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

@@ -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();
}