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.
This commit is contained in:
Marc-André Bombeck committed 2026-10-25 13:52:04 +01:00
1 parent fa50bcd1cb
commit 77ace106b3
20 files changed
+436 -47

No files matched your search

+23 -23
View File
@@ -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,
@@ -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 ?? {};
@@ -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;
@@ -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();
}
+2 -2
View File
@@ -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",
+7 -2
View File
@@ -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;
+2 -1
View File
@@ -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;
@@ -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, {
@@ -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);
@@ -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);
@@ -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);
+2 -1
View File
@@ -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);
@@ -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) {
@@ -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<string, string>, 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);
});
});
+23 -2
View File
@@ -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<R>(
}
}
/**
* 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<T extends (...args: any[]) => Promise<Response>>(
// 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 {
+12 -2
View File
@@ -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<T = unknown>(
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) {
+2 -2
View File
@@ -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<ExistingFactorProof | "absent" | "invalid"> {
if (!request.headers.get("content-type")?.includes("application/json")) {
if (!isJsonContentType(request.headers.get("content-type"))) {
return "absent";
}
let raw: string;
@@ -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<string, string>,
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();
}
});
});
+120
View File
@@ -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<string> {
const hosts = new Set<string>();
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",
}),
};
}
+1
View File
@@ -403,6 +403,7 @@ export const ERROR_CODE_CATALOGUE: Readonly<Record<string, readonly string[]>> =
"report-selection.body.invalid_shape",
"report-selection.leaves.unknown",
],
request: ["request.cross_site"],
route: ["route.retired"],
search: ["search.invalid"],
security_activity: ["security_activity.invalid"],