From f4629a17d675585dbd9e3cbdba1eca23f98a2aa4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 4 Jun 2026 01:27:43 +0200 Subject: [PATCH] feat(share-links): scrub the share token from logs and harden the /c edge MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Register the `/c/` prefix in `PATH_SECRET_PATHS` so the raw `hls_` clinician share token, which rides as the trailing path segment, is rewritten to [REDACTED] in `http.path` / `http.route` before any Wide Event or GlitchTip report leaves the process — the same posture as the Withings webhook path secret. The resolve path never logs, throws, or annotates the raw token. Defend the share surface at the edge irrespective of what the RSC emits: the proxy now sets `Cache-Control: no-store`, `X-Robots-Tag: noindex, nofollow`, and `Referrer-Policy: no-referrer` on `/c/` responses, so no downstream proxy caches a scoped record, no crawler indexes the token, and the token-bearing URL is not leaked in an outbound `Referer`. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/lib/logging/__tests__/redact.test.ts | 29 ++++++++++++++++++++++++ src/lib/logging/redact.ts | 13 +++++++---- src/proxy.ts | 20 ++++++++++++++++ 3 files changed, 58 insertions(+), 4 deletions(-) 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