mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 20:42:43 +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
+23
-23
@@ -38581,29 +38581,29 @@ components:
|
||||
profile.update.invalidBody, profile.update.nothingSaved; profile_claim — profile_claim.invalid,
|
||||
profile_claim.oidc_only_unsupported, profile_claim.sso_unavailable, profile_claim.taken; record_write —
|
||||
record_write.rate_limited; report-selection — report-selection.body.invalid_json,
|
||||
report-selection.body.invalid_shape, report-selection.leaves.unknown; route — route.retired; search —
|
||||
search.invalid; security_activity — security_activity.invalid; share-link —
|
||||
share-link.document.decryptFailed, share-link.document.notFound, share-link.document.rateLimited,
|
||||
share-link.documents.invalid, share-link.report.notFound, share-link.report.rateLimited,
|
||||
share-link.selection.forbidden_leaf, share-link.selection.unknown_leaf; sharing —
|
||||
sharing.accept.expired, sharing.accept.not_found, sharing.accept.not_pending, sharing.accept.revoked,
|
||||
sharing.access.denied, sharing.invite.duplicate, sharing.invite.invalid, sharing.invite.invalid_scope,
|
||||
sharing.invite.manage_browser_only, sharing.invite.no_account, sharing.invite.refused,
|
||||
sharing.invite.self, sharing.not_permitted, sharing.renounce.already_ended, sharing.renounce.not_found,
|
||||
sharing.revoke.already_ended, sharing.revoke.not_found, sharing.session.changed, sharing.switch.invalid,
|
||||
sharing.switch.wrong_transport; source-priority — source-priority.body.invalid_json,
|
||||
source-priority.body.invalid_shape; symptoms — symptoms.definition.limitReached,
|
||||
symptoms.definition.notFound, symptoms.episode.notOpen, symptoms.event.notFound, symptoms.invalid;
|
||||
timeline — timeline.invalid; tokens — tokens.documents.ceiling_reached,
|
||||
tokens.measurements.ceiling_reached, tokens.mirror.not_measurement_token,
|
||||
tokens.workouts.ceiling_reached; vaccination — vaccination.booster-invalid,
|
||||
vaccination.booster-no-antigen, vaccination.booster-out-of-scope, vaccination.custom-vaccine-not-found,
|
||||
vaccination.custom.invalid, vaccination.custom.name-taken, vaccination.custom.not-found,
|
||||
vaccination.encounter-not-found, vaccination.identity-required, vaccination.invalid,
|
||||
vaccination.link.invalid, vaccination.not-found, vaccination.practitioner-not-found; workout —
|
||||
workout.batch.invalid, workout.batch.payload_too_large, workout.batch.source_not_permitted,
|
||||
workout.batch.too_large, workout.delete.synced_source; (unprefixed) — about_me_conflict,
|
||||
ai_response_truncated, coach_prefs_conflict, dashboard_layout_conflict, empty_file,
|
||||
report-selection.body.invalid_shape, report-selection.leaves.unknown; request — request.cross_site;
|
||||
route — route.retired; search — search.invalid; security_activity — security_activity.invalid;
|
||||
share-link — share-link.document.decryptFailed, share-link.document.notFound,
|
||||
share-link.document.rateLimited, share-link.documents.invalid, share-link.report.notFound,
|
||||
share-link.report.rateLimited, share-link.selection.forbidden_leaf, share-link.selection.unknown_leaf;
|
||||
sharing — sharing.accept.expired, sharing.accept.not_found, sharing.accept.not_pending,
|
||||
sharing.accept.revoked, sharing.access.denied, sharing.invite.duplicate, sharing.invite.invalid,
|
||||
sharing.invite.invalid_scope, sharing.invite.manage_browser_only, sharing.invite.no_account,
|
||||
sharing.invite.refused, sharing.invite.self, sharing.not_permitted, sharing.renounce.already_ended,
|
||||
sharing.renounce.not_found, sharing.revoke.already_ended, sharing.revoke.not_found,
|
||||
sharing.session.changed, sharing.switch.invalid, sharing.switch.wrong_transport; source-priority —
|
||||
source-priority.body.invalid_json, source-priority.body.invalid_shape; symptoms —
|
||||
symptoms.definition.limitReached, symptoms.definition.notFound, symptoms.episode.notOpen,
|
||||
symptoms.event.notFound, symptoms.invalid; timeline — timeline.invalid; tokens —
|
||||
tokens.documents.ceiling_reached, tokens.measurements.ceiling_reached,
|
||||
tokens.mirror.not_measurement_token, tokens.workouts.ceiling_reached; vaccination —
|
||||
vaccination.booster-invalid, vaccination.booster-no-antigen, vaccination.booster-out-of-scope,
|
||||
vaccination.custom-vaccine-not-found, vaccination.custom.invalid, vaccination.custom.name-taken,
|
||||
vaccination.custom.not-found, vaccination.encounter-not-found, vaccination.identity-required,
|
||||
vaccination.invalid, vaccination.link.invalid, vaccination.not-found,
|
||||
vaccination.practitioner-not-found; workout — workout.batch.invalid, workout.batch.payload_too_large,
|
||||
workout.batch.source_not_permitted, workout.batch.too_large, workout.delete.synced_source; (unprefixed)
|
||||
— about_me_conflict, ai_response_truncated, coach_prefs_conflict, dashboard_layout_conflict, empty_file,
|
||||
health_score_config_conflict, insights_layout_conflict, invalid_base_updated_at,
|
||||
json_carries_no_intake_time, json_not_an_array, medication_layout_conflict, missing_required_columns,
|
||||
modules_conflict, mood_tag_layout_conflict, not_configured, notification_prefs_conflict, oidc_only,
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("@/lib/db", () => ({ prisma: {} }));
|
||||
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
|
||||
vi.mock("@/lib/auth/hmac", () => ({ hashToken: vi.fn() }));
|
||||
vi.mock("@/lib/auth/audit", () => ({
|
||||
auditLog: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
|
||||
vi.mock("next/headers", () => ({
|
||||
headers: vi.fn(async () => ({ get: () => null })),
|
||||
cookies: vi.fn(async () => ({
|
||||
get: () => undefined,
|
||||
set: () => {},
|
||||
delete: () => {},
|
||||
})),
|
||||
}));
|
||||
|
||||
import { NextRequest } from "next/server";
|
||||
import { apiHandler } from "@/lib/api-handler";
|
||||
import { safeJson } from "@/lib/api-response";
|
||||
|
||||
/**
|
||||
* A page on a sibling subdomain shares the session cookie's site, so a Lax
|
||||
* cookie rides its POSTs. It could send a CORS-simple request, a body typed
|
||||
* `text/plain; x=application/json`, and the server took it for JSON from the
|
||||
* signed-in person. `apiHandler` now refuses a state change a browser sent
|
||||
* from another origin, and `safeJson` compares the media type exactly.
|
||||
*/
|
||||
|
||||
const handler = vi.fn(async (request: NextRequest) => {
|
||||
const { data, error } = await safeJson<{ value: number }>(request);
|
||||
if (error) return error;
|
||||
return Response.json({ data, error: null });
|
||||
});
|
||||
const route = apiHandler(handler);
|
||||
|
||||
const ORIGIN = "https://health.example.com";
|
||||
|
||||
function post(headers: Record<string, string>, body = '{"value":1}') {
|
||||
return route(
|
||||
new NextRequest(`${ORIGIN}/api/measurements`, {
|
||||
method: "POST",
|
||||
headers: { host: "health.example.com", ...headers },
|
||||
body,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
handler.mockClear();
|
||||
});
|
||||
|
||||
describe("apiHandler and a state change from another site", () => {
|
||||
it("refuses the forged text/plain POST from a sibling subdomain", async () => {
|
||||
const response = await post({
|
||||
"content-type": "text/plain; x=application/json",
|
||||
"sec-fetch-site": "same-site",
|
||||
origin: "https://evil.example.com",
|
||||
cookie: "healthlog_session=s",
|
||||
});
|
||||
expect(response.status).toBe(403);
|
||||
expect((await response.json()).meta?.errorCode).toBe("request.cross_site");
|
||||
expect(handler).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("refuses a cross-site Origin from a browser that sends no Sec-Fetch-Site", async () => {
|
||||
const response = await post({
|
||||
"content-type": "application/json",
|
||||
origin: "https://evil.example.org",
|
||||
});
|
||||
expect(response.status).toBe(403);
|
||||
expect(handler).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("refuses an opaque origin", async () => {
|
||||
const response = await post({
|
||||
"content-type": "application/json",
|
||||
origin: "null",
|
||||
});
|
||||
expect(response.status).toBe(403);
|
||||
});
|
||||
|
||||
it("accepts the app's own page", async () => {
|
||||
const response = await post({
|
||||
"content-type": "application/json; charset=utf-8",
|
||||
"sec-fetch-site": "same-origin",
|
||||
origin: ORIGIN,
|
||||
});
|
||||
expect(response.status).toBe(200);
|
||||
expect(handler).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("accepts an Origin naming the host the request went to, without Sec-Fetch-Site", async () => {
|
||||
const response = await post({
|
||||
"content-type": "application/json",
|
||||
origin: ORIGIN,
|
||||
});
|
||||
expect(response.status).toBe(200);
|
||||
});
|
||||
|
||||
it("accepts a Bearer client and a client that is not a browser page", async () => {
|
||||
expect(
|
||||
(
|
||||
await post({
|
||||
"content-type": "application/json",
|
||||
authorization: "Bearer hlk_x",
|
||||
"sec-fetch-site": "cross-site",
|
||||
})
|
||||
).status,
|
||||
).toBe(200);
|
||||
expect((await post({ "content-type": "application/json" })).status).toBe(
|
||||
200,
|
||||
);
|
||||
});
|
||||
|
||||
it("leaves reads alone", async () => {
|
||||
const response = await route(
|
||||
new NextRequest(`${ORIGIN}/api/measurements`, {
|
||||
headers: { "sec-fetch-site": "cross-site" },
|
||||
}),
|
||||
);
|
||||
expect(response.status).not.toBe(403);
|
||||
});
|
||||
});
|
||||
|
||||
describe("safeJson's media type", () => {
|
||||
it("refuses a type that only mentions JSON in a parameter", async () => {
|
||||
const response = await post({
|
||||
"content-type": "text/plain; x=application/json",
|
||||
});
|
||||
expect(response.status).toBe(415);
|
||||
});
|
||||
|
||||
it("accepts JSON in any case, with parameters", async () => {
|
||||
expect(
|
||||
(await post({ "content-type": "Application/JSON; charset=UTF-8" }))
|
||||
.status,
|
||||
).toBe(200);
|
||||
});
|
||||
});
|
||||
+23
-2
@@ -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 {
|
||||
|
||||
+12
-2
@@ -434,6 +434,17 @@ export async function readBodyText(
|
||||
return { text: new TextDecoder().decode(joined) };
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a `Content-Type` names JSON: the media type itself, compared
|
||||
* exactly, with any parameters after it (`; charset=utf-8`). A substring
|
||||
* test accepted `text/plain; x=application/json`, a type a page on another
|
||||
* origin can send without a CORS preflight.
|
||||
*/
|
||||
export function isJsonContentType(value: string | null | undefined): boolean {
|
||||
if (!value) return false;
|
||||
return value.split(";", 1)[0]!.trim().toLowerCase() === "application/json";
|
||||
}
|
||||
|
||||
/**
|
||||
* Safely parse JSON body from a request.
|
||||
* Returns the parsed body or a 400 error response if parsing fails.
|
||||
@@ -450,8 +461,7 @@ export async function safeJson<T = unknown>(
|
||||
request: Request,
|
||||
opts?: { maxBytes?: number },
|
||||
): Promise<{ data: T; error?: never } | { data?: never; error: Response }> {
|
||||
const ct = request.headers.get("content-type");
|
||||
if (!ct || !ct.includes("application/json")) {
|
||||
if (!isJsonContentType(request.headers.get("content-type"))) {
|
||||
return { error: apiError("Content-Type must be application/json", 415) };
|
||||
}
|
||||
if (opts?.maxBytes !== undefined) {
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
* guesser cannot multiply the budget by rotating between routes.
|
||||
*/
|
||||
import { prisma } from "@/lib/db";
|
||||
import { apiError } from "@/lib/api-response";
|
||||
import { apiError, isJsonContentType } from "@/lib/api-response";
|
||||
import { REPROOF_REQUIRED_CODE } from "@/lib/api-errors";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
@@ -323,7 +323,7 @@ export function isSecondFactorProof(
|
||||
async function readProof(
|
||||
request: Request,
|
||||
): Promise<ExistingFactorProof | "absent" | "invalid"> {
|
||||
if (!request.headers.get("content-type")?.includes("application/json")) {
|
||||
if (!isJsonContentType(request.headers.get("content-type"))) {
|
||||
return "absent";
|
||||
}
|
||||
let raw: string;
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
|
||||
import { crossSiteWriteReason } from "../cross-site-guard";
|
||||
|
||||
const ORIGINAL_APP_URL = process.env.APP_URL;
|
||||
afterEach(() => {
|
||||
if (ORIGINAL_APP_URL === undefined) delete process.env.APP_URL;
|
||||
else process.env.APP_URL = ORIGINAL_APP_URL;
|
||||
});
|
||||
|
||||
function req(
|
||||
headers: Record<string, string>,
|
||||
method = "POST",
|
||||
url = "http://app:3000/api/x",
|
||||
) {
|
||||
return new Request(url, { method, headers });
|
||||
}
|
||||
|
||||
describe("crossSiteWriteReason", () => {
|
||||
it("accepts the public origin behind a proxy that rewrites Host", () => {
|
||||
process.env.APP_URL = "https://health.example.com";
|
||||
expect(
|
||||
crossSiteWriteReason(
|
||||
req({ host: "app:3000", origin: "https://health.example.com" }),
|
||||
"/api/x",
|
||||
),
|
||||
).toBeNull();
|
||||
expect(
|
||||
crossSiteWriteReason(
|
||||
req({
|
||||
host: "app:3000",
|
||||
"x-forwarded-host": "lan.example.com",
|
||||
origin: "https://lan.example.com",
|
||||
}),
|
||||
"/api/x",
|
||||
),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("accepts a LAN install reached by its address over plain HTTP", () => {
|
||||
delete process.env.APP_URL;
|
||||
expect(
|
||||
crossSiteWriteReason(
|
||||
req({ host: "192.168.1.20:3000", origin: "http://192.168.1.20:3000" }),
|
||||
"/api/x",
|
||||
),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("refuses same-site and cross-site fetch metadata on every write method", () => {
|
||||
for (const method of ["POST", "PUT", "PATCH", "DELETE"]) {
|
||||
expect(
|
||||
crossSiteWriteReason(
|
||||
req({ "sec-fetch-site": "same-site" }, method),
|
||||
"/api/x",
|
||||
),
|
||||
).toBe("sec-fetch-site");
|
||||
}
|
||||
});
|
||||
|
||||
it("leaves the webhook and URL-credential surfaces to their own secret", () => {
|
||||
for (const path of [
|
||||
"/api/withings/webhook/abc",
|
||||
"/api/whoop/webhook/abc",
|
||||
"/api/telegram/webhook",
|
||||
"/api/internal/deploy-webhook",
|
||||
"/api/notifications/action/hla_x",
|
||||
"/api/monitoring/csp-report",
|
||||
]) {
|
||||
expect(
|
||||
crossSiteWriteReason(req({ "sec-fetch-site": "cross-site" }), path),
|
||||
).toBeNull();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,120 @@
|
||||
/**
|
||||
* Refuse a state-changing request that a browser sent from another site.
|
||||
*
|
||||
* The session cookie is `SameSite=Lax`, which keeps it off a cross-site POST
|
||||
* but not off a POST from a sibling subdomain: `SameSite` compares the
|
||||
* registrable domain, so `https://other.example.com` is the same site as
|
||||
* `https://health.example.com`. A page there could send
|
||||
* `fetch(url, { method: "POST", mode: "no-cors", credentials: "include" })`
|
||||
* with a body typed `text/plain`, which needs no preflight, and act as the
|
||||
* signed-in person: add readings, delete entries, disconnect integrations,
|
||||
* invite a delegate. Nothing on the server compared where a request came
|
||||
* from.
|
||||
*
|
||||
* A browser says where a request comes from: `Sec-Fetch-Site` on every
|
||||
* request from a current browser, and `Origin` on every cross-origin POST
|
||||
* from any browser of the last decade. This guard runs in `apiHandler`
|
||||
* before the handler, for every method that can change state:
|
||||
*
|
||||
* - `Sec-Fetch-Site: same-origin` or `none` passes; `same-site` and
|
||||
* `cross-site` are refused.
|
||||
* - Without `Sec-Fetch-Site`, an `Origin` passes only when its host is the
|
||||
* host the request was sent to (`Host`, or `X-Forwarded-Host` behind a
|
||||
* proxy) or the configured `APP_URL`. A page cannot set either header
|
||||
* on another origin's request.
|
||||
* - A request with neither header is not from a browser page (the phone
|
||||
* app, a script, a webhook sender) and passes; it carries no cookie a
|
||||
* page could have lent it.
|
||||
* - A request carrying `Authorization` passes: a page cannot attach that
|
||||
* header to another origin without a CORS preflight, which this server
|
||||
* never answers.
|
||||
*
|
||||
* Exempt by path: the surfaces that authenticate with a secret in a header
|
||||
* or the URL rather than the session cookie, and that a third party's client
|
||||
* may legitimately call from a browser (a push client's action button). The
|
||||
* MCP OAuth endpoints, which must accept cross-origin requests by their
|
||||
* specification, do not run through `apiHandler` at all.
|
||||
*/
|
||||
import { apiError } from "@/lib/api-response";
|
||||
|
||||
const SAFE_METHODS = new Set(["GET", "HEAD", "OPTIONS"]);
|
||||
|
||||
/**
|
||||
* Path prefixes that do not authenticate with the session cookie. Each is a
|
||||
* webhook with its own secret or a URL-credential action; see the module
|
||||
* comment.
|
||||
*/
|
||||
export const CROSS_SITE_EXEMPT_PREFIXES = [
|
||||
"/api/withings/webhook",
|
||||
"/api/whoop/webhook/",
|
||||
"/api/telegram/webhook",
|
||||
"/api/internal/deploy-webhook",
|
||||
"/api/notifications/action/",
|
||||
"/api/monitoring/csp-report",
|
||||
] as const;
|
||||
|
||||
function hostOf(value: string | null | undefined): string | null {
|
||||
if (!value) return null;
|
||||
try {
|
||||
return new URL(value).host.toLowerCase();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** The hosts this request may name as its own origin. */
|
||||
function ownHosts(request: Request): Set<string> {
|
||||
const hosts = new Set<string>();
|
||||
const host = request.headers.get("host")?.trim().toLowerCase();
|
||||
if (host) hosts.add(host);
|
||||
const forwarded = request.headers
|
||||
.get("x-forwarded-host")
|
||||
?.split(",")[0]
|
||||
?.trim()
|
||||
.toLowerCase();
|
||||
if (forwarded) hosts.add(forwarded);
|
||||
for (const configured of [
|
||||
process.env.APP_URL,
|
||||
process.env.NEXT_PUBLIC_APP_URL,
|
||||
]) {
|
||||
const appHost = hostOf(configured);
|
||||
if (appHost) hosts.add(appHost);
|
||||
}
|
||||
return hosts;
|
||||
}
|
||||
|
||||
/** Why a request is refused, or null when it may proceed. */
|
||||
export function crossSiteWriteReason(
|
||||
request: Request,
|
||||
pathname: string,
|
||||
): "sec-fetch-site" | "origin" | null {
|
||||
if (SAFE_METHODS.has(request.method.toUpperCase())) return null;
|
||||
if (CROSS_SITE_EXEMPT_PREFIXES.some((p) => pathname.startsWith(p))) {
|
||||
return null;
|
||||
}
|
||||
if (request.headers.has("authorization")) return null;
|
||||
|
||||
const site = request.headers.get("sec-fetch-site")?.trim().toLowerCase();
|
||||
if (site) {
|
||||
return site === "same-origin" || site === "none" ? null : "sec-fetch-site";
|
||||
}
|
||||
const origin = request.headers.get("origin");
|
||||
if (origin === null) return null;
|
||||
const originHost = hostOf(origin);
|
||||
return originHost && ownHosts(request).has(originHost) ? null : "origin";
|
||||
}
|
||||
|
||||
/** A 403 for a cross-site state change, or null. */
|
||||
export function refuseCrossSiteWrite(
|
||||
request: Request,
|
||||
pathname: string,
|
||||
): { response: Response; reason: "sec-fetch-site" | "origin" } | null {
|
||||
const reason = crossSiteWriteReason(request, pathname);
|
||||
if (!reason) return null;
|
||||
return {
|
||||
reason,
|
||||
response: apiError("Cross-site request refused", 403, {
|
||||
errorCode: "request.cross_site",
|
||||
}),
|
||||
};
|
||||
}
|
||||
@@ -403,6 +403,7 @@ export const ERROR_CODE_CATALOGUE: Readonly<Record<string, readonly string[]>> =
|
||||
"report-selection.body.invalid_shape",
|
||||
"report-selection.leaves.unknown",
|
||||
],
|
||||
request: ["request.cross_site"],
|
||||
route: ["route.retired"],
|
||||
search: ["search.invalid"],
|
||||
security_activity: ["security_activity.invalid"],
|
||||
|
||||
Reference in new issue
Block a user