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
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Comment on lines +29 to +35

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Using the masked key string (sk-xxxx****yyyy) as the select option value introduces a potential collision risk if multiple keys share the same masked representation. A more robust approach would be to use the unique key id as the <option> value directly. This would eliminate the need for the client-side resolvePlaygroundKeyId lookup entirely and prevent any collision issues.


/**
* Qualify a provider-scoped playground model with its `providerId/` prefix so
* OmniRoute can resolve it unambiguously. The previous heuristic only prefixed
Expand Down Expand Up @@ -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<string>("");
Expand Down Expand Up @@ -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<string, string> = {
"Content-Type": "application/json",
"x-connection-id": providerId,
};
const playgroundKeyId = resolvePlaygroundKeyId(selectedKey, keys);
if (playgroundKeyId) headers[PLAYGROUND_KEY_ID_HEADER] = playgroundKeyId;
Comment on lines +217 to +218
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: [
Expand Down Expand Up @@ -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<HTMLTextAreaElement>) => {
if (e.key === "Enter" && !e.shiftKey) {
Expand Down
35 changes: 32 additions & 3 deletions src/shared/utils/apiKeyPolicy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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.
Comment on lines +234 to +237
*
* 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<string | null> {
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);
Comment on lines +246 to +248
return typeof row?.key === "string" ? row.key : null;
} catch {
return null;
}
}

export async function enforceApiKeyPolicy(
request: Request,
modelStr: string | null
): Promise<ApiKeyPolicyResult> {
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 };
}
Comment on lines +263 to 266
Expand Down
77 changes: 77 additions & 0 deletions tests/unit/playground-key-policy-3503.test.ts
Original file line number Diff line number Diff line change
@@ -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<string> {
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<string, string>) {
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);
});