mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
Combine the remote MCP server (stdio + remote /mcp + OAuth 2.1 + read tools + deep-value reads + a confirmed, audience-bound write surface) with the v1.23 account-security line (MFA, known/trusted devices, free-text note encryption). - schema: keep every v1.23 User relation (MFA, known/trusted devices) alongside the McpOAuthConnection relation; the MeasurementSource enum carries the MCP value. - api-handler: the MCP audience guard (a token scoped to health:read/health:write is bound to /mcp and refused a REST write) and the step-up MFA helpers coexist. - maintenance queues: register the MCP token-cleanup sweep next to the note-encryption backfill and key-rotation queues; shift it to 04:00 so the two daily discovery ticks do not collide. - migrations: renumber the MCP migrations to 0206/0207 so they follow the v1.23 chain (0199-0205). - MCP mood writes route the note through the v1.23 at-rest cipher.
417 lines
19 KiB
TypeScript
417 lines
19 KiB
TypeScript
import { NextResponse, type NextRequest } from "next/server";
|
|
import { shouldRunWeb } from "@/lib/process-type";
|
|
|
|
/**
|
|
* 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.
|
|
*/
|
|
const PUBLIC_PATHS = [
|
|
"/auth/",
|
|
"/api/auth/login",
|
|
"/api/auth/register",
|
|
"/api/auth/registration-status",
|
|
"/api/auth/passkey/login-options",
|
|
"/api/auth/passkey/login-verify",
|
|
"/api/health",
|
|
"/api/version",
|
|
"/api/notifications/vapid",
|
|
"/api/monitoring/",
|
|
"/api/send",
|
|
"/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/integrations/moodlog/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 B3 — `/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/",
|
|
// `/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 W5 reconcile (Sec-MED-2) — `/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 two dashboard entries are display-only preference
|
|
* writes: idempotent, user-scoped, Zod-validated, and touching nothing but
|
|
* the caller's own `User.dashboardWidgetsJson` blob (chart-overlay toggles
|
|
* + comparison-baseline selector). They carry no health data and are safe
|
|
* to exercise in the demo so the above-chart toggles work there. 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" },
|
|
];
|
|
|
|
// 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",
|
|
};
|
|
|
|
function generateNonce(): string {
|
|
const bytes = crypto.getRandomValues(new Uint8Array(16));
|
|
let binary = "";
|
|
for (const byte of bytes) {
|
|
binary += String.fromCharCode(byte);
|
|
}
|
|
return btoa(binary);
|
|
}
|
|
|
|
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()) {
|
|
return 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" } },
|
|
);
|
|
}
|
|
|
|
// 301 redirects for renamed routes
|
|
const redirect = LEGACY_REDIRECTS[pathname];
|
|
if (redirect) {
|
|
return 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 NextResponse.redirect(new URL(legacyAdmin, request.url), 301);
|
|
}
|
|
|
|
// 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,
|
|
)
|
|
) {
|
|
return NextResponse.json(
|
|
{
|
|
data: null,
|
|
error: "Demo mode: modifications are disabled",
|
|
meta: { demo: true },
|
|
},
|
|
{ status: 403 },
|
|
);
|
|
}
|
|
}
|
|
|
|
// 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);
|
|
if (!isApiRoute && !isStaticFile && !isPublic) {
|
|
const hasSession = request.cookies.has("healthlog_session");
|
|
if (!hasSession) {
|
|
return NextResponse.redirect(new URL("/auth/login", request.url));
|
|
}
|
|
|
|
// v1.4.22 C4 — 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 W5 reconcile (Sec-MED-2) — 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 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 NextResponse.redirect(new URL("/enroll-mfa", request.url));
|
|
}
|
|
}
|
|
|
|
// Generate or propagate x-request-id for request correlation
|
|
const requestId = request.headers.get("x-request-id") || crypto.randomUUID();
|
|
|
|
// 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);
|
|
|
|
// Security headers
|
|
response.headers.set("X-Content-Type-Options", "nosniff");
|
|
response.headers.set("X-Frame-Options", "DENY");
|
|
response.headers.set("Referrer-Policy", "strict-origin-when-cross-origin");
|
|
response.headers.set(
|
|
"Permissions-Policy",
|
|
"camera=(), microphone=(), geolocation=()",
|
|
);
|
|
|
|
// v1.11.0 (Epic C, C6) — 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.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 = isDev
|
|
? `default-src 'self'; script-src 'self' 'unsafe-eval' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src 'self'; font-src 'self';`
|
|
: `default-src 'self'; script-src 'self' 'nonce-${nonce}'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src 'self'${aiConnectSrc}${withingsConnectSrc}${whoopConnectSrc}; 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;`;
|
|
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
|
|
"/((?!_next/static|_next/image|favicon.ico|sw\\.js|manifest\\.json|robots\\.txt|sitemap\\.xml|.*\\.(?:svg|png|jpg|jpeg|gif|webp|txt|xml)$).*)",
|
|
],
|
|
};
|