From 611466b4191877c47f0ab2f258d6a76a91bf64e0 Mon Sep 17 00:00:00 2001 From: Ravi Tharuma <25951435+RaviTharuma@users.noreply.github.com> Date: Mon, 17 Aug 2026 14:50:52 +0200 Subject: [PATCH] fix(api): hash API keys in the v1 models catalog cache key Validated in local merge-train-equivalent focused gate on release/v3.8.50 tip 9081b5714631df8747e9bd331786654c9833bf2f: catalog fingerprint regression + existing catalog-cache callers, 8 tests passed. --- changelog.d/fixes/catalog-cache-hash-apikey.md | 1 + src/app/api/v1/models/catalogCache.ts | 10 +++++++++- tests/unit/catalog-cache-auth-fingerprint.test.ts | 15 +++++++++++++++ 3 files changed, 25 insertions(+), 1 deletion(-) create mode 100644 changelog.d/fixes/catalog-cache-hash-apikey.md create mode 100644 tests/unit/catalog-cache-auth-fingerprint.test.ts diff --git a/changelog.d/fixes/catalog-cache-hash-apikey.md b/changelog.d/fixes/catalog-cache-hash-apikey.md new file mode 100644 index 0000000000..e815ab1fec --- /dev/null +++ b/changelog.d/fixes/catalog-cache-hash-apikey.md @@ -0,0 +1 @@ +- **fix(api):** hash API keys in the `/v1/models` catalog cache Map key so heap dumps cannot leak bearer tokens (`src/app/api/v1/models/catalogCache.ts`) diff --git a/src/app/api/v1/models/catalogCache.ts b/src/app/api/v1/models/catalogCache.ts index 6cca9e9cd8..59eed06f51 100644 --- a/src/app/api/v1/models/catalogCache.ts +++ b/src/app/api/v1/models/catalogCache.ts @@ -12,11 +12,19 @@ * Auth rejection is NOT handled here and must stay in the caller: it depends on * live per-request state (dashboard cookie, API key) and must never be cached. */ +import { createHash } from "node:crypto"; + import { getModelCatalogCacheVersion } from "@/lib/db/readCache"; import { extractApiKey } from "@/sse/services/auth"; import { isCodexModelCatalogClient } from "./catalogRequest"; +/** Fingerprint an API key for the catalog memo Map. Never store the raw secret. */ +export function fingerprintCatalogAuthKey(apiKey: string): string { + if (!apiKey) return ""; + return createHash("sha256").update(apiKey).digest("hex").slice(0, 16); +} + export type CachedCatalog = { body: string; headers: Record; @@ -96,7 +104,7 @@ function buildCatalogCacheKey( const configuredOnly = url.searchParams.get("configuredOnly") === "true" ? "1" : "0"; const hideAuto = catalogSettings?.hideAutoCombos ? "1" : "0"; const hideNoThink = catalogSettings?.hideNoThinkVariants ? "1" : "0"; - return `${prefix}|${isCodex}|${apiKey}|${configuredOnly}|${hideAuto}|${hideNoThink}`; + return `${prefix}|${isCodex}|${fingerprintCatalogAuthKey(apiKey)}|${configuredOnly}|${hideAuto}|${hideNoThink}`; } // Tracks the model-catalog cache version (src/lib/db/readCache.ts) as of the last diff --git a/tests/unit/catalog-cache-auth-fingerprint.test.ts b/tests/unit/catalog-cache-auth-fingerprint.test.ts new file mode 100644 index 0000000000..bc22b0071b --- /dev/null +++ b/tests/unit/catalog-cache-auth-fingerprint.test.ts @@ -0,0 +1,15 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; + +import { fingerprintCatalogAuthKey } from "../../src/app/api/v1/models/catalogCache.ts"; + +test("fingerprintCatalogAuthKey never returns the raw API key", () => { + const raw = "sk-test-super-secret-catalog-key"; + const finger = fingerprintCatalogAuthKey(raw); + assert.equal(finger.length, 16); + assert.equal(finger.includes(raw), false); + assert.equal(finger.includes("sk-test"), false); + assert.equal(fingerprintCatalogAuthKey(raw), finger); + assert.notEqual(fingerprintCatalogAuthKey("sk-other"), finger); + assert.equal(fingerprintCatalogAuthKey(""), ""); +});