mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 13:22:14 +00:00
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:
1 parent
75760b606a
commit
15f0e499f9
3 files changed
+50
-13
No files matched your search
@@ -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
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
Reference in new issue
Block a user