From 18b49496a383d3d72339eb837b45263c54fe222f Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Fri, 23 Oct 2026 16:19:19 +0100 Subject: [PATCH 1/2] fix(web): require same-site requests for cookie-authenticated writes and set frame headers on app pages --- .../__tests__/unit/cookie-write-site.test.ts | 440 ++++++++++++++++++ .../unit/proxy-frame-headers.test.ts | 97 ++++ apps/web/app/api/analytics/track/route.ts | 7 +- .../web/app/api/dev-reset-transcript/route.ts | 3 +- apps/web/app/api/invite/accept/route.ts | 3 +- apps/web/app/api/invite/decline/route.ts | 3 +- .../app/api/settings/billing/manage/route.ts | 5 +- .../api/settings/billing/subscribe/route.ts | 3 +- apps/web/app/api/settings/user/name/route.ts | 3 +- apps/web/app/api/utils.ts | 3 +- .../web/app/api/video/comment/delete/route.ts | 3 +- apps/web/app/api/video/metadata/route.ts | 3 +- .../api/videos/[videoId]/retry-ai/route.ts | 7 +- .../[videoId]/retry-transcription/route.ts | 7 +- apps/web/proxy.ts | 42 +- packages/database/auth/request-site.ts | 53 +++ packages/database/package.json | 2 + packages/web-backend/src/Auth.ts | 14 + 18 files changed, 673 insertions(+), 25 deletions(-) create mode 100644 apps/web/__tests__/unit/cookie-write-site.test.ts create mode 100644 apps/web/__tests__/unit/proxy-frame-headers.test.ts create mode 100644 packages/database/auth/request-site.ts diff --git a/apps/web/__tests__/unit/cookie-write-site.test.ts b/apps/web/__tests__/unit/cookie-write-site.test.ts new file mode 100644 index 00000000000..dc8457e911c --- /dev/null +++ b/apps/web/__tests__/unit/cookie-write-site.test.ts @@ -0,0 +1,440 @@ +import { isCrossSiteCookieWrite } from "@cap/database/auth/request-site"; +import { + Database, + getCurrentUser, + HttpAuthMiddlewareLive, + RpcAuthMiddlewareLive, +} from "@cap/web-backend"; +import { + CurrentUser, + DatabaseError, + HttpAuthMiddleware, + RpcAuthMiddleware, +} from "@cap/web-domain"; +import { + HttpApi, + HttpApiBuilder, + HttpApiEndpoint, + HttpApiGroup, + HttpServer, + HttpServerRequest, +} from "@effect/platform"; +import { Rpc, RpcGroup, RpcSerialization, RpcServer } from "@effect/rpc"; +import { Effect, Layer, Option, Schema } from "effect"; +import { Hono } from "hono"; +import { afterAll, beforeEach, describe, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => { + const cookieUser = { + id: "cookie-user", + email: "cookie@example.com", + activeOrganizationId: "org", + image: null, + }; + const keyUser = { + id: "key-user", + email: "key@example.com", + activeOrganizationId: "org", + image: null, + }; + const client = { + select: () => ({ + from: () => ({ + where: async () => [cookieUser], + leftJoin: () => ({ where: async () => [{ users: keyUser }] }), + }), + }), + update: () => ({ set: () => ({ where: async () => undefined }) }), + }; + return { + cookieUser, + keyUser, + client, + getServerSession: vi.fn(async () => ({ user: { id: cookieUser.id } })), + getSessionUser: vi.fn(async () => cookieUser), + }; +}); + +vi.mock("server-only", () => ({})); + +vi.mock("@cap/env", () => ({ + serverEnv: () => ({ WEB_URL: "https://cap.so" }), + buildEnv: { + NEXT_PUBLIC_WEB_URL: "https://cap.so", + NEXT_PUBLIC_IS_CAP: "true", + }, +})); + +vi.mock("@cap/database", () => ({ db: () => mocks.client })); + +vi.mock("@cap/database/auth/auth-options", () => ({ + authOptions: () => ({}), + getServerSession: mocks.getServerSession, +})); + +vi.mock("@cap/database/auth/session", () => ({ + getCurrentUser: mocks.getSessionUser, +})); + +import { POST as updateUserName } from "@/app/api/settings/user/name/route"; +import { withAuth, withOptionalAuth } from "@/app/api/utils"; + +const API_KEY = "0123456789abcdef0123456789abcdef0123"; + +const request = ( + method: string, + headers: Record = {}, + url = "https://cap.so/api/test", +) => + new Request(url, { + method, + headers: { host: new URL(url).host, ...headers }, + }); + +const crossSite = { + "sec-fetch-site": "cross-site", + origin: "https://other.example", +}; +const sameOrigin = { + "sec-fetch-site": "same-origin", + origin: "https://cap.so", +}; +const sameSite = { + "sec-fetch-site": "same-site", + origin: "https://www.cap.so", +}; + +beforeEach(() => { + mocks.getServerSession.mockClear(); + mocks.getSessionUser.mockClear(); +}); + +describe("isCrossSiteCookieWrite", () => { + it.each(["POST", "PUT", "PATCH", "DELETE"])( + "flags a %s with Sec-Fetch-Site cross-site", + (method) => { + expect(isCrossSiteCookieWrite(request(method, crossSite))).toBe(true); + }, + ); + + it("flags a write whose Origin is another site when fetch metadata is missing", () => { + expect( + isCrossSiteCookieWrite( + request("POST", { origin: "https://other.example" }), + ), + ).toBe(true); + expect(isCrossSiteCookieWrite(request("POST", { origin: "null" }))).toBe( + true, + ); + }); + + it.each([ + ["same-origin", sameOrigin], + ["same-site", sameSite], + ["user-initiated", { "sec-fetch-site": "none" }], + ["app origin without fetch metadata", { origin: "https://cap.so" }], + ["no browser headers", {}], + ])("allows a %s write", (_label, headers) => { + expect(isCrossSiteCookieWrite(request("POST", headers))).toBe(false); + }); + + it("treats the request host as an own origin for custom domains and previews", () => { + expect( + isCrossSiteCookieWrite( + request( + "POST", + { origin: "https://videos.customer.example" }, + "https://videos.customer.example/api/analytics/track", + ), + ), + ).toBe(false); + expect( + isCrossSiteCookieWrite( + request( + "POST", + { + origin: "https://cap-preview.vercel.app", + host: "internal:3000", + "x-forwarded-host": "cap-preview.vercel.app", + }, + "http://internal:3000/api/test", + ), + ), + ).toBe(false); + expect( + isCrossSiteCookieWrite({ + method: "POST", + url: "http://localhost:3000/api/test", + headers: new Headers({ origin: "http://localhost:3000" }), + }), + ).toBe(false); + }); + + it("leaves requests with an Authorization header alone", () => { + expect( + isCrossSiteCookieWrite( + request("POST", { ...crossSite, authorization: `Bearer ${API_KEY}` }), + ), + ).toBe(false); + }); + + it.each(["GET", "HEAD", "OPTIONS"])("leaves %s alone", (method) => { + expect(isCrossSiteCookieWrite(request(method, crossSite))).toBe(false); + }); +}); + +describe("Hono cookie fallback", () => { + const app = new Hono() + .post("/required", withAuth, (c) => c.json({ user: c.get("user").id })) + .get("/required", withAuth, (c) => c.json({ user: c.get("user").id })) + .post("/optional", withOptionalAuth, (c) => + c.json({ user: c.get("user")?.id ?? null }), + ); + + const send = ( + path: string, + method: string, + headers: Record, + ) => app.request(request(method, headers, `https://cap.so${path}`)); + + it("does not authenticate a cross-site cookie write", async () => { + const response = await send("/required", "POST", crossSite); + expect(response.status).toBe(401); + expect(mocks.getSessionUser).not.toHaveBeenCalled(); + }); + + it("keeps optional endpoints working anonymously for cross-site writes", async () => { + const response = await send("/optional", "POST", crossSite); + expect(response.status).toBe(200); + expect(await response.json()).toEqual({ user: null }); + }); + + it.each([ + ["same-origin", sameOrigin], + ["same-site", sameSite], + ])("authenticates a %s cookie write", async (_label, headers) => { + const response = await send("/required", "POST", headers); + expect(response.status).toBe(200); + expect(await response.json()).toEqual({ user: "cookie-user" }); + }); + + it("authenticates a bearer write from any site", async () => { + const response = await send("/required", "POST", { + ...crossSite, + authorization: `Bearer ${API_KEY}`, + }); + expect(response.status).toBe(200); + expect(await response.json()).toEqual({ user: "key-user" }); + }); + + it("authenticates a cross-site GET with the cookie", async () => { + const response = await send("/required", "GET", crossSite); + expect(response.status).toBe(200); + expect(await response.json()).toEqual({ user: "cookie-user" }); + }); +}); + +describe("App Router cookie writes", () => { + const rename = (headers: Record) => + updateUserName( + new Request("https://cap.so/api/settings/user/name", { + method: "POST", + headers: { host: "cap.so", ...headers }, + body: JSON.stringify({ firstName: "A", lastName: "B" }), + }) as never, + ); + + it("does not authenticate a cross-site cookie write", async () => { + const response = await rename(crossSite); + expect(response.status).toBe(401); + expect(mocks.getSessionUser).not.toHaveBeenCalled(); + }); + + it("authenticates a same-origin cookie write", async () => { + const response = await rename(sameOrigin); + expect(response.status).toBe(200); + expect(mocks.getSessionUser).toHaveBeenCalledTimes(1); + }); +}); + +const database = Layer.succeed( + Database, + Database.make({ + use: (callback) => + Effect.tryPromise({ + try: () => callback(mocks.client as never), + catch: (cause) => new DatabaseError({ cause }), + }), + }), +); + +describe("Effect getCurrentUser", () => { + const run = (web?: Request) => + Effect.runPromise( + getCurrentUser.pipe( + Effect.map(Option.map((user) => user.id)), + Effect.provide(database), + (effect) => + web + ? Effect.provideService( + effect, + HttpServerRequest.HttpServerRequest, + HttpServerRequest.fromWeb(web), + ) + : effect, + ), + ); + + it("returns no user for a cross-site cookie write", async () => { + expect(await run(request("POST", crossSite))).toEqual(Option.none()); + expect(mocks.getServerSession).not.toHaveBeenCalled(); + }); + + it.each([ + ["same-origin write", request("POST", sameOrigin)], + ["same-site write", request("POST", sameSite)], + ["cross-site GET", request("GET", crossSite)], + [ + "cross-site write with an Authorization header", + request("POST", { ...crossSite, authorization: "Bearer other" }), + ], + ["call outside a request", undefined], + ])("returns the cookie user for a %s", async (_label, web) => { + expect(await run(web)).toEqual(Option.some("cookie-user")); + }); +}); + +const WhoAmI = HttpApiEndpoint.post("whoAmI", "/api/who-am-i") + .addSuccess(Schema.Struct({ user: Schema.String })) + .middleware(HttpAuthMiddleware); +const WhoAmIGet = HttpApiEndpoint.get("whoAmIGet", "/api/who-am-i") + .addSuccess(Schema.Struct({ user: Schema.String })) + .middleware(HttpAuthMiddleware); +class WhoAmIApi extends HttpApi.make("whoAmI").add( + HttpApiGroup.make("whoAmI").add(WhoAmI).add(WhoAmIGet), +) {} + +const httpApi = HttpApiBuilder.api(WhoAmIApi).pipe( + Layer.provide( + HttpApiBuilder.group(WhoAmIApi, "whoAmI", (handlers) => + handlers + .handle("whoAmI", () => + Effect.map(CurrentUser, (user) => ({ user: user.id })), + ) + .handle("whoAmIGet", () => + Effect.map(CurrentUser, (user) => ({ user: user.id })), + ), + ), + ), + Layer.provide(HttpAuthMiddlewareLive), + Layer.provide(database), + Layer.merge(HttpServer.layerContext), + HttpApiBuilder.toWebHandler, +); + +const RpcWhoAmI = Rpc.make("WhoAmI", { success: Schema.String }).middleware( + RpcAuthMiddleware, +); +const rpcGroup = RpcGroup.make(RpcWhoAmI); +const rpcApi = RpcServer.toWebHandler(rpcGroup, { + layer: Layer.mergeAll( + rpcGroup.toLayer({ + WhoAmI: () => Effect.map(CurrentUser, (user) => user.id), + }), + RpcAuthMiddlewareLive, + RpcSerialization.layerJson, + HttpServer.layerContext, + ).pipe(Layer.provide(database)), +}); + +afterAll(async () => { + await httpApi.dispose(); + await rpcApi.dispose(); +}); + +describe("HttpApi auth middleware", () => { + const send = (method: string, headers: Record) => + httpApi.handler(request(method, headers, "https://cap.so/api/who-am-i")); + + it("does not authenticate a cross-site cookie write", async () => { + const response = await send("POST", crossSite); + expect(response.status).toBe(401); + }); + + it.each([ + ["same-origin", sameOrigin], + ["same-site", sameSite], + ])("authenticates a %s cookie write", async (_label, headers) => { + const response = await send("POST", headers); + expect(response.status).toBe(200); + expect(await response.json()).toEqual({ user: "cookie-user" }); + }); + + it("authenticates a bearer write from any site", async () => { + const response = await send("POST", { + ...crossSite, + authorization: `Bearer ${API_KEY}`, + }); + expect(response.status).toBe(200); + expect(await response.json()).toEqual({ user: "key-user" }); + }); + + it("authenticates a cross-site GET with the cookie", async () => { + const response = await send("GET", crossSite); + expect(response.status).toBe(200); + expect(await response.json()).toEqual({ user: "cookie-user" }); + }); +}); + +describe("RPC auth middleware", () => { + const call = async ( + headers: Record, + rpcHeaders: [string, string][] = [], + ) => { + const response = await rpcApi.handler( + new Request("https://cap.so/api/erpc", { + method: "POST", + headers: { + host: "cap.so", + "content-type": "application/json", + ...headers, + }, + body: JSON.stringify({ + _tag: "Request", + id: "1", + tag: "WhoAmI", + payload: {}, + headers: rpcHeaders, + }), + }), + ); + const [message] = (await response.json()) as [ + { exit: { _tag: string; value?: unknown; cause?: unknown } }, + ]; + return message.exit; + }; + + it("does not authenticate a cross-site cookie call", async () => { + const exit = await call(crossSite); + expect(exit._tag).toBe("Failure"); + expect(JSON.stringify(exit.cause)).toContain("UnauthenticatedError"); + }); + + it("reads fetch metadata from the HTTP request, not the RPC payload headers", async () => { + const exit = await call(crossSite, [ + ["sec-fetch-site", "same-origin"], + ["origin", "https://cap.so"], + ]); + expect(exit._tag).toBe("Failure"); + }); + + it.each([ + ["same-origin", sameOrigin], + ["same-site", sameSite], + ])("authenticates a %s cookie call", async (_label, headers) => { + expect(await call(headers)).toEqual({ + _tag: "Success", + value: "cookie-user", + }); + }); +}); diff --git a/apps/web/__tests__/unit/proxy-frame-headers.test.ts b/apps/web/__tests__/unit/proxy-frame-headers.test.ts new file mode 100644 index 00000000000..a7dc56c98c7 --- /dev/null +++ b/apps/web/__tests__/unit/proxy-frame-headers.test.ts @@ -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 = {}) => + 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); + }); +}); diff --git a/apps/web/app/api/analytics/track/route.ts b/apps/web/app/api/analytics/track/route.ts index 9386d1d249a..01746e860d7 100644 --- a/apps/web/app/api/analytics/track/route.ts +++ b/apps/web/app/api/analytics/track/route.ts @@ -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"; @@ -254,7 +255,11 @@ export async function POST(request: NextRequest) { ), ); } - }).pipe(provideOptionalAuth), + }).pipe((trackView) => + isCrossSiteCookieWrite(request) + ? trackView + : provideOptionalAuth(trackView), + ), ); return Response.json({ success: true }); diff --git a/apps/web/app/api/dev-reset-transcript/route.ts b/apps/web/app/api/dev-reset-transcript/route.ts index c8a1d4ff9f3..ce7963d94f4 100644 --- a/apps/web/app/api/dev-reset-transcript/route.ts +++ b/apps/web/app/api/dev-reset-transcript/route.ts @@ -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"; @@ -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 }); } diff --git a/apps/web/app/api/invite/accept/route.ts b/apps/web/app/api/invite/accept/route.ts index 02576e7c261..36dc1eca8db 100644 --- a/apps/web/app/api/invite/accept/route.ts +++ b/apps/web/app/api/invite/accept/route.ts @@ -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"; @@ -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 }); } diff --git a/apps/web/app/api/invite/decline/route.ts b/apps/web/app/api/invite/decline/route.ts index 4bf150e9599..7aad9a133b5 100644 --- a/apps/web/app/api/invite/decline/route.ts +++ b/apps/web/app/api/invite/decline/route.ts @@ -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, @@ -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 }); } diff --git a/apps/web/app/api/settings/billing/manage/route.ts b/apps/web/app/api/settings/billing/manage/route.ts index 8d38fd0ab3d..f57a48d316a 100644 --- a/apps/web/app/api/settings/billing/manage/route.ts +++ b/apps/web/app/api/settings/billing/manage/route.ts @@ -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"; @@ -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) { diff --git a/apps/web/app/api/settings/billing/subscribe/route.ts b/apps/web/app/api/settings/billing/subscribe/route.ts index fe71ca1bbd1..d5c2f2dcf7b 100644 --- a/apps/web/app/api/settings/billing/subscribe/route.ts +++ b/apps/web/app/api/settings/billing/subscribe/route.ts @@ -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"; @@ -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(); diff --git a/apps/web/app/api/settings/user/name/route.ts b/apps/web/app/api/settings/user/name/route.ts index 1d803d849cb..a5a349db9b9 100644 --- a/apps/web/app/api/settings/user/name/route.ts +++ b/apps/web/app/api/settings/user/name/route.ts @@ -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) { diff --git a/apps/web/app/api/utils.ts b/apps/web/app/api/utils.ts index 15897cccd9c..a7aa9710ea6 100644 --- a/apps/web/app/api/utils.ts +++ b/apps/web/app/api/utils.ts @@ -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, @@ -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(); } diff --git a/apps/web/app/api/video/comment/delete/route.ts b/apps/web/app/api/video/comment/delete/route.ts index 46f671827c1..66e362f3f58 100644 --- a/apps/web/app/api/video/comment/delete/route.ts +++ b/apps/web/app/api/video/comment/delete/route.ts @@ -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"; @@ -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), diff --git a/apps/web/app/api/video/metadata/route.ts b/apps/web/app/api/video/metadata/route.ts index 38c5ae9be5b..29b87964ffd 100644 --- a/apps/web/app/api/video/metadata/route.ts +++ b/apps/web/app/api/video/metadata/route.ts @@ -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; diff --git a/apps/web/app/api/videos/[videoId]/retry-ai/route.ts b/apps/web/app/api/videos/[videoId]/retry-ai/route.ts index dad6851b750..eb26330ae7a 100644 --- a/apps/web/app/api/videos/[videoId]/retry-ai/route.ts +++ b/apps/web/app/api/videos/[videoId]/retry-ai/route.ts @@ -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"; @@ -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 }); } diff --git a/apps/web/app/api/videos/[videoId]/retry-transcription/route.ts b/apps/web/app/api/videos/[videoId]/retry-transcription/route.ts index f679c7200f4..00b03f36c0b 100644 --- a/apps/web/app/api/videos/[videoId]/retry-transcription/route.ts +++ b/apps/web/app/api/videos/[videoId]/retry-transcription/route.ts @@ -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 { videos } from "@cap/database/schema"; import type { Video } from "@cap/web-domain"; @@ -6,11 +7,13 @@ 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 }); } diff --git a/apps/web/proxy.ts b/apps/web/proxy.ts index 3f2a43baca6..859667cd95b 100644 --- a/apps/web/proxy.ts +++ b/apps/web/proxy.ts @@ -35,7 +35,39 @@ 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; @@ -43,16 +75,6 @@ export async function proxy(request: NextRequest) { 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, diff --git a/packages/database/auth/request-site.ts b/packages/database/auth/request-site.ts new file mode 100644 index 00000000000..40e17f46a8c --- /dev/null +++ b/packages/database/auth/request-site.ts @@ -0,0 +1,53 @@ +import { serverEnv } from "@cap/env"; + +const READ_ONLY_METHODS = new Set(["GET", "HEAD", "OPTIONS"]); + +export type SiteCheckedRequest = { + method: string; + url: string; + headers: Pick; +}; + +const parseUrl = (value: string | null | undefined) => { + if (!value) return null; + try { + return new URL(value); + } catch { + return null; + } +}; + +const isOwnOrigin = (origin: URL, request: SiteCheckedRequest) => { + const ownHosts = [ + request.headers.get("x-forwarded-host")?.split(",")[0]?.trim(), + request.headers.get("host"), + parseUrl(request.url)?.host, + ]; + if (ownHosts.includes(origin.host)) return true; + + let webUrl: string | undefined; + try { + webUrl = serverEnv().WEB_URL; + } catch { + return false; + } + return parseUrl(webUrl)?.origin === origin.origin; +}; + +export const isCrossSiteCookieWrite = (request: SiteCheckedRequest) => { + if (READ_ONLY_METHODS.has(request.method.toUpperCase())) return false; + if (request.headers.has("authorization")) return false; + + // Sec-Fetch-Site is set by the browser and cannot be forged by page script, + // so it decides when present. Origin is the fallback for clients that + // predate fetch metadata and for plain-http self-hosted installs, where + // browsers do not send Sec-Fetch-* headers. + const fetchSite = request.headers.get("sec-fetch-site"); + if (fetchSite) return fetchSite === "cross-site"; + + const origin = request.headers.get("origin"); + if (!origin) return false; + + const originUrl = parseUrl(origin); + return !originUrl || !isOwnOrigin(originUrl, request); +}; diff --git a/packages/database/package.json b/packages/database/package.json index ae6d39b6cd2..45a780af164 100644 --- a/packages/database/package.json +++ b/packages/database/package.json @@ -60,6 +60,7 @@ ".": "./index.ts", "./auth/auth-options": "./auth/auth-options.ts", "./auth/domain-utils": "./auth/domain-utils.ts", + "./auth/request-site": "./auth/request-site.ts", "./auth/session": "./auth/session.ts", "./auth/sso": "./auth/sso.ts", "./auth/sso-state": "./auth/sso-state.ts", @@ -78,6 +79,7 @@ ".": "./dist/index.js", "./auth/auth-options": "./dist/auth/auth-options.js", "./auth/domain-utils": "./dist/auth/domain-utils.js", + "./auth/request-site": "./dist/auth/request-site.js", "./auth/session": "./dist/auth/session.js", "./schema": "./dist/schema.js", "./crypto": "./dist/crypto.js", diff --git a/packages/web-backend/src/Auth.ts b/packages/web-backend/src/Auth.ts index aa6ebda01e9..cab0346dc83 100644 --- a/packages/web-backend/src/Auth.ts +++ b/packages/web-backend/src/Auth.ts @@ -1,5 +1,6 @@ import { getServerSession } from "@cap/database/auth/auth-options"; import { isBlockedAccountEmail } from "@cap/database/auth/domain-utils"; +import { isCrossSiteCookieWrite } from "@cap/database/auth/request-site"; import * as Db from "@cap/database/schema"; import { CurrentUser, @@ -15,6 +16,19 @@ import { Database } from "./Database.ts"; export const getCurrentUser = Effect.gen(function* () { const db = yield* Database; + const request = yield* Effect.serviceOption( + HttpServerRequest.HttpServerRequest, + ); + + if ( + Option.isSome(request) && + isCrossSiteCookieWrite({ + method: request.value.method, + url: request.value.originalUrl, + headers: new Headers(request.value.headers), + }) + ) + return Option.none(); return yield* Option.fromNullable( yield* Effect.tryPromise(() => getServerSession()), From 59c0f5a3aaa960986cf5b690364eb92c8adc8f10 Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Fri, 23 Oct 2026 16:56:47 +0100 Subject: [PATCH 2/2] fix(web): run same-site check on the cookie-auth fallback regardless of Authorization header --- .../__tests__/unit/cookie-write-site.test.ts | 49 ++++++++++++++++--- packages/database/auth/request-site.ts | 7 ++- 2 files changed, 46 insertions(+), 10 deletions(-) diff --git a/apps/web/__tests__/unit/cookie-write-site.test.ts b/apps/web/__tests__/unit/cookie-write-site.test.ts index dc8457e911c..564ff3db045 100644 --- a/apps/web/__tests__/unit/cookie-write-site.test.ts +++ b/apps/web/__tests__/unit/cookie-write-site.test.ts @@ -170,12 +170,17 @@ describe("isCrossSiteCookieWrite", () => { ).toBe(false); }); - it("leaves requests with an Authorization header alone", () => { + it("still flags a cross-site write that carries an Authorization header", () => { expect( isCrossSiteCookieWrite( request("POST", { ...crossSite, authorization: `Bearer ${API_KEY}` }), ), - ).toBe(false); + ).toBe(true); + expect( + isCrossSiteCookieWrite( + request("POST", { ...crossSite, authorization: "Bearer bogus" }), + ), + ).toBe(true); }); it.each(["GET", "HEAD", "OPTIONS"])("leaves %s alone", (method) => { @@ -203,12 +208,30 @@ describe("Hono cookie fallback", () => { expect(mocks.getSessionUser).not.toHaveBeenCalled(); }); + it("does not authenticate a cross-site cookie write carrying a bogus bearer", async () => { + const response = await send("/required", "POST", { + ...crossSite, + authorization: "Bearer bogus", + }); + expect(response.status).toBe(401); + expect(mocks.getSessionUser).not.toHaveBeenCalled(); + }); + it("keeps optional endpoints working anonymously for cross-site writes", async () => { const response = await send("/optional", "POST", crossSite); expect(response.status).toBe(200); expect(await response.json()).toEqual({ user: null }); }); + it("keeps optional endpoints anonymous for a cross-site write with a bogus bearer", async () => { + const response = await send("/optional", "POST", { + ...crossSite, + authorization: "Bearer bogus", + }); + expect(response.status).toBe(200); + expect(await response.json()).toEqual({ user: null }); + }); + it.each([ ["same-origin", sameOrigin], ["same-site", sameSite], @@ -285,8 +308,14 @@ describe("Effect getCurrentUser", () => { ), ); - it("returns no user for a cross-site cookie write", async () => { - expect(await run(request("POST", crossSite))).toEqual(Option.none()); + it.each([ + ["plain cross-site write", request("POST", crossSite)], + [ + "cross-site write carrying a bogus bearer", + request("POST", { ...crossSite, authorization: "Bearer other" }), + ], + ])("returns no user for a %s", async (_label, web) => { + expect(await run(web)).toEqual(Option.none()); expect(mocks.getServerSession).not.toHaveBeenCalled(); }); @@ -294,10 +323,6 @@ describe("Effect getCurrentUser", () => { ["same-origin write", request("POST", sameOrigin)], ["same-site write", request("POST", sameSite)], ["cross-site GET", request("GET", crossSite)], - [ - "cross-site write with an Authorization header", - request("POST", { ...crossSite, authorization: "Bearer other" }), - ], ["call outside a request", undefined], ])("returns the cookie user for a %s", async (_label, web) => { expect(await run(web)).toEqual(Option.some("cookie-user")); @@ -361,6 +386,14 @@ describe("HttpApi auth middleware", () => { expect(response.status).toBe(401); }); + it("does not authenticate a cross-site cookie write carrying a bogus bearer", async () => { + const response = await send("POST", { + ...crossSite, + authorization: "Bearer bogus", + }); + expect(response.status).toBe(401); + }); + it.each([ ["same-origin", sameOrigin], ["same-site", sameSite], diff --git a/packages/database/auth/request-site.ts b/packages/database/auth/request-site.ts index 40e17f46a8c..c9e82a039bc 100644 --- a/packages/database/auth/request-site.ts +++ b/packages/database/auth/request-site.ts @@ -5,7 +5,7 @@ const READ_ONLY_METHODS = new Set(["GET", "HEAD", "OPTIONS"]); export type SiteCheckedRequest = { method: string; url: string; - headers: Pick; + headers: Pick; }; const parseUrl = (value: string | null | undefined) => { @@ -34,9 +34,12 @@ const isOwnOrigin = (origin: URL, request: SiteCheckedRequest) => { return parseUrl(webUrl)?.origin === origin.origin; }; +// Callers must only consult this on the cookie-auth path: a successfully +// authenticated bearer token returns before the check is reached. The presence +// of an Authorization header is not proof of bearer auth, so it is ignored +// here — an invalid bearer that falls back to the cookie still gets checked. export const isCrossSiteCookieWrite = (request: SiteCheckedRequest) => { if (READ_ONLY_METHODS.has(request.method.toUpperCase())) return false; - if (request.headers.has("authorization")) return false; // Sec-Fetch-Site is set by the browser and cannot be forged by page script, // so it decides when present. Origin is the fallback for clients that