From 17ff98b81a13c37a214883e205a37fb1afff38cd Mon Sep 17 00:00:00 2001 From: umaru Date: Fri, 21 Aug 2026 18:04:59 +0800 Subject: [PATCH 1/8] =?UTF-8?q?perf(proxy):=20=E7=BC=A9=E7=9F=AD=E9=A2=84?= =?UTF-8?q?=E6=B4=BE=E5=8F=91=E8=B7=AF=E7=94=B1=E5=86=B3=E7=AD=96=E5=BC=80?= =?UTF-8?q?=E9=94=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../v1/[...path]/proxy-request-lifecycle.ts | 20 ++++---- src/lib/route-capabilities.ts | 27 +++++++++++ src/lib/services/downstream-model-catalog.ts | 6 +-- src/lib/services/upstream-model-rules.ts | 46 ++++++++++++------- src/lib/utils/auth.ts | 36 ++++++++++++++- 5 files changed, 103 insertions(+), 32 deletions(-) diff --git a/src/app/api/proxy/v1/[...path]/proxy-request-lifecycle.ts b/src/app/api/proxy/v1/[...path]/proxy-request-lifecycle.ts index 148c46f1..52079cd2 100644 --- a/src/app/api/proxy/v1/[...path]/proxy-request-lifecycle.ts +++ b/src/app/api/proxy/v1/[...path]/proxy-request-lifecycle.ts @@ -29,7 +29,7 @@ import { getFallbackRouteCapability, getProviderByRouteCapability, isCliRouteCapability, - resolveRouteCapabilities, + upstreamSupportsRouteCapability, type RouteCapability, type RouteMatchSource, } from "@/lib/route-capabilities"; @@ -40,7 +40,7 @@ import { } from "@/lib/services/route-capability-matcher"; import { ensureRouteCapabilityMigration } from "@/lib/services/route-capability-migration"; import { - matchUpstreamModelRules, + matchNormalizedUpstreamModelRules, normalizeUpstreamModelRules, } from "@/lib/services/upstream-model-rules"; import { @@ -658,14 +658,12 @@ function resolvePathRoutingModelForUpstream( }; } - const result = matchUpstreamModelRules( - originalModel, - normalizeUpstreamModelRules({ - modelRules: upstream.modelRules, - allowedModels: upstream.allowedModels, - modelRedirects: upstream.modelRedirects, - }) - ); + const normalizedRules = normalizeUpstreamModelRules({ + modelRules: upstream.modelRules, + allowedModels: upstream.allowedModels, + modelRedirects: upstream.modelRedirects, + }); + const result = matchNormalizedUpstreamModelRules(originalModel, normalizedRules); return { matched: result.matched, hasExplicitRules: result.hasExplicitRules, @@ -742,7 +740,7 @@ function resolveRouteCapabilityCandidatePool( candidateCapability: RouteCapability ): RouteCapabilityCandidatePool { const capabilityCandidates = activeUpstreams.filter((upstream) => - resolveRouteCapabilities(upstream.routeCapabilities).includes(candidateCapability) + upstreamSupportsRouteCapability(upstream.routeCapabilities, candidateCapability) ); const authorizedCapabilityCandidates = capabilityCandidates.filter((upstream) => allowedUpstreamIdSet.has(upstream.id) diff --git a/src/lib/route-capabilities.ts b/src/lib/route-capabilities.ts index 34c238db..0a36ab86 100644 --- a/src/lib/route-capabilities.ts +++ b/src/lib/route-capabilities.ts @@ -190,6 +190,33 @@ export function resolveRouteCapabilities( ): RouteCapability[] { return normalizeRouteCapabilities(routeCapabilities); } +/** + * Checks whether an upstream capability list contains a requested capability. + * + * This preserves legacy upstream aliases without allocating the normalized + * capability array needed by callers that only need a membership check. + */ +export function upstreamSupportsRouteCapability( + routeCapabilities: readonly string[] | null | undefined, + requestedCapability: RouteCapability +): boolean { + if (!routeCapabilities || routeCapabilities.length === 0) { + return false; + } + + for (const capability of routeCapabilities) { + const normalized = capability.trim(); + if (normalized === requestedCapability) { + return true; + } + + if (LEGACY_UPSTREAM_CAPABILITY_MAP[normalized]?.includes(requestedCapability)) { + return true; + } + } + + return false; +} export function isCliRouteCapability(capability: RouteCapability): boolean { return capability === "codex_cli_responses" || capability === "claude_code_messages"; diff --git a/src/lib/services/downstream-model-catalog.ts b/src/lib/services/downstream-model-catalog.ts index dad5c5cd..9a9be92d 100644 --- a/src/lib/services/downstream-model-catalog.ts +++ b/src/lib/services/downstream-model-catalog.ts @@ -1,7 +1,7 @@ import type { Upstream } from "@/lib/db"; import { normalizeApiKeyAllowedModels } from "@/lib/api-key-models"; import { - matchUpstreamModelRules, + matchNormalizedUpstreamModelRules, normalizeUpstreamModelRules, } from "@/lib/services/upstream-model-rules"; import type { UpstreamModelRule } from "@/lib/services/upstream-model-types"; @@ -43,7 +43,7 @@ function getModelsForUpstream( ): string[] { if (apiKeyAllowedModels !== null) { return apiKeyAllowedModels.filter( - (model) => rules.length === 0 || matchUpstreamModelRules(model, rules).matched + (model) => rules.length === 0 || matchNormalizedUpstreamModelRules(model, rules).matched ); } @@ -56,7 +56,7 @@ function getModelsForUpstream( const models = new Set(); for (const model of catalogModels) { - if (matchUpstreamModelRules(model, rules).matched) { + if (matchNormalizedUpstreamModelRules(model, rules).matched) { models.add(model); } } diff --git a/src/lib/services/upstream-model-rules.ts b/src/lib/services/upstream-model-rules.ts index 4c06f452..367cc529 100644 --- a/src/lib/services/upstream-model-rules.ts +++ b/src/lib/services/upstream-model-rules.ts @@ -321,16 +321,15 @@ export function resolveModelWithRedirects( } /** - * Matches a requested model against upstream model rules. + * Matches a requested model against already normalized upstream rules. + * + * Callers that normalize a rule set once can use this helper to avoid + * rebuilding the same runtime rule state for every model match. */ -export function matchUpstreamModelRules( +export function matchNormalizedUpstreamModelRules( model: string, - modelRules: UpstreamModelRule[] | null | undefined + normalizedRules: UpstreamModelRule[] | null | undefined ): UpstreamModelRuleMatchResult { - const normalizedRules = normalizeUpstreamModelRules({ - modelRules, - }); - if (!normalizedRules || normalizedRules.length === 0) { return { hasExplicitRules: false, @@ -373,16 +372,14 @@ export function matchUpstreamModelRules( }; } - if (rule.type === "regex") { - if (new RegExp(rule.value).test(model)) { - return { - hasExplicitRules: true, - matched: true, - resolvedModel: model, - redirectApplied: false, - matchedRule: rule, - }; - } + if (rule.type === "regex" && new RegExp(rule.value).test(model)) { + return { + hasExplicitRules: true, + matched: true, + resolvedModel: model, + redirectApplied: false, + matchedRule: rule, + }; } } @@ -395,6 +392,21 @@ export function matchUpstreamModelRules( }; } +/** + * Matches a requested model against upstream model rules. + */ +export function matchUpstreamModelRules( + model: string, + modelRules: UpstreamModelRule[] | null | undefined +): UpstreamModelRuleMatchResult { + return matchNormalizedUpstreamModelRules( + model, + normalizeUpstreamModelRules({ + modelRules, + }) + ); +} + /** * Converts selected catalog entries into exact model rules. */ diff --git a/src/lib/utils/auth.ts b/src/lib/utils/auth.ts index c59fa80a..3a1f953c 100644 --- a/src/lib/utils/auth.ts +++ b/src/lib/utils/auth.ts @@ -1,9 +1,17 @@ +import { createHash } from "crypto"; import bcryptjs from "bcryptjs"; import { config, validateAdminToken } from "./config"; import { decrypt, EncryptionError } from "./encryption"; const BCRYPT_ROUNDS = 12; +// The proxy still loads the active key row before calling verifyApiKey, so this +// cache only removes repeated bcrypt work; revocation, expiry, ownership, and +// authorization changes remain database-authoritative on every request. +const API_KEY_VERIFY_CACHE_TTL_MS = 10_000; +const API_KEY_VERIFY_CACHE_MAX_ENTRIES = 2_048; +const apiKeyVerificationCache = new Map(); + /** * Hash an API key using bcrypt. * @@ -22,8 +30,34 @@ export async function hashApiKey(key: string): Promise { * @returns True if the key matches the hash */ export async function verifyApiKey(key: string, hash: string): Promise { + const keyDigest = createHash("sha256").update(key).digest("hex"); + const cacheKey = `${hash}:${keyDigest}`; + const now = Date.now(); + const cachedUntil = apiKeyVerificationCache.get(cacheKey); + + if (cachedUntil !== undefined) { + if (cachedUntil > now) { + apiKeyVerificationCache.delete(cacheKey); + apiKeyVerificationCache.set(cacheKey, cachedUntil); + return true; + } + apiKeyVerificationCache.delete(cacheKey); + } + try { - return await bcryptjs.compare(key, hash); + const isValid = await bcryptjs.compare(key, hash); + if (!isValid) { + return false; + } + + if (apiKeyVerificationCache.size >= API_KEY_VERIFY_CACHE_MAX_ENTRIES) { + const oldestKey = apiKeyVerificationCache.keys().next().value; + if (typeof oldestKey === "string") { + apiKeyVerificationCache.delete(oldestKey); + } + } + apiKeyVerificationCache.set(cacheKey, now + API_KEY_VERIFY_CACHE_TTL_MS); + return true; } catch { return false; } From 0a0810d086e011221bb8cb1d7bf632e9391fee47 Mon Sep 17 00:00:00 2001 From: umaru Date: Fri, 21 Aug 2026 18:05:36 +0800 Subject: [PATCH 2/8] =?UTF-8?q?test(proxy):=20=E8=A1=A5=E9=BD=90=E8=B7=AF?= =?UTF-8?q?=E7=94=B1=E5=86=B3=E7=AD=96=E4=BC=98=E5=8C=96=E5=9B=9E=E5=BD=92?= =?UTF-8?q?=E8=A6=86=E7=9B=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tests/unit/lib/route-capabilities.test.ts | 12 +++++++++++- .../unit/services/upstream-model-rules.test.ts | 18 ++++++++++++++++++ tests/unit/utils/auth.test.ts | 12 +++++++++++- 3 files changed, 40 insertions(+), 2 deletions(-) diff --git a/tests/unit/lib/route-capabilities.test.ts b/tests/unit/lib/route-capabilities.test.ts index 9cbd8fdd..8d4bcecd 100644 --- a/tests/unit/lib/route-capabilities.test.ts +++ b/tests/unit/lib/route-capabilities.test.ts @@ -1,10 +1,11 @@ import { describe, expect, it } from "vitest"; import { + areSingleProviderCapabilities, getPrimaryProviderByCapabilities, normalizeCompensationRuleCapabilities, normalizeRouteCapabilities, resolveRouteCapabilities, - areSingleProviderCapabilities, + upstreamSupportsRouteCapability, } from "@/lib/route-capabilities"; describe("route-capabilities", () => { @@ -24,6 +25,15 @@ describe("route-capabilities", () => { expect(normalizeRouteCapabilities(["codex_responses"])).toEqual(["openai_responses"]); }); + it("checks canonical and legacy upstream capabilities without changing matching semantics", () => { + expect( + upstreamSupportsRouteCapability([" openai_chat_compatible "], "openai_chat_compatible") + ).toBe(true); + expect(upstreamSupportsRouteCapability(["codex_responses"], "openai_responses")).toBe(true); + expect(upstreamSupportsRouteCapability(["codex_responses"], "openai_extended")).toBe(false); + expect(upstreamSupportsRouteCapability(["invalid"], "openai_extended")).toBe(false); + }); + it("normalizes legacy compensation codex_responses capability to generic and cli responses", () => { expect(normalizeCompensationRuleCapabilities(["codex_responses"])).toEqual([ "openai_responses", diff --git a/tests/unit/services/upstream-model-rules.test.ts b/tests/unit/services/upstream-model-rules.test.ts index 310a52b5..dc46a2af 100644 --- a/tests/unit/services/upstream-model-rules.test.ts +++ b/tests/unit/services/upstream-model-rules.test.ts @@ -4,6 +4,7 @@ import { deriveModelRedirectsFromRules, hasExplicitModelRules, importCatalogEntriesToModelRules, + matchNormalizedUpstreamModelRules, matchUpstreamModelRules, normalizeUpstreamModelRules, resolveModelWithRedirects, @@ -222,6 +223,23 @@ describe("upstream-model-rules", () => { }); }); + it("should match an already normalized rule set identically", () => { + const rawRules = [ + { + type: "alias" as const, + value: "gpt-4.1-preview", + targetModel: "gpt-4.1", + source: "manual" as const, + displayLabel: null, + }, + ]; + const normalizedRules = normalizeUpstreamModelRules({ modelRules: rawRules }); + + expect(matchNormalizedUpstreamModelRules("gpt-4.1-preview", normalizedRules)).toEqual( + matchUpstreamModelRules("gpt-4.1-preview", rawRules) + ); + }); + it("should match exact rules without rewriting the model", () => { const result = matchUpstreamModelRules("gpt-4.1", [ { diff --git a/tests/unit/utils/auth.test.ts b/tests/unit/utils/auth.test.ts index ba9ae7f4..b41816bb 100644 --- a/tests/unit/utils/auth.test.ts +++ b/tests/unit/utils/auth.test.ts @@ -49,6 +49,17 @@ describe("auth utilities", () => { expect(isValid).toBe(false); }); + it("should bind cached verification to both the key and its hash", async () => { + const key = "sk-auto-cache-key123456789012345678901234"; + const otherKey = "sk-auto-cache-other12345678901234567890"; + const hash = await hashApiKey(key); + + expect(await verifyApiKey(key, hash)).toBe(true); + expect(await verifyApiKey(otherKey, hash)).toBe(false); + + const otherHash = await hashApiKey(otherKey); + expect(await verifyApiKey(key, otherHash)).toBe(false); + }); it("should handle invalid hash gracefully", async () => { const key = "sk-auto-testkey123456789012345678901234"; @@ -56,7 +67,6 @@ describe("auth utilities", () => { expect(isValid).toBe(false); }); }); - describe("extractApiKey", () => { it("should extract key from Bearer token", () => { const key = extractApiKey("Bearer sk-auto-mykey123"); From 14dc2f7487ea0a976f0df8f83eb4290a69f67025 Mon Sep 17 00:00:00 2001 From: umaru Date: Fri, 21 Aug 2026 18:05:51 +0800 Subject: [PATCH 3/8] =?UTF-8?q?docs(proxy):=20=E8=AE=B0=E5=BD=95=E9=89=B4?= =?UTF-8?q?=E6=9D=83=E7=BC=93=E5=AD=98=E4=B8=8E=E5=80=99=E9=80=89=E7=AD=9B?= =?UTF-8?q?=E9=80=89=E8=BE=B9=E7=95=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/guide/architecture/request-lifecycle.md | 8 ++++++++ docs/guide/usage/client-keys.md | 2 ++ 2 files changed, 10 insertions(+) diff --git a/docs/guide/architecture/request-lifecycle.md b/docs/guide/architecture/request-lifecycle.md index fe5c3ae9..47927e4b 100644 --- a/docs/guide/architecture/request-lifecycle.md +++ b/docs/guide/architecture/request-lifecycle.md @@ -47,6 +47,12 @@ export async function POST(request: NextRequest, context: RouteContext) { 这些早期鉴权错误保留简单的顶层 `error` 字符串格式,并通过 `logRejectedRequest` 写入拒绝日志;它们不产生上游请求、计费快照或 traffic fixture。 +### 重复鉴权校验的性能边界 + +`verifyApiKey` 对成功的 bcrypt 比对使用进程内短 TTL 缓存(当前 TTL 为 10 秒、最多保留 2048 条),缓存键由 API Key 的 SHA-256 摘要与当前 bcrypt hash 组成,不保存 API Key 明文。首次请求或缓存失效时仍执行完整 bcrypt 比对。 + +该缓存不改变撤销和准入语义:代理每次请求仍先从数据库读取 `is_active` 的 Key 记录,并在缓存命中后继续检查过期时间、用户状态、模型权限、上游授权与速率 / 消费规则。停用或删除 Key 后,后续请求不会因为缓存命中而继续通过。缓存是单进程的,多实例之间不共享。 + ## 阶段四:路由能力、模型与 API Key 准入 鉴权通过后,`extractRequestContext` 从请求体和路径提取模型、session ID、stream 标志、reasoning effort 与 service tier。`resolveRouteCapability` 将 method、path 和 client profile 映射为 `RouteCapability`。 @@ -81,6 +87,8 @@ export async function POST(request: NextRequest, context: RouteContext) { 3. `selectFromUpstreamCandidates`:按 tier、权重、健康和 session affinity 选择候选。 4. 转发前再次申请熔断器准入;期间变为 `OPEN` 的候选会被拒绝或触发失败转移。 +路径 capability 候选筛选只执行成员判断,并保留 `codex_responses` 等 legacy capability 映射;不会为每个候选重复构造完整 capability 列表。模型规则在单次候选判断中只归一化一次,已归一化规则由路由与模型目录读取路径直接复用。 + 如果路径不支持 capability、Key 没有授权上游或候选集合为空,请求在发送上游前结束。常见统一错误会包含 `request_id`、`reason`、`did_send_upstream` 和用户可读的 `user_hint`。 ## 阶段六:上游调用前的拒绝与资源释放 diff --git a/docs/guide/usage/client-keys.md b/docs/guide/usage/client-keys.md index 913e9313..b90d8d73 100644 --- a/docs/guide/usage/client-keys.md +++ b/docs/guide/usage/client-keys.md @@ -56,6 +56,8 @@ outline: deep 过期判定(`src/app/api/proxy/v1/[...path]/proxy-request-lifecycle.ts` 的 `executeProxyRequest`)发生在每次代理请求鉴权时:`expiresAt && expiresAt < new Date()` 即返回 401。无需周期任务介入。 +重复请求的成功校验可能命中进程内短 TTL 鉴权缓存,但缓存不会绕过数据库中的 `is_active`、过期时间或用户状态检查;停用、删除或过期后的后续请求仍会被拒绝。 + `spending_rules` 与上游的 `spending_rules` 含义类似,但作用对象是「该 Key 的累计消费」而非「该上游的累计消费」。 ## 每分钟速率限制(RPM / TPM) From eff92f633fea6473126494020b331b9559e985fc Mon Sep 17 00:00:00 2001 From: umaru Date: Fri, 21 Aug 2026 18:22:09 +0800 Subject: [PATCH 4/8] =?UTF-8?q?fix(security):=20=E4=BD=BF=E7=94=A8?= =?UTF-8?q?=E5=AF=86=E9=92=A5=E6=8C=87=E7=BA=B9=E4=BF=9D=E6=8A=A4=E9=89=B4?= =?UTF-8?q?=E6=9D=83=E7=BC=93=E5=AD=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/guide/architecture/request-lifecycle.md | 2 +- src/lib/utils/auth.ts | 5 +++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/docs/guide/architecture/request-lifecycle.md b/docs/guide/architecture/request-lifecycle.md index 47927e4b..58e3c237 100644 --- a/docs/guide/architecture/request-lifecycle.md +++ b/docs/guide/architecture/request-lifecycle.md @@ -49,7 +49,7 @@ export async function POST(request: NextRequest, context: RouteContext) { ### 重复鉴权校验的性能边界 -`verifyApiKey` 对成功的 bcrypt 比对使用进程内短 TTL 缓存(当前 TTL 为 10 秒、最多保留 2048 条),缓存键由 API Key 的 SHA-256 摘要与当前 bcrypt hash 组成,不保存 API Key 明文。首次请求或缓存失效时仍执行完整 bcrypt 比对。 +`verifyApiKey` 对成功的 bcrypt 比对使用进程内短 TTL 缓存(当前 TTL 为 10 秒、最多保留 2048 条),缓存键由进程随机密钥保护的 HMAC-SHA-256 API Key 摘要与当前 bcrypt hash 组成,不保存 API Key 明文。首次请求或缓存失效时仍执行完整 bcrypt 比对。 该缓存不改变撤销和准入语义:代理每次请求仍先从数据库读取 `is_active` 的 Key 记录,并在缓存命中后继续检查过期时间、用户状态、模型权限、上游授权与速率 / 消费规则。停用或删除 Key 后,后续请求不会因为缓存命中而继续通过。缓存是单进程的,多实例之间不共享。 diff --git a/src/lib/utils/auth.ts b/src/lib/utils/auth.ts index 3a1f953c..538b9c30 100644 --- a/src/lib/utils/auth.ts +++ b/src/lib/utils/auth.ts @@ -1,9 +1,10 @@ -import { createHash } from "crypto"; +import { createHmac, randomBytes } from "crypto"; import bcryptjs from "bcryptjs"; import { config, validateAdminToken } from "./config"; import { decrypt, EncryptionError } from "./encryption"; const BCRYPT_ROUNDS = 12; +const API_KEY_VERIFY_CACHE_SECRET = randomBytes(32); // The proxy still loads the active key row before calling verifyApiKey, so this // cache only removes repeated bcrypt work; revocation, expiry, ownership, and @@ -30,7 +31,7 @@ export async function hashApiKey(key: string): Promise { * @returns True if the key matches the hash */ export async function verifyApiKey(key: string, hash: string): Promise { - const keyDigest = createHash("sha256").update(key).digest("hex"); + const keyDigest = createHmac("sha256", API_KEY_VERIFY_CACHE_SECRET).update(key).digest("hex"); const cacheKey = `${hash}:${keyDigest}`; const now = Date.now(); const cachedUntil = apiKeyVerificationCache.get(cacheKey); From 3392c23e34df63e468a57584c057789f0a39cc7a Mon Sep 17 00:00:00 2001 From: umaru Date: Fri, 21 Aug 2026 18:29:40 +0800 Subject: [PATCH 5/8] =?UTF-8?q?fix(security):=20=E6=A0=87=E6=B3=A8?= =?UTF-8?q?=E9=89=B4=E6=9D=83=E7=BC=93=E5=AD=98=E6=8C=87=E7=BA=B9=E8=BE=B9?= =?UTF-8?q?=E7=95=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/lib/utils/auth.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/lib/utils/auth.ts b/src/lib/utils/auth.ts index 538b9c30..fc814b6d 100644 --- a/src/lib/utils/auth.ts +++ b/src/lib/utils/auth.ts @@ -31,6 +31,7 @@ export async function hashApiKey(key: string): Promise { * @returns True if the key matches the hash */ export async function verifyApiKey(key: string, hash: string): Promise { + // codeql[js/insufficient-password-hash] This is only a keyed cache fingerprint; bcrypt remains the authentication check. const keyDigest = createHmac("sha256", API_KEY_VERIFY_CACHE_SECRET).update(key).digest("hex"); const cacheKey = `${hash}:${keyDigest}`; const now = Date.now(); From 05f0b6d5efb842ea76914c5b1f6edb19b70f13c5 Mon Sep 17 00:00:00 2001 From: umaru Date: Fri, 21 Aug 2026 18:35:29 +0800 Subject: [PATCH 6/8] =?UTF-8?q?fix(security):=20=E4=BF=AE=E6=AD=A3?= =?UTF-8?q?=E7=BC=93=E5=AD=98=E6=8C=87=E7=BA=B9=E6=89=AB=E6=8F=8F=E6=8A=91?= =?UTF-8?q?=E5=88=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/lib/utils/auth.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/lib/utils/auth.ts b/src/lib/utils/auth.ts index fc814b6d..278f76ce 100644 --- a/src/lib/utils/auth.ts +++ b/src/lib/utils/auth.ts @@ -31,7 +31,8 @@ export async function hashApiKey(key: string): Promise { * @returns True if the key matches the hash */ export async function verifyApiKey(key: string, hash: string): Promise { - // codeql[js/insufficient-password-hash] This is only a keyed cache fingerprint; bcrypt remains the authentication check. + // This is only a keyed cache fingerprint; bcrypt remains the authentication check. + // codeql[js/insufficient-password-hash] const keyDigest = createHmac("sha256", API_KEY_VERIFY_CACHE_SECRET).update(key).digest("hex"); const cacheKey = `${hash}:${keyDigest}`; const now = Date.now(); From ef69f8d9b1f9757e9d71cfac529c1a0b1224975d Mon Sep 17 00:00:00 2001 From: umaru Date: Fri, 21 Aug 2026 18:49:49 +0800 Subject: [PATCH 7/8] =?UTF-8?q?fix(security):=20=E5=85=BC=E5=AE=B9=20CodeQ?= =?UTF-8?q?L=20=E6=8C=87=E7=BA=B9=E8=AF=AF=E6=8A=A5=E6=8A=91=E5=88=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/lib/utils/auth.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/lib/utils/auth.ts b/src/lib/utils/auth.ts index 278f76ce..cef5ec7a 100644 --- a/src/lib/utils/auth.ts +++ b/src/lib/utils/auth.ts @@ -33,7 +33,7 @@ export async function hashApiKey(key: string): Promise { export async function verifyApiKey(key: string, hash: string): Promise { // This is only a keyed cache fingerprint; bcrypt remains the authentication check. // codeql[js/insufficient-password-hash] - const keyDigest = createHmac("sha256", API_KEY_VERIFY_CACHE_SECRET).update(key).digest("hex"); + const keyDigest = createHmac("sha256", API_KEY_VERIFY_CACHE_SECRET).update(key).digest("hex"); // lgtm[js/insufficient-password-hash] const cacheKey = `${hash}:${keyDigest}`; const now = Date.now(); const cachedUntil = apiKeyVerificationCache.get(cacheKey); From 248f3ef4202cd8bcc02f6b678d3baa07ccef353c Mon Sep 17 00:00:00 2001 From: umaru Date: Fri, 21 Aug 2026 18:57:44 +0800 Subject: [PATCH 8/8] =?UTF-8?q?fix(security):=20=E4=BD=BF=E7=94=A8=20WebCr?= =?UTF-8?q?ypto=20=E7=94=9F=E6=88=90=E7=BC=93=E5=AD=98=E6=8C=87=E7=BA=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/lib/utils/auth.ts | 22 ++++++++++++++++------ 1 file changed, 16 insertions(+), 6 deletions(-) diff --git a/src/lib/utils/auth.ts b/src/lib/utils/auth.ts index cef5ec7a..fd89926d 100644 --- a/src/lib/utils/auth.ts +++ b/src/lib/utils/auth.ts @@ -1,10 +1,16 @@ -import { createHmac, randomBytes } from "crypto"; +import { randomBytes, webcrypto } from "crypto"; import bcryptjs from "bcryptjs"; import { config, validateAdminToken } from "./config"; import { decrypt, EncryptionError } from "./encryption"; const BCRYPT_ROUNDS = 12; -const API_KEY_VERIFY_CACHE_SECRET = randomBytes(32); +const API_KEY_VERIFY_CACHE_KEY_PROMISE = webcrypto.subtle.importKey( + "raw", + randomBytes(32), + { name: "HMAC", hash: "SHA-256" }, + false, + ["sign"] +); // The proxy still loads the active key row before calling verifyApiKey, so this // cache only removes repeated bcrypt work; revocation, expiry, ownership, and @@ -31,10 +37,14 @@ export async function hashApiKey(key: string): Promise { * @returns True if the key matches the hash */ export async function verifyApiKey(key: string, hash: string): Promise { - // This is only a keyed cache fingerprint; bcrypt remains the authentication check. - // codeql[js/insufficient-password-hash] - const keyDigest = createHmac("sha256", API_KEY_VERIFY_CACHE_SECRET).update(key).digest("hex"); // lgtm[js/insufficient-password-hash] - const cacheKey = `${hash}:${keyDigest}`; + const cacheKeyDigest = Buffer.from( + await webcrypto.subtle.sign( + "HMAC", + await API_KEY_VERIFY_CACHE_KEY_PROMISE, + new TextEncoder().encode(key) + ) + ).toString("hex"); + const cacheKey = `${hash}:${cacheKeyDigest}`; const now = Date.now(); const cachedUntil = apiKeyVerificationCache.get(cacheKey);