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

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