Skip to content

Commit dbdf7cd

Browse files
Merge pull request #2997 from CapSoftware/fix/same-site-cookie-writes
fix(web): require same-site requests for cookie-authenticated writes and set frame headers on app pages
2 parents 1a6b644 + 3beb785 commit dbdf7cd

18 files changed

Lines changed: 709 additions & 25 deletions

File tree

‎apps/web/__tests__/unit/cookie-write-site.test.ts‎

Lines changed: 473 additions & 0 deletions
Large diffs are not rendered by default.
Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
import { NextRequest } from "next/server";
2+
import { describe, expect, it, vi } from "vitest";
3+
import { proxy } from "../../proxy";
4+
5+
vi.mock("@cap/database", () => ({
6+
db: () => ({
7+
select: () => ({ from: () => ({ where: async () => [] }) }),
8+
}),
9+
}));
10+
11+
vi.mock("@cap/database/schema", () => ({ organizations: {} }));
12+
13+
vi.mock("@cap/env", () => ({
14+
buildEnv: { NEXT_PUBLIC_IS_CAP: "true" },
15+
serverEnv: () => ({
16+
WEB_URL: "https://cap.so",
17+
VERCEL_URL_HOST: undefined,
18+
VERCEL_BRANCH_URL_HOST: undefined,
19+
VERCEL_PROJECT_PRODUCTION_URL_HOST: undefined,
20+
}),
21+
}));
22+
23+
const send = (url: string, headers: Record<string, string> = {}) =>
24+
proxy(new NextRequest(url, { headers }));
25+
26+
const frameAncestors = (response: Response) =>
27+
[...response.headers.values()].filter((value) =>
28+
value.startsWith("frame-ancestors"),
29+
);
30+
31+
const expectFrameHeaders = (response: Response) => {
32+
expect(response.headers.get("x-frame-options")).toBe("SAMEORIGIN");
33+
expect(frameAncestors(response)).toEqual(["frame-ancestors https://cap.so"]);
34+
};
35+
36+
const expectNoFrameHeaders = (response: Response) => {
37+
expect(response.headers.get("x-frame-options")).toBeNull();
38+
expect(frameAncestors(response)).toEqual([]);
39+
};
40+
41+
describe("proxy frame headers", () => {
42+
it.each([
43+
"/login",
44+
"/dashboard",
45+
"/dashboard/caps",
46+
"/dashboard/settings/account",
47+
"/dashboard/settings/organization/billing",
48+
"/onboarding",
49+
"/onboarding/organization-setup",
50+
"/invite/invite-id",
51+
"/cli/authorize",
52+
"/mcp/authorize",
53+
])("sets frame headers on %s", async (path) => {
54+
const response = await send(`https://cap.so${path}`);
55+
expect(response.status).toBe(200);
56+
expectFrameHeaders(response);
57+
});
58+
59+
it.each([
60+
"/",
61+
"/s/video-id",
62+
"/embed/video-id",
63+
"/c/collection-id",
64+
"/pricing",
65+
"/blog/some-post",
66+
"/cli/complete",
67+
"/dashboards",
68+
"/invited",
69+
])("leaves %s without frame headers", async (path) => {
70+
const response = await send(`https://cap.so${path}`);
71+
expectNoFrameHeaders(response);
72+
});
73+
74+
it("leaves the share iframe redirect without frame headers", async () => {
75+
const response = await send("https://cap.so/s/video-id", {
76+
"sec-fetch-dest": "iframe",
77+
});
78+
expect(response.headers.get("location")).toBe(
79+
"https://cap.so/embed/video-id",
80+
);
81+
expectNoFrameHeaders(response);
82+
});
83+
84+
it("keeps redirecting app pages on custom domains to the main origin", async () => {
85+
const response = await send("https://videos.customer.example/dashboard");
86+
expect(response.headers.get("location")).toBe("https://cap.so/dashboard");
87+
expectFrameHeaders(response);
88+
});
89+
90+
it("keeps serving share pages on verified custom domains without frame headers", async () => {
91+
const response = await send("https://videos.customer.example/s/video-id", {
92+
cookie: "verified_domain=videos.customer.example",
93+
});
94+
expect(response.status).toBe(200);
95+
expectNoFrameHeaders(response);
96+
});
97+
});

‎apps/web/app/api/analytics/track/route.ts‎

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { db } from "@cap/database";
2+
import { isCrossSiteCookieWrite } from "@cap/database/auth/request-site";
23
import { videos, videoUploads } from "@cap/database/schema";
34
import { provideOptionalAuth, Tinybird } from "@cap/web-backend";
45
import { CurrentUser, Video } from "@cap/web-domain";
@@ -254,7 +255,11 @@ export async function POST(request: NextRequest) {
254255
),
255256
);
256257
}
257-
}).pipe(provideOptionalAuth),
258+
}).pipe((trackView) =>
259+
isCrossSiteCookieWrite(request)
260+
? trackView
261+
: provideOptionalAuth(trackView),
262+
),
258263
);
259264

260265
return Response.json({ success: true });

