From 77ace106b370177e4deaddad861178aa84151a69 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 25 Oct 2026 12:54:30 +0100 Subject: [PATCH] fix(http): refuse state changes a browser sends from another site The session cookie is SameSite=Lax, and SameSite compares the registrable domain, so a page on a sibling subdomain of the instance could send a CORS-simple POST with the person's cookie attached. safeJson accepted any Content-Type containing "application/json", so a text/plain body that mentions it in a parameter, which needs no preflight, was parsed as the person's own request: readings added, entries deleted, integrations disconnected, a delegate invited within the re-proof window. apiHandler now refuses a POST, PUT, PATCH or DELETE before authentication when the browser marks it Sec-Fetch-Site same-site or cross-site, or, from a browser that sends no fetch metadata, when its Origin names a host other than the one the request went to or APP_URL. A request with an Authorization header, or with neither header (the phone app, scripts, webhook senders), is unaffected, as are the webhook and URL-credential routes that authenticate with their own secret. The JSON media type is compared exactly, with parameters allowed, in safeJson and in every route that branched on a substring of it. --- docs/api/openapi.yaml | 46 +++--- .../notifications/reminder-check/route.ts | 11 +- .../api/auth/passkey/login-options/route.ts | 9 +- .../auth/passkey/register-options/route.ts | 3 +- src/app/api/consent/ai/web/route.ts | 4 +- src/app/api/dawarich/disconnect/route.ts | 9 +- src/app/api/dawarich/sync/route.ts | 3 +- .../documents/inbound/[id]/extract/route.ts | 3 +- .../api/documents/inbound/[id]/index/route.ts | 3 +- .../documents/inbound/[id]/suggest/route.ts | 3 +- .../documents/inbound/[id]/summary/route.ts | 3 +- src/app/api/labs/ocr/extract/route.ts | 3 +- .../intake/dose-history-import/route.ts | 3 +- .../__tests__/api-handler-cross-site.test.ts | 141 ++++++++++++++++++ src/lib/api-handler.ts | 25 +++- src/lib/api-response.ts | 14 +- src/lib/auth/existing-factor-proof.ts | 4 +- .../http/__tests__/cross-site-refusal.test.ts | 75 ++++++++++ src/lib/http/cross-site-guard.ts | 120 +++++++++++++++ src/lib/openapi/error-codes.ts | 1 + 20 files changed, 436 insertions(+), 47 deletions(-) create mode 100644 src/lib/__tests__/api-handler-cross-site.test.ts create mode 100644 src/lib/http/__tests__/cross-site-refusal.test.ts create mode 100644 src/lib/http/cross-site-guard.ts diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 9255e81e3..9af1fe4af 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -38581,29 +38581,29 @@ components: profile.update.invalidBody, profile.update.nothingSaved; profile_claim — profile_claim.invalid, profile_claim.oidc_only_unsupported, profile_claim.sso_unavailable, profile_claim.taken; record_write — record_write.rate_limited; report-selection — report-selection.body.invalid_json, - report-selection.body.invalid_shape, report-selection.leaves.unknown; route — route.retired; search — - search.invalid; security_activity — security_activity.invalid; share-link — - share-link.document.decryptFailed, share-link.document.notFound, share-link.document.rateLimited, - share-link.documents.invalid, share-link.report.notFound, share-link.report.rateLimited, - share-link.selection.forbidden_leaf, share-link.selection.unknown_leaf; sharing — - sharing.accept.expired, sharing.accept.not_found, sharing.accept.not_pending, sharing.accept.revoked, - sharing.access.denied, sharing.invite.duplicate, sharing.invite.invalid, sharing.invite.invalid_scope, - sharing.invite.manage_browser_only, sharing.invite.no_account, sharing.invite.refused, - sharing.invite.self, sharing.not_permitted, sharing.renounce.already_ended, sharing.renounce.not_found, - sharing.revoke.already_ended, sharing.revoke.not_found, sharing.session.changed, sharing.switch.invalid, - sharing.switch.wrong_transport; source-priority — source-priority.body.invalid_json, - source-priority.body.invalid_shape; symptoms — symptoms.definition.limitReached, - symptoms.definition.notFound, symptoms.episode.notOpen, symptoms.event.notFound, symptoms.invalid; - timeline — timeline.invalid; tokens — tokens.documents.ceiling_reached, - tokens.measurements.ceiling_reached, tokens.mirror.not_measurement_token, - tokens.workouts.ceiling_reached; vaccination — vaccination.booster-invalid, - vaccination.booster-no-antigen, vaccination.booster-out-of-scope, vaccination.custom-vaccine-not-found, - vaccination.custom.invalid, vaccination.custom.name-taken, vaccination.custom.not-found, - vaccination.encounter-not-found, vaccination.identity-required, vaccination.invalid, - vaccination.link.invalid, vaccination.not-found, vaccination.practitioner-not-found; workout — - workout.batch.invalid, workout.batch.payload_too_large, workout.batch.source_not_permitted, - workout.batch.too_large, workout.delete.synced_source; (unprefixed) — about_me_conflict, - ai_response_truncated, coach_prefs_conflict, dashboard_layout_conflict, empty_file, + report-selection.body.invalid_shape, report-selection.leaves.unknown; request — request.cross_site; + route — route.retired; search — search.invalid; security_activity — security_activity.invalid; + share-link — share-link.document.decryptFailed, share-link.document.notFound, + share-link.document.rateLimited, share-link.documents.invalid, share-link.report.notFound, + share-link.report.rateLimited, share-link.selection.forbidden_leaf, share-link.selection.unknown_leaf; + sharing — sharing.accept.expired, sharing.accept.not_found, sharing.accept.not_pending, + sharing.accept.revoked, sharing.access.denied, sharing.invite.duplicate, sharing.invite.invalid, + sharing.invite.invalid_scope, sharing.invite.manage_browser_only, sharing.invite.no_account, + sharing.invite.refused, sharing.invite.self, sharing.not_permitted, sharing.renounce.already_ended, + sharing.renounce.not_found, sharing.revoke.already_ended, sharing.revoke.not_found, + sharing.session.changed, sharing.switch.invalid, sharing.switch.wrong_transport; source-priority — + source-priority.body.invalid_json, source-priority.body.invalid_shape; symptoms — + symptoms.definition.limitReached, symptoms.definition.notFound, symptoms.episode.notOpen, + symptoms.event.notFound, symptoms.invalid; timeline — timeline.invalid; tokens — + tokens.documents.ceiling_reached, tokens.measurements.ceiling_reached, + tokens.mirror.not_measurement_token, tokens.workouts.ceiling_reached; vaccination — + vaccination.booster-invalid, vaccination.booster-no-antigen, vaccination.booster-out-of-scope, + vaccination.custom-vaccine-not-found, vaccination.custom.invalid, vaccination.custom.name-taken, + vaccination.custom.not-found, vaccination.encounter-not-found, vaccination.identity-required, + vaccination.invalid, vaccination.link.invalid, vaccination.not-found, + vaccination.practitioner-not-found; workout — workout.batch.invalid, workout.batch.payload_too_large, + workout.batch.source_not_permitted, workout.batch.too_large, workout.delete.synced_source; (unprefixed) + — about_me_conflict, ai_response_truncated, coach_prefs_conflict, dashboard_layout_conflict, empty_file, health_score_config_conflict, insights_layout_conflict, invalid_base_updated_at, json_carries_no_intake_time, json_not_an_array, medication_layout_conflict, missing_required_columns, modules_conflict, mood_tag_layout_conflict, not_configured, notification_prefs_conflict, oidc_only, diff --git a/src/app/api/admin/notifications/reminder-check/route.ts b/src/app/api/admin/notifications/reminder-check/route.ts index 736677301..b71c26475 100644 --- a/src/app/api/admin/notifications/reminder-check/route.ts +++ b/src/app/api/admin/notifications/reminder-check/route.ts @@ -3,7 +3,12 @@ import type { NextRequest } from "next/server"; import { prisma } from "@/lib/db"; import { apiHandler, requireAdmin } from "@/lib/api-handler"; import { annotate, getEvent } from "@/lib/logging/context"; -import { apiSuccess, returnAllZodIssues, safeJson } from "@/lib/api-response"; +import { + apiSuccess, + returnAllZodIssues, + safeJson, + isJsonContentType, +} from "@/lib/api-response"; import { adminReminderCheckSchema } from "@/lib/validations/notifications"; import { parseScheduleRecurrence } from "@/lib/medication-schedule"; import { dispatchLocalisedNotification } from "@/lib/notifications/dispatch-localised"; @@ -76,9 +81,7 @@ export const POST = apiHandler(async (request: NextRequest) => { // body and no content-type), so the parse only runs when a JSON body is // actually presented. An empty object is the same instance-wide sweep. let input: unknown = {}; - if ( - (request.headers.get("content-type") ?? "").includes("application/json") - ) { + if (isJsonContentType(request.headers.get("content-type"))) { const { data, error } = await safeJson(request, { maxBytes: 4 * 1024 }); if (error) return error; input = data ?? {}; diff --git a/src/app/api/auth/passkey/login-options/route.ts b/src/app/api/auth/passkey/login-options/route.ts index ae1655b5a..7fb0bd8e6 100644 --- a/src/app/api/auth/passkey/login-options/route.ts +++ b/src/app/api/auth/passkey/login-options/route.ts @@ -1,5 +1,10 @@ import { createAuthenticationOptions } from "@/lib/auth/passkey"; -import { apiSuccess, apiError, safeJson } from "@/lib/api-response"; +import { + apiSuccess, + apiError, + safeJson, + isJsonContentType, +} from "@/lib/api-response"; import { checkAuthSurfaceRateLimit, rateLimitHeaders } from "@/lib/rate-limit"; import { NextResponse } from "next/server"; import { apiHandler } from "@/lib/api-handler"; @@ -35,7 +40,7 @@ export const POST = apiHandler(async (request: Request) => { // The body is optional (the web client sends its page transport; nothing // else needs to send anything). A missing or unreadable body is no body, as // before; only a stated plain-http page refuses, before a challenge exists. - if (request.headers.get("content-type")?.includes("application/json")) { + if (isJsonContentType(request.headers.get("content-type"))) { const { data: body } = await safeJson(request, { maxBytes: 4 * 1024 }); const transportRefusal = refuseWhenCookieCannotStick(body); if (transportRefusal) return transportRefusal; diff --git a/src/app/api/auth/passkey/register-options/route.ts b/src/app/api/auth/passkey/register-options/route.ts index b72bfff13..de277bc69 100644 --- a/src/app/api/auth/passkey/register-options/route.ts +++ b/src/app/api/auth/passkey/register-options/route.ts @@ -21,6 +21,7 @@ import { apiSuccess, getClientIp, safeJson, + isJsonContentType, } from "@/lib/api-response"; import { apiHandler, requireCookieAuth } from "@/lib/api-handler"; import { annotate } from "@/lib/logging/context"; @@ -32,7 +33,7 @@ const proofRequired = () => export const POST = apiHandler(async (request: NextRequest) => { const { user, session } = await requireCookieAuth(); - if (!request.headers.get("content-type")?.includes("application/json")) { + if (!isJsonContentType(request.headers.get("content-type"))) { return proofRequired(); } diff --git a/src/app/api/consent/ai/web/route.ts b/src/app/api/consent/ai/web/route.ts index 2e2bb87e4..45d55f924 100644 --- a/src/app/api/consent/ai/web/route.ts +++ b/src/app/api/consent/ai/web/route.ts @@ -17,7 +17,7 @@ * grants. */ import { apiHandler, requireAuth } from "@/lib/api-handler"; -import { apiError, apiSuccess } from "@/lib/api-response"; +import { apiError, apiSuccess, isJsonContentType } from "@/lib/api-response"; import { parseJsonBody } from "@/lib/api/body"; import { annotate } from "@/lib/logging/context"; import { auditLog } from "@/lib/auth/audit"; @@ -47,7 +47,7 @@ export const POST = apiHandler(async (req: Request) => { // empty object, the retired `{ intent }` shape) is not the consent act. let confirmed = false; const contentType = req.headers.get("content-type") ?? ""; - if (contentType.includes("application/json")) { + if (isJsonContentType(contentType)) { const parsed = await parseJsonBody(req, webConsentGrantBody, { maxBytes: 1024, action: "consent.ai.web-grant.invalid_shape", diff --git a/src/app/api/dawarich/disconnect/route.ts b/src/app/api/dawarich/disconnect/route.ts index 44be6ae90..6e3e01b7b 100644 --- a/src/app/api/dawarich/disconnect/route.ts +++ b/src/app/api/dawarich/disconnect/route.ts @@ -7,7 +7,12 @@ import { NextRequest } from "next/server"; import { apiHandler, requireAuth } from "@/lib/api-handler"; -import { apiSuccess, returnAllZodIssues, safeJson } from "@/lib/api-response"; +import { + apiSuccess, + returnAllZodIssues, + safeJson, + isJsonContentType, +} from "@/lib/api-response"; import { auditLog } from "@/lib/auth/audit"; import { prisma } from "@/lib/db"; import { markDisconnected } from "@/lib/integrations/status"; @@ -23,7 +28,7 @@ export const POST = apiHandler(async (request: NextRequest) => { // The body is optional: no body is `{ deleteData: false }`. let body: unknown = {}; - if (request.headers.get("content-type")?.includes("application/json")) { + if (isJsonContentType(request.headers.get("content-type"))) { const read = await safeJson(request, { maxBytes: 1024 }); if (read.error) return read.error; body = read.data; diff --git a/src/app/api/dawarich/sync/route.ts b/src/app/api/dawarich/sync/route.ts index 34f956994..585437280 100644 --- a/src/app/api/dawarich/sync/route.ts +++ b/src/app/api/dawarich/sync/route.ts @@ -13,6 +13,7 @@ import { apiSuccess, returnAllZodIssues, safeJson, + isJsonContentType, } from "@/lib/api-response"; import { loadDawarichConnection } from "@/lib/dawarich/connection"; import { yesterdayKey } from "@/lib/dawarich/sync"; @@ -34,7 +35,7 @@ export const POST = apiHandler(async (request: NextRequest) => { // The body is optional: no body is the nightly window. let body: unknown = {}; - if (request.headers.get("content-type")?.includes("application/json")) { + if (isJsonContentType(request.headers.get("content-type"))) { const read = await safeJson(request, { maxBytes: 1024 }); if (read.error) return read.error; body = read.data; diff --git a/src/app/api/documents/inbound/[id]/extract/route.ts b/src/app/api/documents/inbound/[id]/extract/route.ts index 9ec8e00e5..a3192cabe 100644 --- a/src/app/api/documents/inbound/[id]/extract/route.ts +++ b/src/app/api/documents/inbound/[id]/extract/route.ts @@ -50,6 +50,7 @@ import { getClientIp, safeJson, sanitiseZodIssues, + isJsonContentType, } from "@/lib/api-response"; import { AI_BUDGETS } from "@/lib/ai/ai-budgets"; import { requireAiCapability } from "@/lib/ai/capabilities/gate"; @@ -152,7 +153,7 @@ export const POST = apiHandler( const background = prefersRespondAsync(request); const contentType = request.headers.get("content-type") ?? ""; - if (contentType.includes("application/json")) { + if (isJsonContentType(contentType)) { // One body read for both JSON modes, BEFORE any bucket charge — a // malformed body never needs a refund because nothing was charged yet. const { data: body, error: jsonError } = await safeJson(request, { diff --git a/src/app/api/documents/inbound/[id]/index/route.ts b/src/app/api/documents/inbound/[id]/index/route.ts index aa0b5318a..279456993 100644 --- a/src/app/api/documents/inbound/[id]/index/route.ts +++ b/src/app/api/documents/inbound/[id]/index/route.ts @@ -40,6 +40,7 @@ import { getClientIp, safeJson, sanitiseZodIssues, + isJsonContentType, } from "@/lib/api-response"; import { requireAiCapability } from "@/lib/ai/capabilities/gate"; import { @@ -92,7 +93,7 @@ export const POST = apiHandler( const background = prefersRespondAsync(request); const contentType = request.headers.get("content-type") ?? ""; - if (contentType.includes("application/json")) { + if (isJsonContentType(contentType)) { return handleTextIndex(request, user.id, document, background); } return handleVisionIndex(request, user.id, document, background); diff --git a/src/app/api/documents/inbound/[id]/suggest/route.ts b/src/app/api/documents/inbound/[id]/suggest/route.ts index 47371f779..0bcb03de7 100644 --- a/src/app/api/documents/inbound/[id]/suggest/route.ts +++ b/src/app/api/documents/inbound/[id]/suggest/route.ts @@ -30,6 +30,7 @@ import { getClientIp, safeJson, sanitiseZodIssues, + isJsonContentType, } from "@/lib/api-response"; import { AI_BUDGETS } from "@/lib/ai/ai-budgets"; import { requireAiCapability } from "@/lib/ai/capabilities/gate"; @@ -94,7 +95,7 @@ export const POST = apiHandler( const background = prefersRespondAsync(request); const contentType = request.headers.get("content-type") ?? ""; - if (contentType.includes("application/json")) { + if (isJsonContentType(contentType)) { return handleTextSuggest(request, user.id, document, background); } return handleVisionSuggest(request, user.id, document, background); diff --git a/src/app/api/documents/inbound/[id]/summary/route.ts b/src/app/api/documents/inbound/[id]/summary/route.ts index c5e293890..231adcda4 100644 --- a/src/app/api/documents/inbound/[id]/summary/route.ts +++ b/src/app/api/documents/inbound/[id]/summary/route.ts @@ -40,6 +40,7 @@ import { getClientIp, safeJson, sanitiseZodIssues, + isJsonContentType, } from "@/lib/api-response"; import { AI_BUDGETS } from "@/lib/ai/ai-budgets"; import { requireAiCapability } from "@/lib/ai/capabilities/gate"; @@ -165,7 +166,7 @@ export const POST = apiHandler( background: prefersRespondAsync(request), }; const contentType = request.headers.get("content-type") ?? ""; - if (contentType.includes("application/json")) { + if (isJsonContentType(contentType)) { return handleTextSummary(request, ask); } return handleVisionSummary(request, ask); diff --git a/src/app/api/labs/ocr/extract/route.ts b/src/app/api/labs/ocr/extract/route.ts index a8869ee74..04c26d5f9 100644 --- a/src/app/api/labs/ocr/extract/route.ts +++ b/src/app/api/labs/ocr/extract/route.ts @@ -48,6 +48,7 @@ import { apiValidationError, safeJson, sanitiseZodIssues, + isJsonContentType, } from "@/lib/api-response"; import { requireAiCapability } from "@/lib/ai/capabilities/gate"; import { @@ -91,7 +92,7 @@ export const POST = apiHandler(async (request: Request) => { // Dispatch on the body shape: a JSON body is the local-OCR text mode; a // multipart body is the native-vision image/PDF mode. const contentType = request.headers.get("content-type") ?? ""; - if (contentType.includes("application/json")) { + if (isJsonContentType(contentType)) { return handleTextExtract(request, user.id); } return handleVisionExtract(request, user.id); diff --git a/src/app/api/medications/intake/dose-history-import/route.ts b/src/app/api/medications/intake/dose-history-import/route.ts index 2d3cda2ce..bd2c0660d 100644 --- a/src/app/api/medications/intake/dose-history-import/route.ts +++ b/src/app/api/medications/intake/dose-history-import/route.ts @@ -35,6 +35,7 @@ import { apiSuccess, getClientIp, readBodyText, + isJsonContentType, } from "@/lib/api-response"; import { auditLog } from "@/lib/auth/audit"; import { prisma } from "@/lib/db"; @@ -127,7 +128,7 @@ export const POST = apiHandler(async (request: NextRequest) => { } const contentType = (request.headers.get("content-type") ?? "").toLowerCase(); - const isJson = contentType.includes("application/json"); + const isJson = isJsonContentType(contentType); const isText = contentType.includes("text/csv") || contentType.includes("text/plain"); if (!isJson && !isText) { diff --git a/src/lib/__tests__/api-handler-cross-site.test.ts b/src/lib/__tests__/api-handler-cross-site.test.ts new file mode 100644 index 000000000..1ee042d8e --- /dev/null +++ b/src/lib/__tests__/api-handler-cross-site.test.ts @@ -0,0 +1,141 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; + +vi.mock("@/lib/db", () => ({ prisma: {} })); +vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() })); +vi.mock("@/lib/auth/hmac", () => ({ hashToken: vi.fn() })); +vi.mock("@/lib/auth/audit", () => ({ + auditLog: vi.fn().mockResolvedValue(undefined), +})); +vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() })); +vi.mock("next/headers", () => ({ + headers: vi.fn(async () => ({ get: () => null })), + cookies: vi.fn(async () => ({ + get: () => undefined, + set: () => {}, + delete: () => {}, + })), +})); + +import { NextRequest } from "next/server"; +import { apiHandler } from "@/lib/api-handler"; +import { safeJson } from "@/lib/api-response"; + +/** + * A page on a sibling subdomain shares the session cookie's site, so a Lax + * cookie rides its POSTs. It could send a CORS-simple request, a body typed + * `text/plain; x=application/json`, and the server took it for JSON from the + * signed-in person. `apiHandler` now refuses a state change a browser sent + * from another origin, and `safeJson` compares the media type exactly. + */ + +const handler = vi.fn(async (request: NextRequest) => { + const { data, error } = await safeJson<{ value: number }>(request); + if (error) return error; + return Response.json({ data, error: null }); +}); +const route = apiHandler(handler); + +const ORIGIN = "https://health.example.com"; + +function post(headers: Record, body = '{"value":1}') { + return route( + new NextRequest(`${ORIGIN}/api/measurements`, { + method: "POST", + headers: { host: "health.example.com", ...headers }, + body, + }), + ); +} + +afterEach(() => { + handler.mockClear(); +}); + +describe("apiHandler and a state change from another site", () => { + it("refuses the forged text/plain POST from a sibling subdomain", async () => { + const response = await post({ + "content-type": "text/plain; x=application/json", + "sec-fetch-site": "same-site", + origin: "https://evil.example.com", + cookie: "healthlog_session=s", + }); + expect(response.status).toBe(403); + expect((await response.json()).meta?.errorCode).toBe("request.cross_site"); + expect(handler).not.toHaveBeenCalled(); + }); + + it("refuses a cross-site Origin from a browser that sends no Sec-Fetch-Site", async () => { + const response = await post({ + "content-type": "application/json", + origin: "https://evil.example.org", + }); + expect(response.status).toBe(403); + expect(handler).not.toHaveBeenCalled(); + }); + + it("refuses an opaque origin", async () => { + const response = await post({ + "content-type": "application/json", + origin: "null", + }); + expect(response.status).toBe(403); + }); + + it("accepts the app's own page", async () => { + const response = await post({ + "content-type": "application/json; charset=utf-8", + "sec-fetch-site": "same-origin", + origin: ORIGIN, + }); + expect(response.status).toBe(200); + expect(handler).toHaveBeenCalledOnce(); + }); + + it("accepts an Origin naming the host the request went to, without Sec-Fetch-Site", async () => { + const response = await post({ + "content-type": "application/json", + origin: ORIGIN, + }); + expect(response.status).toBe(200); + }); + + it("accepts a Bearer client and a client that is not a browser page", async () => { + expect( + ( + await post({ + "content-type": "application/json", + authorization: "Bearer hlk_x", + "sec-fetch-site": "cross-site", + }) + ).status, + ).toBe(200); + expect((await post({ "content-type": "application/json" })).status).toBe( + 200, + ); + }); + + it("leaves reads alone", async () => { + const response = await route( + new NextRequest(`${ORIGIN}/api/measurements`, { + headers: { "sec-fetch-site": "cross-site" }, + }), + ); + expect(response.status).not.toBe(403); + }); +}); + +describe("safeJson's media type", () => { + it("refuses a type that only mentions JSON in a parameter", async () => { + const response = await post({ + "content-type": "text/plain; x=application/json", + }); + expect(response.status).toBe(415); + }); + + it("accepts JSON in any case, with parameters", async () => { + expect( + (await post({ "content-type": "Application/JSON; charset=UTF-8" })) + .status, + ).toBe(200); + }); +}); diff --git a/src/lib/api-handler.ts b/src/lib/api-handler.ts index d59a27564..8e3a85179 100644 --- a/src/lib/api-handler.ts +++ b/src/lib/api-handler.ts @@ -14,7 +14,7 @@ import { isProxyBypassRoute, } from "@/lib/http/proxy-bypass-routes"; import { WideEventBuilder } from "./logging/event-builder"; -import { eventStorage } from "./logging/context"; +import { annotate, eventStorage } from "./logging/context"; import { emitIfSampled } from "./logging/transports"; import { redactOptional, redactSecrets } from "./logging/redact"; import { routeTemplateFor } from "./logging/route-template"; @@ -24,6 +24,7 @@ import { attachRecordContextEcho } from "./sharing/record-session-fence"; import { refuseOnKeyMismatch } from "./boot/key-mismatch-refusal"; import { acceptIncomingRequestId } from "./http/request-id"; import { getClientIp, withErrorEnvelopeDefaults } from "./api-response"; +import { refuseCrossSiteWrite } from "./http/cross-site-guard"; import { capturedRateLimit, rateLimitResponseHeaders, @@ -128,6 +129,24 @@ function safeRequestProp( } } +/** + * A state change a browser sent from another site, refused before auth and + * before the handler (`./http/cross-site-guard.ts`). + */ +function crossSiteRefusal(request: unknown, pathname: string): Response | null { + const refused = safeRequestProp( + request, + (r) => refuseCrossSiteWrite(r, pathname), + null, + ); + if (!refused) return null; + annotate({ + action: { name: "http.cross_site.refused" }, + meta: { reason: refused.reason }, + }); + return refused.response; +} + /** @internal — exposed for unit tests of the narrow-catch contract. */ export const __testables = { safeRequestProp, @@ -202,7 +221,9 @@ export function apiHandler Promise>( // The boot key check found that the configured encryption key // cannot open this database. Refuse before auth, before the // handler: nothing here may read or write encrypted data. - const refusal = refuseOnKeyMismatch(url.pathname); + const refusal = + refuseOnKeyMismatch(url.pathname) ?? + crossSiteRefusal(request, url.pathname); if (refusal) { response = refusal; } else { diff --git a/src/lib/api-response.ts b/src/lib/api-response.ts index f40ef67da..9cb6747e0 100644 --- a/src/lib/api-response.ts +++ b/src/lib/api-response.ts @@ -434,6 +434,17 @@ export async function readBodyText( return { text: new TextDecoder().decode(joined) }; } +/** + * Whether a `Content-Type` names JSON: the media type itself, compared + * exactly, with any parameters after it (`; charset=utf-8`). A substring + * test accepted `text/plain; x=application/json`, a type a page on another + * origin can send without a CORS preflight. + */ +export function isJsonContentType(value: string | null | undefined): boolean { + if (!value) return false; + return value.split(";", 1)[0]!.trim().toLowerCase() === "application/json"; +} + /** * Safely parse JSON body from a request. * Returns the parsed body or a 400 error response if parsing fails. @@ -450,8 +461,7 @@ export async function safeJson( request: Request, opts?: { maxBytes?: number }, ): Promise<{ data: T; error?: never } | { data?: never; error: Response }> { - const ct = request.headers.get("content-type"); - if (!ct || !ct.includes("application/json")) { + if (!isJsonContentType(request.headers.get("content-type"))) { return { error: apiError("Content-Type must be application/json", 415) }; } if (opts?.maxBytes !== undefined) { diff --git a/src/lib/auth/existing-factor-proof.ts b/src/lib/auth/existing-factor-proof.ts index a2e5d5fe5..9f1374e1d 100644 --- a/src/lib/auth/existing-factor-proof.ts +++ b/src/lib/auth/existing-factor-proof.ts @@ -17,7 +17,7 @@ * guesser cannot multiply the budget by rotating between routes. */ import { prisma } from "@/lib/db"; -import { apiError } from "@/lib/api-response"; +import { apiError, isJsonContentType } from "@/lib/api-response"; import { REPROOF_REQUIRED_CODE } from "@/lib/api-errors"; import { auditLog } from "@/lib/auth/audit"; import { annotate } from "@/lib/logging/context"; @@ -323,7 +323,7 @@ export function isSecondFactorProof( async function readProof( request: Request, ): Promise { - if (!request.headers.get("content-type")?.includes("application/json")) { + if (!isJsonContentType(request.headers.get("content-type"))) { return "absent"; } let raw: string; diff --git a/src/lib/http/__tests__/cross-site-refusal.test.ts b/src/lib/http/__tests__/cross-site-refusal.test.ts new file mode 100644 index 000000000..36456bf0a --- /dev/null +++ b/src/lib/http/__tests__/cross-site-refusal.test.ts @@ -0,0 +1,75 @@ +import { afterEach, describe, expect, it } from "vitest"; + +import { crossSiteWriteReason } from "../cross-site-guard"; + +const ORIGINAL_APP_URL = process.env.APP_URL; +afterEach(() => { + if (ORIGINAL_APP_URL === undefined) delete process.env.APP_URL; + else process.env.APP_URL = ORIGINAL_APP_URL; +}); + +function req( + headers: Record, + method = "POST", + url = "http://app:3000/api/x", +) { + return new Request(url, { method, headers }); +} + +describe("crossSiteWriteReason", () => { + it("accepts the public origin behind a proxy that rewrites Host", () => { + process.env.APP_URL = "https://health.example.com"; + expect( + crossSiteWriteReason( + req({ host: "app:3000", origin: "https://health.example.com" }), + "/api/x", + ), + ).toBeNull(); + expect( + crossSiteWriteReason( + req({ + host: "app:3000", + "x-forwarded-host": "lan.example.com", + origin: "https://lan.example.com", + }), + "/api/x", + ), + ).toBeNull(); + }); + + it("accepts a LAN install reached by its address over plain HTTP", () => { + delete process.env.APP_URL; + expect( + crossSiteWriteReason( + req({ host: "192.168.1.20:3000", origin: "http://192.168.1.20:3000" }), + "/api/x", + ), + ).toBeNull(); + }); + + it("refuses same-site and cross-site fetch metadata on every write method", () => { + for (const method of ["POST", "PUT", "PATCH", "DELETE"]) { + expect( + crossSiteWriteReason( + req({ "sec-fetch-site": "same-site" }, method), + "/api/x", + ), + ).toBe("sec-fetch-site"); + } + }); + + it("leaves the webhook and URL-credential surfaces to their own secret", () => { + for (const path of [ + "/api/withings/webhook/abc", + "/api/whoop/webhook/abc", + "/api/telegram/webhook", + "/api/internal/deploy-webhook", + "/api/notifications/action/hla_x", + "/api/monitoring/csp-report", + ]) { + expect( + crossSiteWriteReason(req({ "sec-fetch-site": "cross-site" }), path), + ).toBeNull(); + } + }); +}); diff --git a/src/lib/http/cross-site-guard.ts b/src/lib/http/cross-site-guard.ts new file mode 100644 index 000000000..1dbf72bd1 --- /dev/null +++ b/src/lib/http/cross-site-guard.ts @@ -0,0 +1,120 @@ +/** + * Refuse a state-changing request that a browser sent from another site. + * + * The session cookie is `SameSite=Lax`, which keeps it off a cross-site POST + * but not off a POST from a sibling subdomain: `SameSite` compares the + * registrable domain, so `https://other.example.com` is the same site as + * `https://health.example.com`. A page there could send + * `fetch(url, { method: "POST", mode: "no-cors", credentials: "include" })` + * with a body typed `text/plain`, which needs no preflight, and act as the + * signed-in person: add readings, delete entries, disconnect integrations, + * invite a delegate. Nothing on the server compared where a request came + * from. + * + * A browser says where a request comes from: `Sec-Fetch-Site` on every + * request from a current browser, and `Origin` on every cross-origin POST + * from any browser of the last decade. This guard runs in `apiHandler` + * before the handler, for every method that can change state: + * + * - `Sec-Fetch-Site: same-origin` or `none` passes; `same-site` and + * `cross-site` are refused. + * - Without `Sec-Fetch-Site`, an `Origin` passes only when its host is the + * host the request was sent to (`Host`, or `X-Forwarded-Host` behind a + * proxy) or the configured `APP_URL`. A page cannot set either header + * on another origin's request. + * - 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. + * + * 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. + */ +import { apiError } from "@/lib/api-response"; + +const SAFE_METHODS = new Set(["GET", "HEAD", "OPTIONS"]); + +/** + * Path prefixes that do not authenticate with the session cookie. Each is a + * webhook with its own secret or a URL-credential action; see the module + * comment. + */ +export const CROSS_SITE_EXEMPT_PREFIXES = [ + "/api/withings/webhook", + "/api/whoop/webhook/", + "/api/telegram/webhook", + "/api/internal/deploy-webhook", + "/api/notifications/action/", + "/api/monitoring/csp-report", +] as const; + +function hostOf(value: string | null | undefined): string | null { + if (!value) return null; + try { + return new URL(value).host.toLowerCase(); + } catch { + return null; + } +} + +/** The hosts this request may name as its own origin. */ +function ownHosts(request: Request): Set { + const hosts = new Set(); + const host = request.headers.get("host")?.trim().toLowerCase(); + if (host) hosts.add(host); + const forwarded = request.headers + .get("x-forwarded-host") + ?.split(",")[0] + ?.trim() + .toLowerCase(); + if (forwarded) hosts.add(forwarded); + for (const configured of [ + process.env.APP_URL, + process.env.NEXT_PUBLIC_APP_URL, + ]) { + const appHost = hostOf(configured); + if (appHost) hosts.add(appHost); + } + return hosts; +} + +/** Why a request is refused, or null when it may proceed. */ +export function crossSiteWriteReason( + request: Request, + pathname: string, +): "sec-fetch-site" | "origin" | null { + if (SAFE_METHODS.has(request.method.toUpperCase())) return null; + if (CROSS_SITE_EXEMPT_PREFIXES.some((p) => pathname.startsWith(p))) { + return null; + } + if (request.headers.has("authorization")) return null; + + const site = request.headers.get("sec-fetch-site")?.trim().toLowerCase(); + if (site) { + return site === "same-origin" || site === "none" ? null : "sec-fetch-site"; + } + const origin = request.headers.get("origin"); + if (origin === null) return null; + const originHost = hostOf(origin); + return originHost && ownHosts(request).has(originHost) ? null : "origin"; +} + +/** A 403 for a cross-site state change, or null. */ +export function refuseCrossSiteWrite( + request: Request, + pathname: string, +): { response: Response; reason: "sec-fetch-site" | "origin" } | null { + const reason = crossSiteWriteReason(request, pathname); + if (!reason) return null; + return { + reason, + response: apiError("Cross-site request refused", 403, { + errorCode: "request.cross_site", + }), + }; +} diff --git a/src/lib/openapi/error-codes.ts b/src/lib/openapi/error-codes.ts index eefaaf11d..429b88ec9 100644 --- a/src/lib/openapi/error-codes.ts +++ b/src/lib/openapi/error-codes.ts @@ -403,6 +403,7 @@ export const ERROR_CODE_CATALOGUE: Readonly> = "report-selection.body.invalid_shape", "report-selection.leaves.unknown", ], + request: ["request.cross_site"], route: ["route.retired"], search: ["search.invalid"], security_activity: ["security_activity.invalid"],