From 15f0e499f96ae0fcc5c6c680b4623cd867d378e0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 13 Sep 2026 13:56:58 +0200 Subject: [PATCH] refactor: share the secret-shape matcher from one leaf The idempotency cache refuses to persist a body carrying a HealthLog token or an AI provider key. The notification test routes are about to refuse echoing such text too, so the pattern moves to src/lib/secret-shape.ts instead of being written a second time. Behaviour of the cache is unchanged. --- src/lib/__tests__/secret-shape.test.ts | 25 +++++++++++++++++++++++++ src/lib/idempotency.ts | 17 ++++------------- src/lib/secret-shape.ts | 21 +++++++++++++++++++++ 3 files changed, 50 insertions(+), 13 deletions(-) create mode 100644 src/lib/__tests__/secret-shape.test.ts create mode 100644 src/lib/secret-shape.ts diff --git a/src/lib/__tests__/secret-shape.test.ts b/src/lib/__tests__/secret-shape.test.ts new file mode 100644 index 000000000..2cdbf3d26 --- /dev/null +++ b/src/lib/__tests__/secret-shape.test.ts @@ -0,0 +1,25 @@ +import { describe, expect, it } from "vitest"; + +import { looksSecretShaped } from "@/lib/secret-shape"; + +describe("looksSecretShaped", () => { + it.each([ + ["an access token", "token hlk_abc123"], + ["a refresh token", "hlr_xyz789"], + ["a share-link token", "see hls_def456"], + ["an invite token", "hlv_0a1b2c3d"], + ["an elevation token", "hle_0a1b2c3d"], + ["an OpenAI key", "echoed sk-1234567890"], + ["an Anthropic key", "sk-ant-api03-xyz12345"], + ])("matches %s", (_label, text) => { + expect(looksSecretShaped(text)).toBe(true); + }); + + it.each([ + ["a word containing sk-", "task-id must not contain spaces"], + ["another one", "risk-management"], + ["a JSON error body", '{"error":"Bad Request","errorCode":400}'], + ])("does not match %s", (_label, text) => { + expect(looksSecretShaped(text)).toBe(false); + }); +}); diff --git a/src/lib/idempotency.ts b/src/lib/idempotency.ts index c9e2dbeb8..70e2ddcbc 100644 --- a/src/lib/idempotency.ts +++ b/src/lib/idempotency.ts @@ -28,6 +28,7 @@ import { isP2002 } from "@/lib/prisma-errors"; import { findActiveGrant } from "@/lib/sharing/grants"; import { decrypt, encrypt } from "@/lib/crypto"; import { createHash } from "node:crypto"; +import { looksSecretShaped } from "@/lib/secret-shape"; const TTL_MS = 24 * 60 * 60 * 1000; /** @@ -641,21 +642,11 @@ export function withIdempotency< // Defence-in-depth: never persist a body that carries a freshly-issued // bearer / refresh token or a third-party AI provider key. Auth and // settings routes shouldn't be wrapped in withIdempotency to begin - // with, but if a future caller forgets we refuse to leak. - // `hlk_` = our access tokens - // `hlr_` = our refresh tokens - // `hls_` = clinician share-link tokens (v1.11) - // `hlv_` = registration invite tokens (v1.16 — the admin mint - // response is the one place the raw token appears) - // `sk-…` = OpenAI / Anthropic keys (full token form, not the - // raw substring — a 422 body explaining "task-id…" - // or any other word containing `sk-` would otherwise - // silently break idempotency for benign retries). - const SECRET_PATTERN = - /(?:\b(?:hlk_|hlr_|hls_|hlv_|hle_)[A-Za-z0-9_-]+|\bsk-(?:ant-)?[A-Za-z0-9_-]{8,})/; + // with, but if a future caller forgets we refuse to leak. The prefixes + // are listed where the matcher lives (`src/lib/secret-shape.ts`). const cloned = response.clone(); const text = await cloned.text(); - if (!SECRET_PATTERN.test(text)) { + if (!looksSecretShaped(text)) { await persistCached(ctx, response, text); } else { // Secret-shaped body — drop the claim so the key isn't left diff --git a/src/lib/secret-shape.ts b/src/lib/secret-shape.ts new file mode 100644 index 000000000..7786f7b59 --- /dev/null +++ b/src/lib/secret-shape.ts @@ -0,0 +1,21 @@ +/** + * One definition of "this text carries a credential HealthLog or a common + * AI provider mints", shared by every surface that refuses to store or echo + * such text. + * + * `hlk_` access tokens, `hlr_` refresh tokens, `hls_` clinician share-link + * tokens, `hlv_` registration invite tokens, `hle_` elevation tokens; + * `sk-…` / `sk-ant-…` OpenAI and Anthropic keys, in the full token form + * only: a bare `sk-` substring ("task-id", "risk-management") is not a key, + * and matching it would refuse benign text. + * + * It is a shape test, not a secret detector. A credential without one of + * these prefixes (a relay's own app token, a password) passes, so a caller + * that knows the secrets in play must also refuse text containing them. + */ +const SECRET_SHAPED = + /(?:\b(?:hlk_|hlr_|hls_|hlv_|hle_)[A-Za-z0-9_-]+|\bsk-(?:ant-)?[A-Za-z0-9_-]{8,})/; + +export function looksSecretShaped(text: string): boolean { + return SECRET_SHAPED.test(text); +}