Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
473 changes: 473 additions & 0 deletions apps/web/__tests__/unit/cookie-write-site.test.ts

Large diffs are not rendered by default.

97 changes: 97 additions & 0 deletions apps/web/__tests__/unit/proxy-frame-headers.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
import { NextRequest } from "next/server";
import { describe, expect, it, vi } from "vitest";
import { proxy } from "../../proxy";

vi.mock("@cap/database", () => ({
db: () => ({
select: () => ({ from: () => ({ where: async () => [] }) }),
}),
}));

vi.mock("@cap/database/schema", () => ({ organizations: {} }));

vi.mock("@cap/env", () => ({
buildEnv: { NEXT_PUBLIC_IS_CAP: "true" },
serverEnv: () => ({
WEB_URL: "https://cap.so",
VERCEL_URL_HOST: undefined,
VERCEL_BRANCH_URL_HOST: undefined,
VERCEL_PROJECT_PRODUCTION_URL_HOST: undefined,
}),
}));

const send = (url: string, headers: Record<string, string> = {}) =>
proxy(new NextRequest(url, { headers }));

const frameAncestors = (response: Response) =>
[...response.headers.values()].filter((value) =>
value.startsWith("frame-ancestors"),
);

const expectFrameHeaders = (response: Response) => {
expect(response.headers.get("x-frame-options")).toBe("SAMEORIGIN");
expect(frameAncestors(response)).toEqual(["frame-ancestors https://cap.so"]);
};

const expectNoFrameHeaders = (response: Response) => {
expect(response.headers.get("x-frame-options")).toBeNull();
expect(frameAncestors(response)).toEqual([]);
};

describe("proxy frame headers", () => {
it.each([
"/login",
"/dashboard",
"/dashboard/caps",
"/dashboard/settings/account",
"/dashboard/settings/organization/billing",
"/onboarding",
"/onboarding/organization-setup",
"/invite/invite-id",
"/cli/authorize",
"/mcp/authorize",
])("sets frame headers on %s", async (path) => {
const response = await send(`https://cap.so${path}`);
expect(response.status).toBe(200);
expectFrameHeaders(response);
});

it.each([
"/",
"/s/video-id",
"/embed/video-id",
"/c/collection-id",
"/pricing",
"/blog/some-post",
"/cli/complete",
"/dashboards",
"/invited",
])("leaves %s without frame headers", async (path) => {
const response = await send(`https://cap.so${path}`);
expectNoFrameHeaders(response);
});

it("leaves the share iframe redirect without frame headers", async () => {
const response = await send("https://cap.so/s/video-id", {
"sec-fetch-dest": "iframe",
});
expect(response.headers.get("location")).toBe(
"https://cap.so/embed/video-id",
);
expectNoFrameHeaders(response);
});

it("keeps redirecting app pages on custom domains to the main origin", async () => {
const response = await send("https://videos.customer.example/dashboard");
expect(response.headers.get("location")).toBe("https://cap.so/dashboard");
expectFrameHeaders(response);
});

it("keeps serving share pages on verified custom domains without frame headers", async () => {
const response = await send("https://videos.customer.example/s/video-id", {
cookie: "verified_domain=videos.customer.example",
});
expect(response.status).toBe(200);
expectNoFrameHeaders(response);
});
});
7 changes: 6 additions & 1 deletion apps/web/app/api/analytics/track/route.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { db } from "@cap/database";
import { isCrossSiteCookieWrite } from "@cap/database/auth/request-site";
import { videos, videoUploads } from "@cap/database/schema";
import { provideOptionalAuth, Tinybird } from "@cap/web-backend";
import { CurrentUser, Video } from "@cap/web-domain";
Expand Down Expand Up @@ -254,7 +255,11 @@ export async function POST(request: NextRequest) {
),
);
}
}).pipe(provideOptionalAuth),
}).pipe((trackView) =>
isCrossSiteCookieWrite(request)
? trackView
: provideOptionalAuth(trackView),
),
);

