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

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