Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 0 additions & 5 deletions config/quality/eslint-suppressions.json
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
40 changes: 27 additions & 13 deletions src/shared/validation/schemas/provider.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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),
Expand Down Expand Up @@ -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"
Expand Down Expand Up @@ -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
Expand Down
65 changes: 65 additions & 0 deletions tests/unit/15930-encrypted-envelope-credential-rejected.test.ts
Original file line number Diff line number Diff line change
@@ -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<string | number | symbol>; 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
});