mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-12 06:06:10 +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
-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 {
|
||||
|
||||
Reference in new issue
Block a user