mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
Two streamed endpoints read a spreadsheet record by record with a column mapping from the query: separator, decimal mark, date order and zone are stated, never guessed per row. Lab rows resolve to markers the way scanned reports do, convert units where the conversion is known and keep what the file said; medication rows map name, dose, daily times, as-needed and a dated window, checked against the create schema, with reminders off unless asked for. A dry run writes nothing, a re-upload only finds duplicates, and every refused row comes back with its line and reason.
871 lines
43 KiB
TypeScript
871 lines
43 KiB
TypeScript
import { NextResponse, type NextRequest } from "next/server";
|
|
import { shouldRunWeb } from "@/lib/process-type";
|
|
import {
|
|
findRetiredRoute,
|
|
retiredRouteEnvelope,
|
|
RETIRED_ROUTE_STATUS,
|
|
} from "@/lib/http/retired-routes";
|
|
import { setBaselineSecurityHeaders } from "@/lib/http/proxy-bypass-routes";
|
|
import { DEMO_REFUSAL_BODY } from "@/lib/demo-refusal";
|
|
import { resolveRequestId } from "@/lib/http/request-id";
|
|
import { redactSecrets } from "@/lib/logging/redact";
|
|
|
|
/**
|
|
* Paths that do NOT require a session cookie (public pages + external webhooks).
|
|
*
|
|
* `/api/version` is intentionally public — both the in-app About page and the
|
|
* compose healthcheck rely on it. `/api/health` stays public for the same
|
|
* reason.
|
|
*/
|
|
export const PUBLIC_PATHS = [
|
|
"/auth/",
|
|
"/api/auth/login",
|
|
"/api/auth/register",
|
|
"/api/auth/registration-status",
|
|
"/api/auth/passkey/login-options",
|
|
"/api/auth/passkey/login-verify",
|
|
// OIDC SSO login. `login` starts the redirect to the IdP, `callback`
|
|
// completes it and mints the session — both must be reachable with no
|
|
// existing session, since they authenticate the user in the first
|
|
// place. `status` is the public flag the login page reads to decide
|
|
// whether to render the SSO button.
|
|
"/api/auth/oidc/login",
|
|
"/api/auth/oidc/callback",
|
|
"/api/auth/oidc/status",
|
|
// v1.32.11 — first-party web-handoff login (iOS #65). `login` sets the state
|
|
// cookie and redirects to the web login page, `complete` mints the handoff
|
|
// code after an interactive login, `token` exchanges it. All three
|
|
// authenticate the flow themselves (state cookie / session / handoff code),
|
|
// never by a pre-existing session, so they bypass the page auth gate. The
|
|
// TRAILING SLASH is load-bearing: `isPublicPath` is a bare `startsWith`, so
|
|
// `/api/auth/native/` admits exactly this family and never `/api/auth/native-…`.
|
|
"/api/auth/native/",
|
|
"/api/health",
|
|
"/api/version",
|
|
"/api/notifications/vapid",
|
|
// v1.42.2 — notification quick answers. A notification app (ntfy's `http`
|
|
// action) calls this with no session; the signed, single-use, short-lived
|
|
// token in the path is the whole credential, verified by the route after
|
|
// its rate limit. Trailing slash: admits only `/action/<token>`.
|
|
"/api/notifications/action/",
|
|
"/api/monitoring/",
|
|
"/api/withings/webhook",
|
|
// v1.11.0 — WHOOP webhook (`recovery.updated` / `sleep.updated` /
|
|
// `workout.updated`, + `*.deleted`). Authenticated by the path-segment
|
|
// secret + the HMAC body signature, never by a session cookie, so it
|
|
// must bypass the auth gate (mirrors the Withings webhook entry).
|
|
"/api/whoop/webhook",
|
|
"/api/telegram/webhook",
|
|
"/api/ingest/",
|
|
// v1.4.26 — `/privacy` is a public legal page. iOS App Store Connect
|
|
// requires a publicly reachable Privacy-Policy URL during submission;
|
|
// GDPR Art. 13 expects the same for any visitor before they sign up.
|
|
"/privacy",
|
|
// v1.4.27 — `/about` carries the GeoLite2 CC BY-SA 4.0 attribution
|
|
// alongside the project credits. The CC licence requires the
|
|
// attribution to be reachable without a sign-in.
|
|
"/about",
|
|
// v1.11.0 — `/c/<token>` is the public clinician view (Epic C). It is
|
|
// authenticated solely by the unguessable `hls_` token in the path, NOT
|
|
// by a session cookie, so it must reach the page without an auth gate.
|
|
// The page renders a flat 404 for any unknown / revoked / expired token.
|
|
"/c/",
|
|
// v1.18.7 — `/api/c/<token>/unlock` is the public passphrase-gate verifier
|
|
// for a protected share link. Like the view it carries no session — the raw
|
|
// path token plus the submitted passphrase are the only credentials — so it
|
|
// must reach the route handler without an auth gate. It is rate-limited and
|
|
// answers one blunt error for every failure class.
|
|
"/api/c/",
|
|
// v1.17.0 — `/invite/<hlv_token>` is the invite universal-link landing
|
|
// (iOS #16). It is a thin shape-validated redirect onto
|
|
// `/auth/register?invite=…`, carries no session, touches no database,
|
|
// and is not an enumeration oracle — so it must reach the page without
|
|
// an auth gate, like the `/auth/` register surface it forwards to.
|
|
"/invite/",
|
|
// v1.42 (#959) — `/claim/<hlp_token>` is the managed-profile handover link.
|
|
// Like `/invite/` it is a shape-validated edge redirect onto
|
|
// `/auth/claim?token=…` (handled below), carries no session and touches no
|
|
// database. `/api/auth/claim` (preview + claim) authenticates by the
|
|
// one-time token in the request body, never by a session: the person
|
|
// claiming the profile has no credentials yet.
|
|
"/claim/",
|
|
"/api/auth/claim",
|
|
// Locale-catalog boot script (`/i18n/<locale>?v=…`). The login page loads
|
|
// it pre-auth, and the body is the same public catalog JSON that ships in
|
|
// the repository — no tenant data, no secrets. Immutable-cacheable.
|
|
"/i18n/",
|
|
// `/onboarding` itself + its subroutes are matched exactly via
|
|
// `isPublicPath()` so we don't admit `/onboarding-export` etc.
|
|
"/robots.txt",
|
|
];
|
|
|
|
/**
|
|
* Exact-match allowlist for IETF-registered discovery endpoints
|
|
* (RFC 8615). Apple reads `/.well-known/apple-app-site-association`
|
|
* without credentials to wire passkey sharing and Universal Links;
|
|
* the bare JSON body has to answer 200 before any auth gate runs.
|
|
*
|
|
* Exact match (not prefix) — a future `/.well-known/openid-configuration`
|
|
* or `/.well-known/security.txt` must be added here explicitly so a new
|
|
* sub-path doesn't auto-inherit "no auth" status.
|
|
*/
|
|
const WELL_KNOWN_PUBLIC_PATHS = new Set<string>([
|
|
"/.well-known/apple-app-site-association",
|
|
// v1.22.0 — OAuth discovery for the remote MCP connector. A remote client
|
|
// (Claude.ai / ChatGPT) reads these unauthenticated to bootstrap the OAuth
|
|
// 2.1 + PKCE flow from the pasted `/mcp` URL alone: RFC 9728 Protected
|
|
// Resource Metadata + RFC 8414 Authorization Server Metadata. Both are
|
|
// public, deterministic, secret-free discovery documents.
|
|
"/.well-known/oauth-protected-resource",
|
|
"/.well-known/oauth-authorization-server",
|
|
]);
|
|
|
|
/**
|
|
* v1.4.22 — `/onboarding` matches exact
|
|
* page + subroutes (`/onboarding`, `/onboarding/*`) only, NOT the
|
|
* loose `pathname.startsWith("/onboarding")` family that would admit
|
|
* `/onboarding-export`, `/onboarding.json`, etc. Every other entry
|
|
* is either a literal terminal path (`/api/version`, `/robots.txt`)
|
|
* or already trailing-slashed (`/auth/`, `/api/auth/`); the lone
|
|
* exception was the unsegmented `/onboarding` literal.
|
|
*/
|
|
function isPublicPath(pathname: string): boolean {
|
|
if (pathname === "/onboarding" || pathname.startsWith("/onboarding/")) {
|
|
return true;
|
|
}
|
|
// v1.22.0 — the remote MCP endpoint authenticates by `Authorization:
|
|
// Bearer hlk_…`, never by a session cookie, so it must skip the page
|
|
// cookie-redirect (mirroring the webhook / `/api/c/` header-authed
|
|
// surfaces). The route handler resolves the Bearer token itself, gates on
|
|
// the off-by-default `mcp` module, and rate-limits per credential. Exact
|
|
// `/mcp` plus `/mcp/` only — a `/mcp-something` page can never inherit
|
|
// the bypass.
|
|
if (pathname === "/mcp" || pathname.startsWith("/mcp/")) {
|
|
return true;
|
|
}
|
|
if (WELL_KNOWN_PUBLIC_PATHS.has(pathname)) {
|
|
return true;
|
|
}
|
|
return PUBLIC_PATHS.some((p) => pathname.startsWith(p));
|
|
}
|
|
|
|
/**
|
|
* API paths that are allowed to accept mutations (POST/PUT/PATCH/DELETE)
|
|
* even when DEMO_MODE is enabled. Everything else is read-only.
|
|
*
|
|
* Each entry pins the exact path AND the exact method, so admitting one
|
|
* verb on a route never opens its siblings (e.g. the dashboard-widgets
|
|
* PUT must not drag in the layout-reset DELETE on the same path).
|
|
*
|
|
* The login family is the historical baseline — a demo visitor still has
|
|
* to authenticate. The rest are idempotent, Zod-validated writes that carry
|
|
* no health data and no free text, and that land only on the acting record:
|
|
* the two dashboard display-preference blobs (chart-overlay toggles +
|
|
* comparison-baseline selector), the setup flow's closed-enum answers, its
|
|
* step ledger and disclaimer/tour checkpoints, and the baseline profile
|
|
* fields the confirm screen submits.
|
|
*
|
|
* The demo is ONE published account that every visitor signs into, so
|
|
* "user-scoped" is not privacy here — whatever one visitor writes is what
|
|
* the next one finds until the instance is reseeded. That is tolerable for
|
|
* a unit toggle and a date of birth, and it is why free text, anything that
|
|
* reaches an AI prompt, and anything creating a second record are refused.
|
|
*
|
|
* This whole block only runs under `DEMO_MODE=true`, so production (apps01)
|
|
* is unaffected by construction.
|
|
*/
|
|
const DEMO_MUTATION_ALLOWLIST: ReadonlyArray<{ path: string; method: string }> =
|
|
[
|
|
{ path: "/api/auth/login", method: "POST" },
|
|
{ path: "/api/auth/passkey/login-options", method: "POST" },
|
|
{ path: "/api/auth/passkey/login-verify", method: "POST" },
|
|
// Chart display-pref toggles above the charts (dashboard + insights):
|
|
{ path: "/api/dashboard/chart-overlay-prefs", method: "PUT" },
|
|
{ path: "/api/dashboard/widgets", method: "PUT" },
|
|
// v1.39 — the needs-based setup flow, which the demo has to be able
|
|
// to complete with preset answers (design spec §Principles 5). All three
|
|
// are user-scoped preference writes: closed enum answers, the step ledger,
|
|
// and the module map derived from them. No health data is created by any
|
|
// of them, and the demo account's own record is the only thing they touch.
|
|
{ path: "/api/onboarding/answers", method: "PATCH" },
|
|
{ path: "/api/onboarding/complete", method: "POST" },
|
|
{ path: "/api/onboarding/restart", method: "POST" },
|
|
// v1.39 — the two writes the flow's welcome and exit make: the
|
|
// one-time disclaimer acknowledgment gates both welcome buttons, and the
|
|
// tour's progress checkpoint is what the done screen's offer leads to.
|
|
// Without the first, a demo account on a fresh seed (or after a version
|
|
// bump of the disclaimer) got a 403 from "Set up" and "Skip for now" both.
|
|
// Neither creates health data.
|
|
{ path: "/api/onboarding/disclaimer", method: "POST" },
|
|
{ path: "/api/onboarding/tour", method: "POST" },
|
|
// v1.39 — the baseline write the confirm screen makes. Without it a demo
|
|
// visitor who typed a height got a 403 and a generic toast, so only the
|
|
// empty-form/Skip path completed and the demo did not in fact walk the
|
|
// same flow (design spec §Principles 5).
|
|
//
|
|
// Read the HANDLER's field list, not the caller's. This route writes
|
|
// `email`, `displayName`, `fullName`, `insurerName`, `insurerIkNumber`,
|
|
// `insuranceNumber`, `locale`, `timezone`, `timeFormat`, `dateFormat` and
|
|
// `moodReminderEnabled` as well as the three the baseline step collects —
|
|
// admitting the path admits all of them, and on one shared published
|
|
// account each is the next visitor's starting state. So the write is
|
|
// narrowed on the SERVER: `applyProfileUpdate` drops everything outside
|
|
// `DEMO_WRITABLE_PROFILE_FIELDS` (height, date of birth, sex) while
|
|
// `DEMO_MODE` is set. What this entry admits is those three fields.
|
|
//
|
|
// Two neighbours of it are deliberately NOT here. The demo is one shared,
|
|
// published account, so "the caller's own" is every visitor at once:
|
|
// `PUT /api/coach/about-me` is free text that persists for the next
|
|
// visitor AND is fed into the Coach prompt — an anonymous prompt-
|
|
// injection surface on a public instance. The flow's anamnesis card
|
|
// renders read-only under `DEMO_MODE` instead, so no visitor meets it
|
|
// as a failed save.
|
|
// `POST /api/managed-profiles` opens with `requireFreshMfa`, and the
|
|
// demo account has no second factor, so admitting it here would buy
|
|
// the guardian arm nothing — the call is refused a layer later either
|
|
// way, and the confirm screen already says so.
|
|
// The per-profile routes (`/api/managed-profiles/{id}`, its guardians)
|
|
// are different paths and stay closed too.
|
|
{ path: "/api/auth/profile", method: "PUT" },
|
|
// v1.42.2 — the record search is a read that travels as a POST so the
|
|
// query stays out of URLs and access logs. It writes nothing.
|
|
{ path: "/api/search", method: "POST" },
|
|
];
|
|
|
|
// Legacy route redirects (German → English)
|
|
const LEGACY_REDIRECTS: Record<string, string> = {
|
|
"/stimmung": "/mood",
|
|
};
|
|
|
|
/**
|
|
* v1.5 admin refactor — sections moved from `/admin#section-<id>` anchors
|
|
* to per-section dynamic routes under `/admin/<slug>`. Pure URL fragments
|
|
* (`#foo`) are never sent to the server, so we cannot redirect those
|
|
* here; the in-app callsites (status-card-grid, anything else linking
|
|
* into admin) have all been rewritten to use the new routes directly.
|
|
*
|
|
* What we *can* redirect server-side is the path-style form, in case
|
|
* anyone bookmarked `/admin/section-XYZ` (which never existed but is a
|
|
* plausible mis-typing) or is following an old URL where the fragment
|
|
* was somehow promoted to a path segment by a referrer rewriter.
|
|
*/
|
|
const LEGACY_ADMIN_ANCHORS: Record<string, string> = {
|
|
"/admin/section-system-status": "/admin/system-status",
|
|
"/admin/section-admin-general": "/admin/general",
|
|
"/admin/section-admin-services": "/admin/services",
|
|
"/admin/section-admin-umami": "/admin/integrations",
|
|
"/admin/section-admin-glitchtip": "/admin/integrations",
|
|
"/admin/section-admin-webpush": "/admin/integrations",
|
|
"/admin/section-admin-reminders": "/admin/reminders",
|
|
"/admin/section-user-management": "/admin/users",
|
|
"/admin/section-api-tokens": "/admin/api-tokens",
|
|
"/admin/section-login-overview": "/admin/login-overview",
|
|
"/admin/section-danger-zone": "/admin/danger-zone",
|
|
};
|
|
|
|
/**
|
|
* Presence marker on every HealthLog page response, read by `public/sw.js`.
|
|
*
|
|
* The service worker is registered with scope `/` and serves `/_next/static/*`
|
|
* cache-first. When HealthLog is stopped and a different app starts on the
|
|
* same origin (`localhost:3000` on a developer machine is the usual case), the
|
|
* worker went on serving HealthLog's cached chunks to that app (#847). It
|
|
* needs a way to tell a HealthLog page from someone else's, and nothing
|
|
* HealthLog already sends is specific enough: `x-request-id`, the nonce CSP
|
|
* and the COOP/COEP set are headers any Next.js app may emit. So the proxy
|
|
* adds one of its own.
|
|
*
|
|
* Presence only, value `1`: no version and no build id, because a version on
|
|
* every page is a free fingerprint for anyone scanning for an outdated
|
|
* instance and the worker does not need it. Pages only, not `/api/*`: the
|
|
* worker checks navigations and nothing else. Keep the name in lockstep with
|
|
* `HEALTHLOG_PAGE_MARKER_HEADER` in `public/sw.js`.
|
|
*/
|
|
export const HEALTHLOG_PAGE_MARKER_HEADER = "X-HealthLog-Page";
|
|
|
|
function generateNonce(): string {
|
|
const bytes = crypto.getRandomValues(new Uint8Array(16));
|
|
let binary = "";
|
|
for (const byte of bytes) {
|
|
binary += String.fromCharCode(byte);
|
|
}
|
|
return btoa(binary);
|
|
}
|
|
|
|
/**
|
|
* Transport-level security headers every response carries, including the early
|
|
* exits (worker-only 503, legacy 301s, demo-mode 403, and the login /
|
|
* onboarding / MFA redirects) that return before the full header block below.
|
|
*
|
|
* Those exits used to answer with no HSTS, no framing refusal and no nosniff at
|
|
* all. The gap was one hop — a redirect has no rendered body and the follow-up
|
|
* navigation re-enters this function and does get the full set — but "the next
|
|
* request will be protected" is not a reason for this one not to be, and HSTS
|
|
* in particular is worth the most on the first hop over a hostile network.
|
|
*
|
|
* Deliberately the transport subset, not the whole block: the per-route CSP
|
|
* below is nonce-bound and carries carve-outs for the document-serve and share
|
|
* routes that only make sense for a response with a body to protect.
|
|
*/
|
|
function applyBaselineSecurityHeaders(response: NextResponse): NextResponse {
|
|
setBaselineSecurityHeaders(response.headers);
|
|
return response;
|
|
}
|
|
|
|
/**
|
|
* The notification quick-answer route (`/api/notifications/action/<token>`).
|
|
* Its caller is a notification app on a phone, not a browser, so when a tap
|
|
* does nothing the operator's only evidence is the server log. Every refusal
|
|
* this proxy makes on that path therefore writes one line with a reason,
|
|
* because the route's own wide event never runs for them. The token is never
|
|
* written: the path is logged as the prefix plus a placeholder.
|
|
*/
|
|
const NOTIFICATION_ACTION_PREFIX = "/api/notifications/action/";
|
|
|
|
/** The request header that marks a Next.js Server Action call. */
|
|
const SERVER_ACTION_HEADER = "next-action";
|
|
|
|
type NotificationActionRefusal =
|
|
"method_not_allowed" | "worker_only" | "demo_mode";
|
|
|
|
function logNotificationActionRefusal(
|
|
request: NextRequest,
|
|
reason: NotificationActionRefusal,
|
|
status: number,
|
|
): void {
|
|
if (!request.nextUrl.pathname.startsWith(NOTIFICATION_ACTION_PREFIX)) return;
|
|
console.warn(
|
|
JSON.stringify({
|
|
timestamp: new Date().toISOString(),
|
|
level: "warn",
|
|
kind: "http",
|
|
message:
|
|
reason === "method_not_allowed"
|
|
? "Notification answer refused: the route takes POST only. A redirect in front of the app (http to https, or a sign-in gateway) turns the POST into a GET."
|
|
: "Notification answer refused before the route ran.",
|
|
action: { name: "notification.action.refused" },
|
|
meta: { reason },
|
|
http: {
|
|
method: request.method,
|
|
path: `${NOTIFICATION_ACTION_PREFIX}[REDACTED]`,
|
|
status,
|
|
user_agent: request.headers.get("user-agent") ?? undefined,
|
|
},
|
|
}),
|
|
);
|
|
}
|
|
|
|
export function proxy(request: NextRequest) {
|
|
const { pathname } = request.nextUrl;
|
|
|
|
// Worker-only container: refuse HTTP traffic with a clear hint instead of
|
|
// serving requests that would either crash (no DB pool ready for writes from
|
|
// the worker) or duplicate work that the dedicated web container is doing.
|
|
if (!shouldRunWeb() && pathname !== "/api/health") {
|
|
logNotificationActionRefusal(request, "worker_only", 503);
|
|
return applyBaselineSecurityHeaders(
|
|
NextResponse.json(
|
|
{
|
|
data: null,
|
|
error:
|
|
"This container runs the worker only — point HTTP at the web service.",
|
|
},
|
|
{ status: 503, headers: { "X-HealthLog-Process-Type": "worker" } },
|
|
),
|
|
);
|
|
}
|
|
|
|
// A Server Action call. HealthLog defines no Server Actions (no module
|
|
// carries `"use server"`, which `no-server-actions-guard.test.ts` holds), so
|
|
// no bundle of this app, old or new, ever sends `Next-Action`: the requests
|
|
// that do are scanners probing the Server Action surface, nightly and in
|
|
// bursts. Left to Next, each one printed "Failed to find Server Action" at
|
|
// error level, unstructured, an error with no user behind it. Refused here
|
|
// instead, with one countable `warn` line: a refusal by design.
|
|
if (request.headers.has(SERVER_ACTION_HEADER)) {
|
|
console.warn(
|
|
JSON.stringify({
|
|
timestamp: new Date().toISOString(),
|
|
level: "warn",
|
|
kind: "http",
|
|
message: "Server Action call refused: this app defines none.",
|
|
action: { name: "proxy.server_action.refused" },
|
|
http: {
|
|
method: request.method,
|
|
// A probe may aim at a path that carries a secret segment.
|
|
path: redactSecrets(pathname),
|
|
status: 404,
|
|
user_agent: request.headers.get("user-agent") ?? undefined,
|
|
},
|
|
}),
|
|
);
|
|
return applyBaselineSecurityHeaders(
|
|
NextResponse.json(
|
|
{
|
|
data: null,
|
|
error: "Not found",
|
|
meta: { errorCode: "server_action.unsupported" },
|
|
},
|
|
{ status: 404 },
|
|
),
|
|
);
|
|
}
|
|
|
|
// 301 redirects for renamed routes
|
|
const redirect = LEGACY_REDIRECTS[pathname];
|
|
if (redirect) {
|
|
return applyBaselineSecurityHeaders(
|
|
NextResponse.redirect(new URL(redirect, request.url), 301),
|
|
);
|
|
}
|
|
|
|
// 301 for the v1.5 admin section-anchor → dynamic-route migration.
|
|
const legacyAdmin = LEGACY_ADMIN_ANCHORS[pathname];
|
|
if (legacyAdmin) {
|
|
return applyBaselineSecurityHeaders(
|
|
NextResponse.redirect(new URL(legacyAdmin, request.url), 301),
|
|
);
|
|
}
|
|
|
|
// A route that existed and is gone answers 410, not 404 — see
|
|
// `src/lib/http/retired-routes.ts` for why the status carries the message
|
|
// and the error code only fills in the detail.
|
|
//
|
|
// Placed before the demo-mode block and before the page session gate on
|
|
// purpose. A retired path is retired on a demo deploy too, and answering a
|
|
// logged-out caller with a login redirect would send them to authenticate
|
|
// for something that no longer exists. It reads nothing off the request but
|
|
// the path, so it cannot answer differently for an authenticated caller than
|
|
// for an anonymous one.
|
|
const retired = findRetiredRoute(pathname);
|
|
if (retired) {
|
|
return applyBaselineSecurityHeaders(
|
|
NextResponse.json(retiredRouteEnvelope(retired), {
|
|
status: RETIRED_ROUTE_STATUS,
|
|
}),
|
|
);
|
|
}
|
|
|
|
// A notification quick answer is a POST and nothing else. ntfy's `http`
|
|
// action sends one; anything else here is a request a redirect rewrote (an
|
|
// HTTP client follows a 301/302 with a GET) or a stray probe. Next would
|
|
// answer 405 on its own, but before any wide event, so the refusal is made
|
|
// here where it can be logged. `OPTIONS` stays with Next.
|
|
if (
|
|
pathname.startsWith(NOTIFICATION_ACTION_PREFIX) &&
|
|
request.method !== "POST" &&
|
|
request.method !== "OPTIONS"
|
|
) {
|
|
logNotificationActionRefusal(request, "method_not_allowed", 405);
|
|
return applyBaselineSecurityHeaders(
|
|
NextResponse.json(
|
|
{
|
|
data: null,
|
|
error: "Method not allowed",
|
|
meta: { errorCode: "notification.action.method_not_allowed" },
|
|
},
|
|
{ status: 405, headers: { Allow: "POST" } },
|
|
),
|
|
);
|
|
}
|
|
|
|
// Demo mode: block all mutations except login
|
|
if (process.env.DEMO_MODE === "true") {
|
|
const method = request.method.toUpperCase();
|
|
const isMutation =
|
|
method !== "GET" && method !== "HEAD" && method !== "OPTIONS";
|
|
const isApi = pathname.startsWith("/api/");
|
|
if (
|
|
isApi &&
|
|
isMutation &&
|
|
!DEMO_MUTATION_ALLOWLIST.some(
|
|
(entry) => pathname === entry.path && method === entry.method,
|
|
)
|
|
) {
|
|
logNotificationActionRefusal(request, "demo_mode", 403);
|
|
return applyBaselineSecurityHeaders(
|
|
NextResponse.json(DEMO_REFUSAL_BODY, { status: 403 }),
|
|
);
|
|
}
|
|
}
|
|
|
|
// v1.38.19 — the invite universal-link landing answers at the EDGE.
|
|
//
|
|
// `src/app/invite/[token]/page.tsx` has always redirected onto
|
|
// `/auth/register?invite=…`, but Next does not deliver that as a server 307:
|
|
// it streams the root layout first and puts the redirect in the RSC flight
|
|
// payload. `<AuthShell>` therefore mounted on the pathname `/invite/…`, and
|
|
// before the entry added beside this one it read the route as protected,
|
|
// took a 401 from `/api/auth/me` and raced its own
|
|
// `router.replace("/auth/login")` against the redirect — logged out the
|
|
// login bounce won, and the visitor held a one-time invite on a page that
|
|
// cannot use it. Answering here means no HTML, no hydration, no race.
|
|
//
|
|
// The page stays as the fallback for any path that bypasses the matcher.
|
|
// The shape gate is the twin of `looksLikeInviteToken` in
|
|
// `src/lib/auth/invite-token.ts`, restated rather than imported: that module
|
|
// pulls the Prisma client, which has no business in the proxy bundle. A
|
|
// segment that fails it is dropped rather than reflected, so the target can
|
|
// never echo attacker-controlled text.
|
|
if (pathname.startsWith("/invite/")) {
|
|
const token = pathname.slice("/invite/".length).split("/")[0] ?? "";
|
|
const target = new URL("/auth/register", request.url);
|
|
if (/^hlv_[0-9a-f]{64}$/.test(token)) {
|
|
target.searchParams.set("invite", token);
|
|
}
|
|
const redirected = applyBaselineSecurityHeaders(
|
|
NextResponse.redirect(target, 307),
|
|
);
|
|
// The secret rides in the path of the request that produced this hop, so
|
|
// the hop itself carries the same defence the landing page earned in
|
|
// v1.17.0: uncacheable, unindexable, and never leaked as a `Referer`.
|
|
redirected.headers.set(
|
|
"Cache-Control",
|
|
"no-store, no-cache, must-revalidate",
|
|
);
|
|
redirected.headers.set("X-Robots-Tag", "noindex, nofollow");
|
|
redirected.headers.set("Referrer-Policy", "no-referrer");
|
|
return redirected;
|
|
}
|
|
|
|
// v1.42 (#959) — the managed-profile handover link answers at the edge for
|
|
// the same reasons as the invite link above: no HTML, no hydration race, the
|
|
// shape gate drops anything that is not a handover token rather than
|
|
// reflecting it, and the hop is uncacheable, unindexable and never leaked as
|
|
// a `Referer`. The shape is restated from `src/lib/auth/handover-token.ts`.
|
|
if (pathname.startsWith("/claim/")) {
|
|
const token = pathname.slice("/claim/".length).split("/")[0] ?? "";
|
|
const target = new URL("/auth/claim", request.url);
|
|
if (/^hlp_[0-9a-f]{64}$/.test(token)) {
|
|
target.searchParams.set("token", token);
|
|
}
|
|
const redirected = applyBaselineSecurityHeaders(
|
|
NextResponse.redirect(target, 307),
|
|
);
|
|
redirected.headers.set(
|
|
"Cache-Control",
|
|
"no-store, no-cache, must-revalidate",
|
|
);
|
|
redirected.headers.set("X-Robots-Tag", "noindex, nofollow");
|
|
redirected.headers.set("Referrer-Policy", "no-referrer");
|
|
return redirected;
|
|
}
|
|
|
|
// Server-side route protection for pages (not API routes — those have their own getSession checks)
|
|
const isApiRoute = pathname.startsWith("/api/");
|
|
const isStaticFile = /\.\w+$/.test(pathname);
|
|
const isPublic = isPublicPath(pathname);
|
|
// Every page behind the session gate. The same predicate drives the
|
|
// auth redirect below and the `no-store` header further down, so a page
|
|
// can never be session-gated without also being uncacheable.
|
|
const isSessionGatedPage = !isApiRoute && !isStaticFile && !isPublic;
|
|
if (isSessionGatedPage) {
|
|
const hasSession = request.cookies.has("healthlog_session");
|
|
if (!hasSession) {
|
|
return applyBaselineSecurityHeaders(
|
|
NextResponse.redirect(new URL("/auth/login", request.url)),
|
|
);
|
|
}
|
|
|
|
// v1.4.22 — server-side onboarding redirect. Previously the
|
|
// `<AuthShell>` ran this in a post-hydration `useEffect`, so a user
|
|
// with `onboardingCompletedAt === null` briefly saw the dashboard
|
|
// flash before the redirect fired. The auth flows (login, passkey,
|
|
// register, /api/auth/me) keep an `hl_onboarding` cookie in sync
|
|
// with the DB state; the proxy reads it without a DB roundtrip so
|
|
// the redirect lands on the first server response. The /onboarding
|
|
// page itself is in PUBLIC_PATHS above, so this branch only runs
|
|
// for the surfaces that need to redirect away.
|
|
const onboardingPending =
|
|
request.cookies.get("hl_onboarding")?.value === "pending";
|
|
// v1.4.22 — exact match the redirect
|
|
// short-circuit so a hypothetical `/onboarding-export` route can
|
|
// never inherit the silent-pass-through.
|
|
const isOnboardingSurface =
|
|
pathname === "/onboarding" || pathname.startsWith("/onboarding/");
|
|
if (onboardingPending && !isOnboardingSurface) {
|
|
return applyBaselineSecurityHeaders(
|
|
NextResponse.redirect(new URL("/onboarding", request.url)),
|
|
);
|
|
}
|
|
|
|
// v1.23 — admin-enforced MFA forced-enrollment gate. When the operator
|
|
// requires a second factor and the account has none, `/api/auth/me` (and
|
|
// every auth surface) sets `hl_mfa_enroll=required`; the proxy reads it
|
|
// without a DB round-trip — mirroring the onboarding hint — and walls the
|
|
// app behind the enrollment surface. Only `/enroll-mfa` and
|
|
// `/settings/security` stay reachable so the user can actually enrol;
|
|
// logout + the enrollment APIs are `/api/*` and never page-gated here.
|
|
// Onboarding takes precedence (the branch above already returned), so a
|
|
// brand-new account finishes onboarding first.
|
|
const mfaEnrollRequired =
|
|
request.cookies.get("hl_mfa_enroll")?.value === "required";
|
|
const isMfaEnrollSurface =
|
|
pathname === "/enroll-mfa" || pathname.startsWith("/settings/security");
|
|
if (mfaEnrollRequired && !isMfaEnrollSurface && !isOnboardingSurface) {
|
|
return applyBaselineSecurityHeaders(
|
|
NextResponse.redirect(new URL("/enroll-mfa", request.url)),
|
|
);
|
|
}
|
|
}
|
|
|
|
// Generate or propagate x-request-id for request correlation. An incoming
|
|
// id is adopted only when it is a plain token (`resolveRequestId`); it is
|
|
// echoed and logged, so anything else is replaced with a fresh one.
|
|
const requestId = resolveRequestId(request.headers.get("x-request-id"));
|
|
|
|
// 128 bits of random, base64-encoded. randomUUID().toString() only carries
|
|
// ~122 bits and has a predictable structure that base64-encodes to a
|
|
// partially guessable pattern.
|
|
const nonce = generateNonce();
|
|
const requestHeaders = new Headers(request.headers);
|
|
requestHeaders.set("x-nonce", nonce);
|
|
requestHeaders.set("x-request-id", requestId);
|
|
|
|
const response = NextResponse.next({
|
|
request: {
|
|
headers: requestHeaders,
|
|
},
|
|
});
|
|
|
|
// Request ID for log correlation
|
|
response.headers.set("x-request-id", requestId);
|
|
|
|
if (!isApiRoute) {
|
|
response.headers.set(HEALTHLOG_PAGE_MARKER_HEADER, "1");
|
|
}
|
|
|
|
// The document vault's decrypt-and-serve route is framed SAME-ORIGIN by
|
|
// the document detail preview (an <iframe> for inline-class PDFs). The
|
|
// blanket `X-Frame-Options: DENY` + `frame-ancestors 'none'` below would
|
|
// refuse that embed, and the blanket page CSP would clobber the route's
|
|
// own `Content-Security-Policy: sandbox` (middleware headers win over
|
|
// route headers). Scope: exactly the `/original` path — list/detail API
|
|
// routes keep the full DENY posture. The replacement CSP keeps the
|
|
// sandbox (the served bytes are user-uploaded and must never script in
|
|
// the app origin) and narrows framing to 'self' instead of dropping it.
|
|
const isDocumentServeRoute =
|
|
/^\/api\/documents\/inbound\/[^/]+\/original$/.test(pathname);
|
|
|
|
// v1.28 — the public share-scoped document serve route at
|
|
// `/c/<token>/d/<id>` is the share analogue of the owner `/original` route:
|
|
// it decrypts and serves a user-uploaded document that the clinician view
|
|
// (`/c/<token>`) frames same-origin (an <iframe> for inline-class PDFs). It
|
|
// needs the SAME narrow posture — SAMEORIGIN framing + a document CSP — and
|
|
// NOT the `/c/` page CSP, which pins `frame-ancestors 'none'` and would
|
|
// refuse the preview embed. Anchored to EXACTLY two dynamic segments so a
|
|
// `/c/<token>` page, a `/c/<token>/d/<id>/extra`, or a `/c/<token>/dx/<id>`
|
|
// lookalike never inherits the carve-out.
|
|
const isShareDocumentServeRoute = /^\/c\/[^/]+\/d\/[^/]+$/.test(pathname);
|
|
|
|
// Both serve routes take the narrow, document-only header posture.
|
|
const isServeRoute = isDocumentServeRoute || isShareDocumentServeRoute;
|
|
|
|
// Session-gated pages carry one account's health record in the document
|
|
// itself. Several of them server-prefetch their data and serialise it into
|
|
// the HTML through a TanStack `HydrationBoundary` — the dashboard, Insights,
|
|
// the workouts list, the medications list, the Coach. Those pages are
|
|
// dynamic today only as a side effect of reading the session cookie, which
|
|
// is a property of the page code, not a guarantee: a stray `revalidate`
|
|
// export, a `force-static` segment, or a caching proxy in front of the app
|
|
// would be enough to retain one account's record and hand it to the next
|
|
// caller. Pin the guarantee at the edge instead, where no page-level change
|
|
// can weaken it.
|
|
//
|
|
// `private` bars shared caches (CDN, reverse proxy) outright; `no-store`
|
|
// additionally stops the browser's own disk cache and the bfcache snapshot,
|
|
// so a back-navigation after logout cannot repaint the record. The public
|
|
// surfaces (`/auth/*`, `/privacy`, `/about`, `/i18n/*`) are excluded by the
|
|
// same predicate that governs the auth redirect, so the statically
|
|
// rendered marketing/legal pages stay cacheable.
|
|
if (isSessionGatedPage) {
|
|
response.headers.set("Cache-Control", "private, no-store");
|
|
}
|
|
|
|
// Security headers
|
|
response.headers.set("X-Content-Type-Options", "nosniff");
|
|
response.headers.set("X-Frame-Options", isServeRoute ? "SAMEORIGIN" : "DENY");
|
|
response.headers.set("Referrer-Policy", "strict-origin-when-cross-origin");
|
|
response.headers.set(
|
|
"Permissions-Policy",
|
|
"camera=(), microphone=(), geolocation=()",
|
|
);
|
|
|
|
// v1.11.0 — the public clinician share view at `/c/<token>`
|
|
// is a scoped health record authenticated by an unguessable bearer token
|
|
// in the path. Defend it at the edge regardless of what the RSC emits:
|
|
// - `Cache-Control: no-store` so no shared proxy / CDN ever retains a
|
|
// scoped record (the page is `force-dynamic`, but that governs Next's
|
|
// cache, not a downstream intermediary).
|
|
// - `X-Robots-Tag: noindex, nofollow` as the header peer of the page's
|
|
// `robots` meta — a crawler that never parses the document still obeys
|
|
// the header, and the token must never reach a search index.
|
|
// - `Referrer-Policy: no-referrer` so the token-bearing URL is not
|
|
// leaked in the `Referer` of any outbound navigation from the page.
|
|
//
|
|
// v1.17.0 — `/invite/<hlv_token>` (iOS #16) carries an equally sensitive
|
|
// secret in the path, so it earns the same edge defence even though it is
|
|
// a pure server redirect: no shared proxy / CDN may cache it and no
|
|
// crawler may index the token-bearing URL.
|
|
if (pathname.startsWith("/c/") || pathname.startsWith("/invite/")) {
|
|
response.headers.set(
|
|
"Cache-Control",
|
|
"no-store, no-cache, must-revalidate",
|
|
);
|
|
response.headers.set("X-Robots-Tag", "noindex, nofollow");
|
|
response.headers.set("Referrer-Policy", "no-referrer");
|
|
}
|
|
// COOP isolates this BrowsingContextGroup from cross-origin popups,
|
|
// closing the Spectre-class side-channel surface a stray
|
|
// `window.opener` reference would otherwise carry. CORP narrows the
|
|
// page's resources to same-origin loaders, complementing the
|
|
// `frame-ancestors 'none'` CSP rule for non-document subresources.
|
|
// The legacy `X-Permitted-Cross-Domain-Policies` line shuts the
|
|
// Flash/PDF crossdomain.xml channel that some scanners still flag.
|
|
response.headers.set("Cross-Origin-Opener-Policy", "same-origin");
|
|
response.headers.set("Cross-Origin-Resource-Policy", "same-origin");
|
|
// COEP `credentialless` completes the cross-origin-isolation triad
|
|
// without the operational hazard of `require-corp`: cross-origin
|
|
// no-CORS subresources load with credentials stripped instead of
|
|
// being blocked outright. The app's CSP already pins `img-src` to
|
|
// `'self' data:` (Gravatar is proxied same-origin since v1.5.5), so
|
|
// no shipped surface loads cross-origin subresources at all — the
|
|
// header is pure defence-in-depth against a future regression and
|
|
// unlocks `crossOriginIsolated` capabilities should they be needed.
|
|
response.headers.set("Cross-Origin-Embedder-Policy", "credentialless");
|
|
response.headers.set("X-Permitted-Cross-Domain-Policies", "none");
|
|
|
|
// CSP — permissive in dev, strict in production. Third-party hosts in
|
|
// `connect-src` are gated to the surfaces that actually need them so a
|
|
// DOM-XSS on an unrelated page can't exfiltrate to them (V3 audit:
|
|
// blanket chatgpt.com on /auth/login was a DOM-XSS exfil channel).
|
|
//
|
|
// F-5 (mobile security audit, 2026-05-16): `wbsapi.withings.net` used
|
|
// to live in the global `connect-src` and shipped on every page. The
|
|
// Withings client lives server-side, so the browser never needs to
|
|
// reach it from a non-Withings surface; mirror the AI gating shape.
|
|
const isDev = process.env.NODE_ENV === "development";
|
|
const cspReportEndpoint = "/api/monitoring/csp-report";
|
|
const isAiSettingsRoute = pathname.startsWith("/settings/ai");
|
|
const aiConnectSrc = isAiSettingsRoute
|
|
? " https://api.openai.com https://chatgpt.com"
|
|
: "";
|
|
const isWithingsRoute =
|
|
pathname.startsWith("/settings/integrations/withings") ||
|
|
pathname.startsWith("/api/withings/");
|
|
const withingsConnectSrc = isWithingsRoute
|
|
? " https://wbsapi.withings.net"
|
|
: "";
|
|
// v1.11.0 — `api.prod.whoop.com` gated to the WHOOP settings surface +
|
|
// `/api/whoop/*`, mirroring the Withings gating shape. The WHOOP data
|
|
// client lives server-side and the OAuth handshake is a browser
|
|
// redirect (not a fetch), so this is belt-and-suspenders parity — no
|
|
// other surface ever needs to reach the WHOOP host from the browser.
|
|
const isWhoopRoute =
|
|
pathname.startsWith("/settings/integrations/whoop") ||
|
|
pathname.startsWith("/api/whoop/");
|
|
const whoopConnectSrc = isWhoopRoute ? " https://api.prod.whoop.com" : "";
|
|
// v1.28.x — `www.strava.com` gated to the Strava settings surface +
|
|
// `/api/strava/*`, mirroring the WHOOP gating shape. The Strava data client
|
|
// lives server-side and the OAuth handshake is a browser redirect (not a
|
|
// fetch), so this is belt-and-suspenders parity — no other surface ever needs
|
|
// to reach the Strava host from the browser.
|
|
const isStravaRoute =
|
|
pathname.startsWith("/settings/integrations/strava") ||
|
|
pathname.startsWith("/api/strava/");
|
|
const stravaConnectSrc = isStravaRoute ? " https://www.strava.com" : "";
|
|
// Local lab OCR runs tesseract's WebAssembly engine in a same-origin worker
|
|
// served from `/tesseract/`. A worker runs under the CSP delivered with its
|
|
// own script, so compiling the WebAssembly needs `'wasm-unsafe-eval'` on
|
|
// that response. It is also granted on the two pages that start the OCR
|
|
// (`/labs` for lab scans, `/documents` for document text), in case the
|
|
// engine compiles on the page. Nowhere else: `'wasm-unsafe-eval'` allows
|
|
// compiling WebAssembly, not `eval`, but a page that never runs the engine
|
|
// has no reason to carry it. (A client-side navigation keeps the CSP of
|
|
// the page first loaded, which is why the worker script's own response is
|
|
// the grant that matters.)
|
|
const isLocalOcrSurface =
|
|
pathname.startsWith("/tesseract/") ||
|
|
pathname === "/labs" ||
|
|
pathname === "/documents";
|
|
const wasmScriptSrc = isLocalOcrSurface ? " 'wasm-unsafe-eval'" : "";
|
|
// v1.5.5 — Gravatar host removed from `img-src`. The /me payload
|
|
// used to return `gravatarUrl: https://www.gravatar.com/avatar/<sha256(email)>`,
|
|
// which leaked the email digest to Automattic on every authenticated
|
|
// page-load. Avatars now live on the User row and serve from
|
|
// same-origin `/api/user/avatar/{id}`, so `img-src 'self'` covers
|
|
// them.
|
|
const csp = isServeRoute
|
|
? // Vault serve route (owner `/original` OR share `/c/<token>/d/<id>`; see
|
|
// the X-Frame-Options note above): the
|
|
// response is a user-uploaded document, not an app page.
|
|
// `default-src 'none'` — a served document may load nothing as if
|
|
// it were a page — and `frame-ancestors 'self'` permits exactly the
|
|
// same-origin preview iframe. Deliberately NO `sandbox` directive:
|
|
// Chromium refuses to render PDFs in sandboxed documents (it
|
|
// force-downloads them), which would kill the inline preview
|
|
// outright. The load-bearing boundaries for user-uploaded bytes
|
|
// stay: magic-byte classification at upload, true Content-Type +
|
|
// `nosniff` at serve (a PDF can never be reinterpreted as HTML),
|
|
// HTML/SVG denied entirely, and attachment-only posture for every
|
|
// non-passive format.
|
|
`default-src 'none'; frame-ancestors 'self';`
|
|
: // v1.37.15 — `frame-src 'self' blob:` + `blob:` in `img-src` (both dev
|
|
// and prod). Since the record fence (v1.37.0) every vault preview loads
|
|
// through `useFencedObjectUrl`, i.e. a `blob:` object URL: without a
|
|
// `frame-src` the PDF preview iframe fell through to `default-src
|
|
// 'self'` and was blocked loudly, and `img-src 'self' data:` degraded
|
|
// every blob thumbnail to its fallback icon silently. Admitting `blob:`
|
|
// is same-origin-tight: a blob: URL can only be minted by script running
|
|
// on this origin (`URL.createObjectURL`), script execution stays
|
|
// nonce-bound (prod), and `object-src 'none'` / `frame-ancestors
|
|
// 'none'` / `default-src 'self'` are unchanged — no foreign origin
|
|
// gains anything. Reverting the previews to direct `/api` src instead
|
|
// is barred by the record-fence guard.
|
|
isDev
|
|
? `default-src 'self'; script-src 'self' 'unsafe-eval' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; frame-src 'self' blob:; connect-src 'self'; font-src 'self';`
|
|
: `default-src 'self'; script-src 'self' 'nonce-${nonce}'${wasmScriptSrc}; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; frame-src 'self' blob:; connect-src 'self'${aiConnectSrc}${withingsConnectSrc}${whoopConnectSrc}${stravaConnectSrc}; font-src 'self'; object-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'none'; worker-src 'self'; report-uri ${cspReportEndpoint}; report-to csp-endpoint;`;
|
|
// The MCP OAuth authorization endpoint sets its own CSP. Its consent form
|
|
// posts to this origin and the server then redirects to the connecting
|
|
// client's `redirect_uri`; Chromium applies `form-action` to that redirect,
|
|
// so the app-wide `form-action 'self'` would strand the user after the
|
|
// grant. The route allows exactly the redirect origin it validated for the
|
|
// request (`src/lib/mcp/oauth/consent-csp.ts`). Middleware headers override
|
|
// route headers, so the proxy must leave the header unset on this exact
|
|
// path; every other header above still applies.
|
|
const isMcpAuthorizeRoute = pathname === "/api/mcp/oauth/authorize";
|
|
if (!isMcpAuthorizeRoute) {
|
|
response.headers.set("Content-Security-Policy", csp);
|
|
}
|
|
|
|
// Production-only headers. HSTS carries `preload` so the domain stays
|
|
// eligible for the Chromium preload list — closes the first-visit MITM
|
|
// window on hostile networks (F-5, mobile security audit 2026-05-16).
|
|
if (!isDev) {
|
|
response.headers.set(
|
|
"Reporting-Endpoints",
|
|
`csp-endpoint="${cspReportEndpoint}"`,
|
|
);
|
|
response.headers.set(
|
|
"Strict-Transport-Security",
|
|
"max-age=31536000; includeSubDomains; preload",
|
|
);
|
|
}
|
|
|
|
return response;
|
|
}
|
|
|
|
export const config = {
|
|
matcher: [
|
|
// Apply to all routes except static files, Next.js internals, SW, and
|
|
// manifest — and except the large-upload API routes listed in
|
|
// `src/lib/http/proxy-bypass-routes.ts`. Next reads a request body into
|
|
// memory for the proxy before the route runs, so a route whose body may
|
|
// exceed `proxyClientMaxBodySize` (1 MB, next.config.ts) is left out of
|
|
// the matcher and `apiHandler` performs the proxy's duties for it. The
|
|
// alternation must stay equal to that list; `proxy-body-limit.test.ts`
|
|
// compiles this exactly as Next does and checks.
|
|
"/((?!_next/static|_next/image|favicon.ico|sw\\.js|manifest\\.json|robots\\.txt|sitemap\\.xml|api/(?:import(?:/csv|/labs-csv|/medications-csv|/apple-health-export|/health-connect-export)?|admin/import-apple-health-export|admin/backups/upload|documents/inbound|medications/intake/(?:dose-history-import|bulk)|labs/ocr/extract|medications/[^/]+/ingredients/extract|user/avatar|measurements/batch|workouts/batch|insights/ecg|mood-entries/bulk|cycle/day-logs/bulk)/?$|.*\\.(?:svg|png|jpg|jpeg|gif|webp|txt|xml)$).*)",
|
|
],
|
|
};
|