mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
Extend the /c/ edge defence (no-store, noindex, no-referrer) to the /invite/<hlv_token> surface — the invite secret rides in the path, so no CDN may cache it and no crawler may index the URL.
379 lines
17 KiB
TypeScript
379 lines
17 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.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.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;
|
|
}
|
|
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-bugreport": "/admin/integrations",
|
|
"/admin/section-admin-feedback": "/admin/feedback",
|
|
"/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));
|
|
}
|
|
}
|
|
|
|
// 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)$).*)",
|
|
],
|
|
};
|