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.
This commit is contained in:
Marc-André Bombeck committed 2026-09-13 13:56:58 +02:00
1 parent 75760b606a
commit 15f0e499f9
3 files changed
+50 -13

No files matched your search

+25
View File
@@ -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);
});
});
+4 -13
View File
@@ -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
+21
View File
@@ -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);
}