diff --git a/src/app/(dashboard)/dashboard/media-providers/components/LlmChatCard.tsx b/src/app/(dashboard)/dashboard/media-providers/components/LlmChatCard.tsx index 12bdd585c294..f41dbd934b63 100644 --- a/src/app/(dashboard)/dashboard/media-providers/components/LlmChatCard.tsx +++ b/src/app/(dashboard)/dashboard/media-providers/components/LlmChatCard.tsx @@ -15,6 +15,25 @@ import { useProviderModels } from "../../providers/hooks/useProviderModels"; const ENDPOINT = "/api/v1/chat/completions"; +/** Header used to test a specific API key's policy from the dashboard playground + * without exposing the key secret to the browser — the gateway resolves the key + * by id server-side (see enforceApiKeyPolicy). */ +const PLAYGROUND_KEY_ID_HEADER = "x-omniroute-playground-key-id"; + +/** + * Map the playground's masked key selection (sk-xxxx****yyyy, as returned by + * `/api/keys`) back to its key id. The id — never the secret — is sent to the + * gateway so it can apply that key's policy (allowed_models, etc.) server-side. + * Returns null for "(default)", which falls through to the dashboard session. + */ +function resolvePlaygroundKeyId( + selectedMaskedKey: string, + keys: { id: string; key: string }[] +): string | null { + if (!selectedMaskedKey) return null; + return keys.find((k) => k.key === selectedMaskedKey)?.id ?? null; +} + /** * Qualify a provider-scoped playground model with its `providerId/` prefix so * OmniRoute can resolve it unambiguously. The previous heuristic only prefixed @@ -112,7 +131,7 @@ export function LlmChatCard({ onControlsChange, }: Props) { const t = useTranslations("miniPlayground"); - const { apiKey, keys } = useApiKey(); + const { keys } = useApiKey(); const { models } = useProviderModels(providerId); const [internalSelectedKey, setInternalSelectedKey] = useState(""); @@ -185,15 +204,23 @@ export function LlmChatCard({ const t0 = performance.now(); try { - const authKey = selectedKey || apiKey; + // The playground authenticates via the dashboard session cookie — we never + // put an API key secret on the wire. `/api/keys` only exposes MASKED values + // (sk-xxxx****yyyy), which are invalid as bearer tokens and would 401 under + // REQUIRE_API_KEY. When a specific key is selected we send only its id so the + // gateway can apply that key's policy (allowed_models, etc.) server-side. + // "(default)" sends no key id → full session access (any model). + const headers: Record = { + "Content-Type": "application/json", + "x-connection-id": providerId, + }; + const playgroundKeyId = resolvePlaygroundKeyId(selectedKey, keys); + if (playgroundKeyId) headers[PLAYGROUND_KEY_ID_HEADER] = playgroundKeyId; const res = await fetch(ENDPOINT, { method: "POST", signal: controller.signal, - headers: { - Authorization: `Bearer ${authKey}`, - "Content-Type": "application/json", - "x-connection-id": providerId, - }, + credentials: "same-origin", + headers, body: JSON.stringify({ model: qualifiedModel, messages: [ @@ -298,7 +325,7 @@ export function LlmChatCard({ // Refocus textarea so user can keep typing requestAnimationFrame(() => textareaRef.current?.focus()); } - }, [input, streaming, selectedKey, apiKey, providerId, qualifiedModel, messages]); + }, [input, streaming, selectedKey, keys, providerId, qualifiedModel, messages]); const handleKeyDown = (e: React.KeyboardEvent) => { if (e.key === "Enter" && !e.shiftKey) { diff --git a/src/shared/utils/apiKeyPolicy.ts b/src/shared/utils/apiKeyPolicy.ts index 2d14ca89fedf..2ae2e9a0406c 100644 --- a/src/shared/utils/apiKeyPolicy.ts +++ b/src/shared/utils/apiKeyPolicy.ts @@ -9,7 +9,8 @@ */ import { extractApiKey } from "@/sse/services/auth"; -import { getApiKeyMetadata, getComboByName, isModelAllowedForKey } from "@/lib/localDb"; +import { getApiKeyMetadata, getComboByName, isModelAllowedForKey, getApiKeyById } from "@/lib/localDb"; +import { isDashboardSessionAuthenticated } from "./apiAuth"; import { resolveComboForModel } from "@/lib/db/modelComboMappings"; import { checkBudget } from "@/domain/costRules"; import { checkTokenLimits } from "@omniroute/open-sse/services/tokenLimitCounter.ts"; @@ -225,13 +226,41 @@ export interface ApiKeyPolicyResult { * // proceed with request, optionally use policy.apiKeyInfo * ``` */ +/** Header carrying the id of the API key a dashboard playground request wants to + * test the policy for (never the key secret). */ +const PLAYGROUND_KEY_ID_HEADER = "x-omniroute-playground-key-id"; + +/** + * Dashboard playground support. An authenticated admin session may test a + * specific API key's policy (allowed_models, budget, …) WITHOUT putting the key + * secret on the wire: the browser sends only the key id via + * `x-omniroute-playground-key-id` and we resolve the secret server-side. + * + * Security: honored ONLY for authenticated dashboard sessions, and only as a + * fallback when no bearer key was presented — so it can never bypass auth or + * escalate privileges, it only applies (narrows to) the selected key's policy. + */ +export async function resolvePlaygroundTestKey(request: Request): Promise { + const keyId = request.headers.get(PLAYGROUND_KEY_ID_HEADER); + if (!keyId) return null; + if (!(await isDashboardSessionAuthenticated(request))) return null; + try { + const row = await getApiKeyById(keyId); + return typeof row?.key === "string" ? row.key : null; + } catch { + return null; + } +} + export async function enforceApiKeyPolicy( request: Request, modelStr: string | null ): Promise { - const apiKey = extractApiKey(request); + // A real bearer key wins; otherwise an authenticated dashboard playground may + // test a specific key's policy by id (resolved server-side, secret never sent). + const apiKey = extractApiKey(request) || (await resolvePlaygroundTestKey(request)); - // No API key = local mode, skip policy checks + // No API key = local/session mode, skip policy checks if (!apiKey) { return { apiKey: null, apiKeyInfo: null, rejection: null }; } diff --git a/tests/unit/playground-key-policy-3503.test.ts b/tests/unit/playground-key-policy-3503.test.ts new file mode 100644 index 000000000000..fd6a87850c39 --- /dev/null +++ b/tests/unit/playground-key-policy-3503.test.ts @@ -0,0 +1,77 @@ +/** + * #3503 — dashboard playground key-by-id resolution. + * + * The playground sends only the API key *id* (never the secret) via + * `x-omniroute-playground-key-id`; the gateway resolves the secret server-side + * in `resolvePlaygroundTestKey`. SECURITY INVARIANT: this is honored ONLY for an + * authenticated dashboard session — the header alone must never resolve a key, + * so it can't be abused by an unauthenticated caller to apply (or probe) a key's + * policy. + */ + +import test from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { SignJWT } from "jose"; + +const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omr-playground-key-3503-")); +process.env.DATA_DIR = TEST_DATA_DIR; +process.env.API_KEY_SECRET = process.env.API_KEY_SECRET || "playground-3503-api-secret"; +process.env.JWT_SECRET = "playground-3503-jwt-secret"; + +const apiKeysDb = await import("../../src/lib/db/apiKeys.ts"); +const { resolvePlaygroundTestKey } = await import("../../src/shared/utils/apiKeyPolicy.ts"); + +const PLAYGROUND_KEY_ID_HEADER = "x-omniroute-playground-key-id"; + +const created = await apiKeysDb.createApiKey("playground-3503", "machine-3503", []); +const KEY_ID = created.id; +const KEY_SECRET = created.key; + +async function sessionCookie(): Promise { + const secret = new TextEncoder().encode(process.env.JWT_SECRET); + const jwt = await new SignJWT({ sub: "admin" }) + .setProtectedHeader({ alg: "HS256" }) + .setExpirationTime("1h") + .sign(secret); + return `auth_token=${jwt}`; +} + +function req(headers: Record) { + return { + method: "POST", + headers: new Headers(headers), + url: "http://localhost/api/v1/chat/completions", + } as unknown as Request; +} + +test.after(() => { + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); +}); + +test("#3503 — authenticated session + key-id header resolves the key secret server-side", async () => { + const out = await resolvePlaygroundTestKey( + req({ [PLAYGROUND_KEY_ID_HEADER]: KEY_ID, cookie: await sessionCookie() }) + ); + assert.equal(out, KEY_SECRET, "an authenticated session should resolve the selected key's secret by id"); +}); + +test("#3503 — SECURITY: the key-id header is IGNORED without an authenticated session", async () => { + // Same valid key id, but no session cookie → must NOT resolve the secret. + const out = await resolvePlaygroundTestKey(req({ [PLAYGROUND_KEY_ID_HEADER]: KEY_ID })); + assert.equal(out, null, "an unauthenticated request must never resolve a key by id"); +}); + +test("#3503 — SECURITY: an invalid session token is rejected", async () => { + const out = await resolvePlaygroundTestKey( + req({ [PLAYGROUND_KEY_ID_HEADER]: KEY_ID, cookie: "auth_token=not-a-valid-jwt" }) + ); + assert.equal(out, null, "a forged/invalid session token must not resolve a key"); +}); + +test("#3503 — no key-id header → null even with a valid session", async () => { + const out = await resolvePlaygroundTestKey(req({ cookie: await sessionCookie() })); + assert.equal(out, null); +});