‎apps/web/app/api/dev-reset-transcript/route.ts‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { db } from "@cap/database";
2+
import { isCrossSiteCookieWrite } from "@cap/database/auth/request-site";
23
import { getCurrentUser } from "@cap/database/auth/session";
34
import { videoEdits, videos, videoUploads } from "@cap/database/schema";
45
import { Storage } from "@cap/web-backend";
@@ -15,7 +16,7 @@ export async function POST(request: NextRequest) {
1516
if (request.headers.get("origin") !== request.nextUrl.origin) {
1617
return Response.json({ error: "same origin required" }, { status: 403 });
1718
}
18-
const user = await getCurrentUser();
19+
const user = isCrossSiteCookieWrite(request) ? null : await getCurrentUser();
1920
if (!user) {
2021
return Response.json({ error: "unauthorized" }, { status: 401 });
2122
}

‎apps/web/app/api/invite/accept/route.ts‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { db } from "@cap/database";
2+
import { isCrossSiteCookieWrite } from "@cap/database/auth/request-site";
23
import { getCurrentUser } from "@cap/database/auth/session";
34
import { nanoId } from "@cap/database/helpers";
45
import { enqueueLoopsSync } from "@cap/database/loops/queue";
@@ -17,7 +18,7 @@ import {
1718
} from "@/utils/organization";
1819

1920
export async function POST(request: NextRequest) {
20-
const user = await getCurrentUser();
21+
const user = isCrossSiteCookieWrite(request) ? null : await getCurrentUser();
2122
if (!user) {
2223
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
2324
}

‎apps/web/app/api/invite/decline/route.ts‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { db } from "@cap/database";
2+
import { isCrossSiteCookieWrite } from "@cap/database/auth/request-site";
23
import { getCurrentUser } from "@cap/database/auth/session";
34
import {
45
organizationInvites,
@@ -12,7 +13,7 @@ import { and, eq, inArray } from "drizzle-orm";
1213
import { type NextRequest, NextResponse } from "next/server";
1314

1415
export async function POST(request: NextRequest) {
15-
const user = await getCurrentUser();
16+
const user = isCrossSiteCookieWrite(request) ? null : await getCurrentUser();
1617
if (!user) {
1718
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
1819
}

‎apps/web/app/api/settings/billing/manage/route.ts‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { db } from "@cap/database";
2+
import { isCrossSiteCookieWrite } from "@cap/database/auth/request-site";
23
import { getCurrentUser } from "@cap/database/auth/session";
34
import { users } from "@cap/database/schema";
45
import { serverEnv } from "@cap/env";
@@ -7,8 +8,8 @@ import { eq } from "drizzle-orm";
78
import { type NextRequest, NextResponse } from "next/server";
89
import type Stripe from "stripe";
910

10-
export async function POST(_request: NextRequest) {
11-
const user = await getCurrentUser();
11+
export async function POST(request: NextRequest) {
12+
const user = isCrossSiteCookieWrite(request) ? null : await getCurrentUser();
1213
let customerId = user?.stripeCustomerId;
1314

1415
if (!user) {

‎apps/web/app/api/settings/billing/subscribe/route.ts‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { db } from "@cap/database";
2+
import { isCrossSiteCookieWrite } from "@cap/database/auth/request-site";
23
import { getCurrentUser } from "@cap/database/auth/session";
34
import { users } from "@cap/database/schema";
45
import { serverEnv } from "@cap/env";
@@ -10,7 +11,7 @@ import { parseCheckoutQuantity } from "@/lib/checkout-quantity";
1011
import { trackServerEvent } from "@/lib/server-analytics";
1112

1213
export async function POST(request: NextRequest) {
13-
const user = await getCurrentUser();
14+
const user = isCrossSiteCookieWrite(request) ? null : await getCurrentUser();
1415
let customerId = user?.stripeCustomerId;
1516
const { priceId, quantity, isOnBoarding } = await request.json();
1617

‎apps/web/app/api/settings/user/name/route.ts‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,12 @@
11
import { db } from "@cap/database";
2+
import { isCrossSiteCookieWrite } from "@cap/database/auth/request-site";
23
import { getCurrentUser } from "@cap/database/auth/session";
34
import { users } from "@cap/database/schema";
45
import { eq } from "drizzle-orm";
56
import type { NextRequest } from "next/server";
67

78
export async function POST(request: NextRequest) {
8-
const user = await getCurrentUser();
9+
const user = isCrossSiteCookieWrite(request) ? null : await getCurrentUser();
910
const { firstName, lastName } = await request.json();
1011

1112
if (!user) {

‎apps/web/app/api/utils.ts‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { db } from "@cap/database";
22
import { isBlockedAccountEmail } from "@cap/database/auth/domain-utils";
3+
import { isCrossSiteCookieWrite } from "@cap/database/auth/request-site";
34
import { getCurrentUser } from "@cap/database/auth/session";
45
import {
56
authApiKeys,
@@ -93,7 +94,7 @@ async function getAuth(c: Context) {
9394
.where(eq(authApiKeys.id, authHeader));
9495
user = res[0]?.users;
9596
if (user && isBlockedAccountEmail(user.email)) user = undefined;
96-
} else {
97+
} else if (!isCrossSiteCookieWrite(c.req.raw)) {
9798
user = await getCurrentUser();
9899
}
99100

0 commit comments

Comments
 (0)