return Response.json({ success: true });
Expand Down
3 changes: 2 additions & 1 deletion apps/web/app/api/dev-reset-transcript/route.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { db } from "@cap/database";
import { isCrossSiteCookieWrite } from "@cap/database/auth/request-site";
import { getCurrentUser } from "@cap/database/auth/session";
import { videoEdits, videos, videoUploads } from "@cap/database/schema";
import { Storage } from "@cap/web-backend";
Expand All @@ -15,7 +16,7 @@ export async function POST(request: NextRequest) {
if (request.headers.get("origin") !== request.nextUrl.origin) {
return Response.json({ error: "same origin required" }, { status: 403 });
}
const user = await getCurrentUser();
const user = isCrossSiteCookieWrite(request) ? null : await getCurrentUser();
if (!user) {
return Response.json({ error: "unauthorized" }, { status: 401 });
}
Expand Down
3 changes: 2 additions & 1 deletion apps/web/app/api/invite/accept/route.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { db } from "@cap/database";
import { isCrossSiteCookieWrite } from "@cap/database/auth/request-site";
import { getCurrentUser } from "@cap/database/auth/session";
import { nanoId } from "@cap/database/helpers";
import { enqueueLoopsSync } from "@cap/database/loops/queue";
Expand All @@ -17,7 +18,7 @@ import {
} from "@/utils/organization";

export async function POST(request: NextRequest) {
const user = await getCurrentUser();
const user = isCrossSiteCookieWrite(request) ? null : await getCurrentUser();
if (!user) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
Expand Down
3 changes: 2 additions & 1 deletion apps/web/app/api/invite/decline/route.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { db } from "@cap/database";
import { isCrossSiteCookieWrite } from "@cap/database/auth/request-site";
import { getCurrentUser } from "@cap/database/auth/session";
import {
organizationInvites,
Expand All @@ -12,7 +13,7 @@ import { and, eq, inArray } from "drizzle-orm";
import { type NextRequest, NextResponse } from "next/server";

export async function POST(request: NextRequest) {
const user = await getCurrentUser();
const user = isCrossSiteCookieWrite(request) ? null : await getCurrentUser();
if (!user) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
Expand Down
5 changes: 3 additions & 2 deletions apps/web/app/api/settings/billing/manage/route.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { db } from "@cap/database";
import { isCrossSiteCookieWrite } from "@cap/database/auth/request-site";
import { getCurrentUser } from "@cap/database/auth/session";
import { users } from "@cap/database/schema";
import { serverEnv } from "@cap/env";
Expand All @@ -7,8 +8,8 @@ import { eq } from "drizzle-orm";
import { type NextRequest, NextResponse } from "next/server";
import type Stripe from "stripe";

export async function POST(_request: NextRequest) {
const user = await getCurrentUser();
export async function POST(request: NextRequest) {
const user = isCrossSiteCookieWrite(request) ? null : await getCurrentUser();
let customerId = user?.stripeCustomerId;

if (!user) {
Expand Down
3 changes: 2 additions & 1 deletion apps/web/app/api/settings/billing/subscribe/route.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { db } from "@cap/database";
import { isCrossSiteCookieWrite } from "@cap/database/auth/request-site";
import { getCurrentUser } from "@cap/database/auth/session";
import { users } from "@cap/database/schema";
import { serverEnv } from "@cap/env";
Expand All @@ -10,7 +11,7 @@ import { parseCheckoutQuantity } from "@/lib/checkout-quantity";
import { trackServerEvent } from "@/lib/server-analytics";

export async function POST(request: NextRequest) {
const user = await getCurrentUser();
const user = isCrossSiteCookieWrite(request) ? null : await getCurrentUser();
let customerId = user?.stripeCustomerId;
const { priceId, quantity, isOnBoarding } = await request.json();

Expand Down
3 changes: 2 additions & 1 deletion apps/web/app/api/settings/user/name/route.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
import { db } from "@cap/database";
import { isCrossSiteCookieWrite } from "@cap/database/auth/request-site";
import { getCurrentUser } from "@cap/database/auth/session";
import { users } from "@cap/database/schema";
import { eq } from "drizzle-orm";
import type { NextRequest } from "next/server";

export async function POST(request: NextRequest) {
const user = await getCurrentUser();
const user = isCrossSiteCookieWrite(request) ? null : await getCurrentUser();
const { firstName, lastName } = await request.json();

if (!user) {
Expand Down
3 changes: 2 additions & 1 deletion apps/web/app/api/utils.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { db } from "@cap/database";
import { isBlockedAccountEmail } from "@cap/database/auth/domain-utils";
import { isCrossSiteCookieWrite } from "@cap/database/auth/request-site";
import { getCurrentUser } from "@cap/database/auth/session";
import {
authApiKeys,
Expand Down Expand Up @@ -93,7 +94,7 @@ async function getAuth(c: Context) {
.where(eq(authApiKeys.id, authHeader));
user = res[0]?.users;
if (user && isBlockedAccountEmail(user.email)) user = undefined;
} else {
} else if (!isCrossSiteCookieWrite(c.req.raw)) {
user = await getCurrentUser();
}

Expand Down
3 changes: 2 additions & 1 deletion apps/web/app/api/video/comment/delete/route.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { db } from "@cap/database";
import { isCrossSiteCookieWrite } from "@cap/database/auth/request-site";
import { getCurrentUser } from "@cap/database/auth/session";
import { comments } from "@cap/database/schema";
import { Comment } from "@cap/web-domain";
Expand All @@ -8,7 +9,7 @@ import type { NextRequest } from "next/server";
import { getHeaders } from "@/utils/helpers";

export async function DELETE(request: NextRequest) {
const user = await getCurrentUser();
const user = isCrossSiteCookieWrite(request) ? null : await getCurrentUser();
const { searchParams } = request.nextUrl;
const commentId = Option.fromNullable(searchParams.get("commentId")).pipe(
Option.map(Comment.CommentId.make),
Expand Down
3 changes: 2 additions & 1 deletion apps/web/app/api/video/metadata/route.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
import { db } from "@cap/database";
import { isCrossSiteCookieWrite } from "@cap/database/auth/request-site";
import { getCurrentUser } from "@cap/database/auth/session";
import { videos } from "@cap/database/schema";
import { eq } from "drizzle-orm";
import type { NextRequest } from "next/server";

export async function PUT(request: NextRequest) {
const user = await getCurrentUser();
const user = isCrossSiteCookieWrite(request) ? null : await getCurrentUser();
const { videoId, metadata } = await request.json();
const userId = user?.id as string;

Expand Down
7 changes: 5 additions & 2 deletions apps/web/app/api/videos/[videoId]/retry-ai/route.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { db } from "@cap/database";
import { isCrossSiteCookieWrite } from "@cap/database/auth/request-site";
import { getCurrentUser } from "@cap/database/auth/session";
import { users, videos } from "@cap/database/schema";
import type { VideoMetadata } from "@cap/database/types";
Expand All @@ -12,11 +13,13 @@ const LEGACY_AI_SUMMARY_FALLBACK =
"The AI was unable to generate a proper summary for this content.";

export async function POST(
_request: Request,
request: Request,
props: RouteContext<"/api/videos/[videoId]/retry-ai">,
) {
try {
const user = await getCurrentUser();
const user = isCrossSiteCookieWrite(request)
? null
: await getCurrentUser();
if (!user) {
return Response.json({ error: "Unauthorized" }, { status: 401 });
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,16 +1,19 @@
import { db } from "@cap/database";
import { isCrossSiteCookieWrite } from "@cap/database/auth/request-site";
import { getCurrentUser } from "@cap/database/auth/session";
import { videos } from "@cap/database/schema";
import type { Video } from "@cap/web-domain";
import { eq } from "drizzle-orm";
import { revalidatePath } from "next/cache";

export async function POST(
_request: Request,
request: Request,
props: RouteContext<"/api/videos/[videoId]/retry-transcription">,
) {
try {
const user = await getCurrentUser();
const user = isCrossSiteCookieWrite(request)
? null
: await getCurrentUser();
if (!user) {
return Response.json({ error: "Unauthorized" }, { status: 401 });
}
Expand Down
42 changes: 32 additions & 10 deletions apps/web/proxy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,24 +35,46 @@ const mainOrigins = [
addHttps(serverEnv().VERCEL_PROJECT_PRODUCTION_URL_HOST),
].filter(Boolean) as string[];

const FRAME_PROTECTED_PATHS = [
"/dashboard",
"/onboarding",
"/invite",
"/cli/authorize",
"/mcp/authorize",
];

const isFrameProtectedPath = (path: string) =>
FRAME_PROTECTED_PATHS.some(
(protectedPath) =>
path === protectedPath || path.startsWith(`${protectedPath}/`),
);

const setFrameHeaders = (response: NextResponse) => {
response.headers.set("X-Frame-Options", "SAMEORIGIN");
response.headers.set(
"Content-Security-Policy",
"frame-ancestors https://cap.so",
);
return response;
};

export async function proxy(request: NextRequest) {
const path = new URL(request.url).pathname;

if (path.startsWith("/login")) return setFrameHeaders(NextResponse.next());

const response = await route(request);
return isFrameProtectedPath(path) ? setFrameHeaders(response) : response;
}

async function route(request: NextRequest) {
const url = new URL(request.url);
const path = url.pathname;

if (path === "/" && request.cookies.has("next-auth.session-token")) {
return NextResponse.redirect(new URL("/dashboard/caps", url.origin));
}

if (path.startsWith("/login")) {
const response = NextResponse.next();
response.headers.set("X-Frame-Options", "SAMEORIGIN");
response.headers.set(
"Content-Security-Policy",
"frame-ancestors https://cap.so",
);
return response;
}

const shareIframeRedirectUrl = getShareIframeRedirectUrl({
method: request.method,
requestUrl: request.url,
Expand Down
Loading
Loading