mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-12 14:16:12 +00:00
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:
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();
|
||||
}
|
||||
|
||||
|
||||
@@ -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,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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in new issue
Block a user