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 -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 {