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.
This commit is contained in:
Marc-André Bombeck committed 2026-10-25 17:44:31 +01:00
1 parent 5a875d2840
commit 0b830e6b02
2 files changed
+83 -7

No files matched your search

@@ -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`, {
+24 -7
View File
@@ -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) {