From 0b830e6b0294764fe004ea5dffc27339a75aa818 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 25 Oct 2026 17:12:00 +0100 Subject: [PATCH] fix(http): exempt only a Bearer request without a session cookie from the cross-site check The cross-site write check let through any request that carried an Authorization header, while the caller is resolved from the session cookie before any header. A browser attaches Basic or Negotiate credentials on its own once a proxy or host asked for them, so a cookie-authenticated write from a sibling subdomain could pass. Only Authorization: Bearer is exempt now, and only when no session cookie rides along; a request carrying both is checked as the cookie request it resolves to. The share-link passphrase POST under /api/c/ is exempt by path: its credential is the token in the path and the passphrase in the body, and from the shared view's no-referrer page a browser without Sec-Fetch-Site sends Origin: null, which was refused. --- .../__tests__/api-handler-cross-site.test.ts | 59 +++++++++++++++++++ src/lib/http/cross-site-guard.ts | 31 +++++++--- 2 files changed, 83 insertions(+), 7 deletions(-) diff --git a/src/lib/__tests__/api-handler-cross-site.test.ts b/src/lib/__tests__/api-handler-cross-site.test.ts index 1ee042d8e..c8b8737f4 100644 --- a/src/lib/__tests__/api-handler-cross-site.test.ts +++ b/src/lib/__tests__/api-handler-cross-site.test.ts @@ -114,6 +114,65 @@ describe("apiHandler and a state change from another site", () => { ); }); + it("refuses a browser-attached Basic or Negotiate header riding a session cookie", async () => { + // The browser adds these on its own once a proxy or the host asked for + // them, and the caller is resolved from the cookie first: the header is + // not what the request is authenticated by. + for (const authorization of ["Basic dXNlcjpwYXNz", "Negotiate YIIGhg"]) { + const response = await post({ + "content-type": "application/json", + authorization, + "sec-fetch-site": "same-site", + origin: "https://evil.example.com", + cookie: "healthlog_session=s", + }); + expect(response.status).toBe(403); + } + expect(handler).not.toHaveBeenCalled(); + }); + + it("checks a Bearer request that also carries a session cookie as the cookie request it resolves to", async () => { + for (const cookie of [ + "healthlog_session=s", + "theme=dark; __Host-healthlog_session=s", + ]) { + const response = await post({ + "content-type": "application/json", + authorization: "Bearer hlk_x", + "sec-fetch-site": "cross-site", + cookie, + }); + expect(response.status).toBe(403); + } + expect(handler).not.toHaveBeenCalled(); + // From the app's own page the same pair passes. + const own = await post({ + "content-type": "application/json", + authorization: "Bearer hlk_x", + "sec-fetch-site": "same-origin", + cookie: "healthlog_session=s", + }); + expect(own.status).toBe(200); + }); + + it("lets the share-link passphrase POST through from a no-referrer page", async () => { + // The shared view is served with `Referrer-Policy: no-referrer`; a + // browser without Sec-Fetch-Site then sends `Origin: null`. The token in + // the path and the passphrase in the body are the credential. + const response = await route( + new NextRequest(`${ORIGIN}/api/c/hls_token/unlock`, { + method: "POST", + headers: { + host: "health.example.com", + "content-type": "application/json", + origin: "null", + }, + body: '{"value":1}', + }), + ); + expect(response.status).toBe(200); + }); + it("leaves reads alone", async () => { const response = await route( new NextRequest(`${ORIGIN}/api/measurements`, { diff --git a/src/lib/http/cross-site-guard.ts b/src/lib/http/cross-site-guard.ts index 1dbf72bd1..ee1e4a7bf 100644 --- a/src/lib/http/cross-site-guard.ts +++ b/src/lib/http/cross-site-guard.ts @@ -25,17 +25,28 @@ * - A request with neither header is not from a browser page (the phone * app, a script, a webhook sender) and passes; it carries no cookie a * page could have lent it. - * - A request carrying `Authorization` passes: a page cannot attach that - * header to another origin without a CORS preflight, which this server - * never answers. + * - A request carrying `Authorization: Bearer` and no session cookie + * passes: a page cannot attach that header to another origin without a + * CORS preflight, which this server never answers, and without a session + * cookie the token is what the request is authenticated by. Any other + * scheme does not count: a browser attaches `Basic` or `Negotiate` + * credentials on its own when a proxy or the host asked for them, and + * the caller is resolved from the session cookie before any header + * (`src/lib/auth/guards/caller.ts`), so a request with both is a cookie + * request and is checked as one. * * Exempt by path: the surfaces that authenticate with a secret in a header * or the URL rather than the session cookie, and that a third party's client - * may legitimately call from a browser (a push client's action button). The - * MCP OAuth endpoints, which must accept cross-origin requests by their - * specification, do not run through `apiHandler` at all. + * may legitimately call from a browser (a push client's action button), and + * the share-link passphrase check under `/api/c/`, whose credential is the + * token in the path plus the passphrase in the body. A shared-record page is + * served with `Referrer-Policy: no-referrer`, and a browser without + * `Sec-Fetch-Site` then sends `Origin: null` on its POST, which no host + * check can admit. The MCP OAuth endpoints, which must accept cross-origin + * requests by their specification, do not run through `apiHandler` at all. */ import { apiError } from "@/lib/api-response"; +import { cookieHeaderCarriesSession } from "@/lib/auth/session-cookie"; const SAFE_METHODS = new Set(["GET", "HEAD", "OPTIONS"]); @@ -51,6 +62,7 @@ export const CROSS_SITE_EXEMPT_PREFIXES = [ "/api/internal/deploy-webhook", "/api/notifications/action/", "/api/monitoring/csp-report", + "/api/c/", ] as const; function hostOf(value: string | null | undefined): string | null { @@ -92,7 +104,12 @@ export function crossSiteWriteReason( if (CROSS_SITE_EXEMPT_PREFIXES.some((p) => pathname.startsWith(p))) { return null; } - if (request.headers.has("authorization")) return null; + if ( + request.headers.get("authorization")?.startsWith("Bearer ") && + !cookieHeaderCarriesSession(request.headers.get("cookie")) + ) { + return null; + } const site = request.headers.get("sec-fetch-site")?.trim().toLowerCase(); if (site) {