diff --git a/config/quality/eslint-suppressions.json b/config/quality/eslint-suppressions.json index 54749b5b8a07..f20888c04c60 100644 --- a/config/quality/eslint-suppressions.json +++ b/config/quality/eslint-suppressions.json @@ -1818,11 +1818,6 @@ "count": 11 } }, - "src/shared/validation/schemas/provider.ts": { - "@typescript-eslint/no-unused-vars": { - "count": 10 - } - }, "src/shared/validation/schemas/proxy.ts": { "@typescript-eslint/no-unused-vars": { "count": 11 diff --git a/src/shared/validation/schemas/provider.ts b/src/shared/validation/schemas/provider.ts index 0fd4f9a1bbc9..246bdd2a07d6 100644 --- a/src/shared/validation/schemas/provider.ts +++ b/src/shared/validation/schemas/provider.ts @@ -1,22 +1,9 @@ import { z } from "zod"; -import { - ACCOUNT_FALLBACK_STRATEGY_VALUES, - ROUTING_STRATEGY_VALUES, -} from "@/shared/constants/routingStrategies"; -import { SUPPORTED_BATCH_ENDPOINTS } from "@/shared/constants/batchEndpoints"; -import { MAX_REQUEST_BODY_LIMIT_MB, MIN_REQUEST_BODY_LIMIT_MB } from "@/shared/constants/bodySize"; -import { COMBO_CONFIG_MODES } from "@/shared/constants/comboConfigMode"; import { MODEL_SUPPORTED_ENDPOINT_VALUES, normalizeModelSupportedEndpoints, } from "@/shared/constants/modelSupportedEndpoints"; import { providerAllowsOptionalApiKey } from "@/shared/constants/providers"; -import { HIDEABLE_SIDEBAR_ITEM_IDS } from "@/shared/constants/sidebarVisibility"; -import { - isForbiddenUpstreamHeaderName, - isForbiddenCustomHeaderName, -} from "@/shared/constants/upstreamHeaders"; -import { MAX_TIMER_TIMEOUT_MS } from "@/shared/utils/runtimeTimeouts"; import { validateProviderSpecificData } from "@/shared/validation/providerSpecificData"; import { isReservedProviderPrefix, @@ -89,6 +76,16 @@ const providerNodeIconUrlSchema = z // Same fix shape as #6562 (priority cap raised to 100_000). export const MAX_PROVIDER_CREDENTIAL_LENGTH = 100_000; +// #15930: credentials arriving with this prefix are encrypted envelopes produced by a +// credential store. `encrypt()` deliberately skips re-encrypting values that already +// carry it, so an envelope would be stored verbatim, fail to decrypt at runtime, and +// surface only as a misleading "Missing API key" on the connection test. Plaintext +// keys from every provider (sk-…, tvly-…, JWTs, cookie headers, JSON storage state) +// start with something else, so rejecting at validation time is near-zero-false-positive. +// Checked inline rather than via lib/db/encryption's looksEncrypted() because this +// schema is imported by client-side dashboard code and must stay free of node:crypto. +const ENCRYPTED_ENVELOPE_PREFIX = "enc:v1:"; + export const createProviderSchema = z .object({ provider: z.string().min(1).max(100), @@ -123,6 +120,14 @@ export const createProviderSchema = z path: ["apiKey"], }); } + if (apiKey.startsWith(ENCRYPTED_ENVELOPE_PREFIX)) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: + "apiKey looks like an encrypted envelope from a credential store, not a plaintext key — store the plaintext API key", + path: ["apiKey"], + }); + } const cx = data.providerSpecificData && typeof data.providerSpecificData === "object" @@ -641,6 +646,15 @@ export const updateProviderConnectionSchema = z path: [], }); } + const apiKey = typeof value.apiKey === "string" ? value.apiKey.trim() : ""; + if (apiKey.startsWith(ENCRYPTED_ENVELOPE_PREFIX)) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: + "apiKey looks like an encrypted envelope from a credential store, not a plaintext key — store the plaintext API key", + path: ["apiKey"], + }); + } }); export const providersBatchTestSchema = z diff --git a/tests/unit/15930-encrypted-envelope-credential-rejected.test.ts b/tests/unit/15930-encrypted-envelope-credential-rejected.test.ts new file mode 100644 index 000000000000..430c72afc7c7 --- /dev/null +++ b/tests/unit/15930-encrypted-envelope-credential-rejected.test.ts @@ -0,0 +1,65 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +const { createProviderSchema, updateProviderConnectionSchema } = + await import("../../src/shared/validation/schemas/provider.ts"); + +// Foreign ciphertext envelope: right prefix, wrong (3-segment) shape — exactly what +// another tool's credential store produces and what #15930 let slip into the DB. +const ENVELOPE = "enc:v1:" + "a1b2c3d4e5f60718293a4b5c6d7e8f90".repeat(2); + +type SafeParseResult = + | { success: true } + | { + success: false; + error: { issues: Array<{ path: Array; message: string }> }; + }; + +function apiKeyIssues( + schema: { safeParse: (d: unknown) => SafeParseResult }, + data: unknown +): string[] { + const result = schema.safeParse(data); + if (result.success) return []; + return result.error.issues.filter((i) => i.path.includes("apiKey")).map((i) => i.message); +} + +test("create rejects an apiKey that is an encrypted envelope", () => { + const issues = apiKeyIssues(createProviderSchema, { + provider: "serper-search", + apiKey: ENVELOPE, + name: "andy+github@savage.hk", + }); + assert.ok( + issues.some((m) => /encrypted envelope/i.test(m)), + `expected an encrypted-envelope issue on apiKey, got: ${JSON.stringify(issues)}` + ); +}); + +test("create still accepts plaintext keys, including values that merely contain the prefix mid-string", () => { + for (const apiKey of ["767321c822493d35e9e4683d902fd0a9e5da51fd", "sk-enc:v1:not-an-envelope"]) { + const result = createProviderSchema.safeParse({ + provider: "serper-search", + apiKey, + name: "andy+github@savage.hk", + }); + assert.equal(result.success, true, `plaintext key rejected: ${apiKey}`); + } +}); + +test("update rejects an encrypted-envelope apiKey", () => { + const issues = apiKeyIssues(updateProviderConnectionSchema, { apiKey: ENVELOPE }); + assert.ok( + issues.some((m) => /encrypted envelope/i.test(m)), + `expected an encrypted-envelope issue on apiKey, got: ${JSON.stringify(issues)}` + ); +}); + +test("update still accepts plaintext keys and unrelated patches", () => { + assert.equal( + updateProviderConnectionSchema.safeParse({ apiKey: "sk-live-abc123" }).success, + true + ); + assert.equal(updateProviderConnectionSchema.safeParse({ isActive: true }).success, true); + assert.equal(updateProviderConnectionSchema.safeParse({}).success, false); // "No valid fields to update" preserved +});