diff --git a/src/lib/logging/__tests__/redact.test.ts b/src/lib/logging/__tests__/redact.test.ts index cbc9c4185..6ee7c294e 100644 --- a/src/lib/logging/__tests__/redact.test.ts +++ b/src/lib/logging/__tests__/redact.test.ts @@ -195,6 +195,35 @@ describe("redactSecrets", () => { "/api/withings/auth/callback", ); }); + + // v1.11.0 (Epic C, C6) — the raw `hls_` clinician share token rides as + // the trailing path segment of `/c/`. It must be scrubbed from + // `http.path` / `http.route` before the Wide Event leaves the process. + it("registers the clinician share-view prefix in PATH_SECRET_PATHS", () => { + expect(PATH_SECRET_PATHS.map((entry) => entry.prefix)).toContain("/c/"); + }); + + it("redacts the share token in the `/c/` path", () => { + expect( + redactSecrets( + "/c/hls_0123456789abcdef0123456789abcdef0123456789abcdef", + ), + ).toBe("/c/[REDACTED]"); + }); + + it("redacts the share token in an absolute URL form", () => { + expect( + redactSecrets( + "https://app.healthlog.dev/c/hls_0123456789abcdef0123456789abcdef0123456789abcdef", + ), + ).toBe("https://app.healthlog.dev/c/[REDACTED]"); + }); + + it("redacts the share token even with a trailing query string", () => { + expect(redactSecrets("/c/hls_deadbeef?print=1")).toBe( + "/c/[REDACTED]?print=1", + ); + }); }); }); diff --git a/src/lib/logging/redact.ts b/src/lib/logging/redact.ts index d0c0bc982..ffaaf8bd1 100644 --- a/src/lib/logging/redact.ts +++ b/src/lib/logging/redact.ts @@ -19,10 +19,10 @@ * log them on purpose, but a misconfigured client error or a * dump of the request body could carry one. Scrub before egress. * - path-segment secrets — routes like `/api/withings/webhook/` - * carry the shared secret as a positional path segment. The Wide - * Event `http.path` and `http.route` fields would otherwise leak - * the secret into stdout, the in-memory ring buffer, and Loki. - * See `PATH_SECRET_PATHS`. + * and the clinician share view `/c/` carry a bearer secret as + * a positional path segment. The Wide Event `http.path` and + * `http.route` fields would otherwise leak the secret into stdout, + * the in-memory ring buffer, and Loki. See `PATH_SECRET_PATHS`. * * The substitution is intentionally generic ([REDACTED]) — we don't * want partial revelation of token entropy. @@ -42,6 +42,11 @@ export const PATH_SECRET_PATHS: ReadonlyArray<{ prefix: string }> = [ // the trailing path segment (v1.4.25 W17a). Without this rule the // secret lands in every Wide Event's `http.path` / `http.route`. { prefix: "/api/withings/webhook/" }, + // v1.11.0 — clinician share view (Epic C). The raw `hls_` share token + // rides as the trailing path segment of `/c/`; it is a bearer + // credential for the share surface, so scrub it from `http.path` / + // `http.route` the same way the Withings webhook secret is scrubbed. + { prefix: "/c/" }, ]; function redactPathSegments(input: string): string { diff --git a/src/proxy.ts b/src/proxy.ts index 07608a389..53521ec17 100644 --- a/src/proxy.ts +++ b/src/proxy.ts @@ -237,6 +237,26 @@ export function proxy(request: NextRequest) { "Permissions-Policy", "camera=(), microphone=(), geolocation=()", ); + + // v1.11.0 (Epic C, C6) — the public clinician share view at `/c/` + // 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. + if (pathname.startsWith("/c/")) { + 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