fix(dashboard): media playground cards stop sending masked API key as Bearer

The 9 media *ExampleCard components under media-providers/components used
the masked value from useApiKey() (sk-xxxx****yyyy) as an Authorization:
Bearer header, which the gateway always rejects (AUTH_002) once
REQUIRE_API_KEY is enabled. Mirror the LlmChatCard fix (#3503): authenticate
via the dashboard session (credentials: "same-origin") and forward the
selected key's id via x-omniroute-playground-key-id instead of its secret.
buildCurl now keeps the <your-api-key> placeholder instead of the masked
value.

Adds tests/unit/bug-9935-masked-bearer.test.ts as the permanent regression
guard (asserts none of the 9 cards embed apiKey as a raw Bearer token).

Refs #9935
This commit is contained in:
adevwithpurpose
2026-08-15 02:33:57 -03:00
parent abd4df63dc
commit 2b4720fa98
12 changed files with 185 additions and 62 deletions

View File

@@ -0,0 +1 @@
- fix(dashboard): media mini-playgrounds authenticate via session instead of sending the masked API key as Bearer, fixing 401s under REQUIRE_API_KEY (#9935)

View File

@@ -5,6 +5,7 @@ import { useTranslations } from "next-intl";
import { useApiKey } from "../../providers/hooks/useApiKey";
import { useProviderModels } from "../../providers/hooks/useProviderModels";
import { buildCurl } from "../../providers/utils/buildCurl";
import { PLAYGROUND_KEY_ID_HEADER, resolvePlaygroundKeyId } from "../../providers/utils/playgroundAuth";
import { PlaygroundCard } from "./PlaygroundCard";
interface Props {
@@ -24,7 +25,7 @@ function extractError(data: unknown): string | null {
export function EmbeddingExampleCard({ providerId }: Props) {
const t = useTranslations("miniPlayground");
const { apiKey } = useApiKey();
const { apiKey, keys } = useApiKey();
const { models } = useProviderModels(providerId);
const firstModel = models[0]?.id ?? "";
@@ -43,7 +44,7 @@ export function EmbeddingExampleCard({ providerId }: Props) {
(typeof window !== "undefined" ? window.location.origin : "http://localhost:20128") +
ENDPOINT_PATH,
headers: {
Authorization: `Bearer ${apiKey || "<your-api-key>"}`,
Authorization: "Bearer <your-api-key>",
"Content-Type": "application/json",
},
body: buildBody(),
@@ -55,13 +56,18 @@ export function EmbeddingExampleCard({ providerId }: Props) {
setResult(undefined);
const t0 = performance.now();
try {
// Authenticate via the dashboard session cookie — never send the masked
// apiKey as a Bearer token (it is not a real credential; see #9935).
const headers: Record<string, string> = {
"Content-Type": "application/json",
"x-connection-id": providerId,
};
const playgroundKeyId = resolvePlaygroundKeyId(apiKey, keys);
if (playgroundKeyId) headers[PLAYGROUND_KEY_ID_HEADER] = playgroundKeyId;
const res = await fetch(ENDPOINT_PATH, {
method: "POST",
headers: {
Authorization: `Bearer ${apiKey}`,
"Content-Type": "application/json",
"x-connection-id": providerId,
},
credentials: "same-origin",
headers,
body: JSON.stringify(buildBody()),
});
const data: unknown = await res.json();

View File

@@ -6,6 +6,7 @@ import { useTranslations } from "next-intl";
import { useApiKey } from "../../providers/hooks/useApiKey";
import { useProviderModels } from "../../providers/hooks/useProviderModels";
import { buildCurl } from "../../providers/utils/buildCurl";
import { PLAYGROUND_KEY_ID_HEADER, resolvePlaygroundKeyId } from "../../providers/utils/playgroundAuth";
import { PlaygroundCard } from "./PlaygroundCard";
interface SuggestedHfModel {
@@ -101,7 +102,7 @@ function ImageResultRenderer(data: unknown, altText: string) {
export function ImageExampleCard({ providerId }: Props) {
const t = useTranslations("miniPlayground");
const tMedia = useTranslations("media");
const { apiKey } = useApiKey();
const { apiKey, keys } = useApiKey();
const { models } = useProviderModels(providerId);
const suggestedModels = useHfSuggestedImageModels(providerId);
@@ -121,7 +122,7 @@ export function ImageExampleCard({ providerId }: Props) {
(typeof window !== "undefined" ? window.location.origin : "http://localhost:20128") +
ENDPOINT_PATH,
headers: {
Authorization: `Bearer ${apiKey || "<your-api-key>"}`,
Authorization: "Bearer <your-api-key>",
"Content-Type": "application/json",
},
body: buildBody(),
@@ -133,13 +134,18 @@ export function ImageExampleCard({ providerId }: Props) {
setResult(undefined);
const t0 = performance.now();
try {
// Authenticate via the dashboard session cookie — never send the masked
// apiKey as a Bearer token (it is not a real credential; see #9935).
const headers: Record<string, string> = {
"Content-Type": "application/json",
"x-connection-id": providerId,
};
const playgroundKeyId = resolvePlaygroundKeyId(apiKey, keys);
if (playgroundKeyId) headers[PLAYGROUND_KEY_ID_HEADER] = playgroundKeyId;
const res = await fetch(ENDPOINT_PATH, {
method: "POST",
headers: {
Authorization: `Bearer ${apiKey}`,
"Content-Type": "application/json",
"x-connection-id": providerId,
},
credentials: "same-origin",
headers,
body: JSON.stringify(buildBody()),
});
const data: unknown = await res.json();

View File

@@ -5,6 +5,7 @@ import { useTranslations } from "next-intl";
import { useApiKey } from "../../providers/hooks/useApiKey";
import { useProviderModels } from "../../providers/hooks/useProviderModels";
import { buildCurl } from "../../providers/utils/buildCurl";
import { PLAYGROUND_KEY_ID_HEADER, resolvePlaygroundKeyId } from "../../providers/utils/playgroundAuth";
import { PlaygroundCard } from "./PlaygroundCard";
interface Props {
@@ -24,7 +25,7 @@ function extractError(data: unknown): string | null {
export function MusicExampleCard({ providerId }: Props) {
const t = useTranslations("miniPlayground");
const { apiKey } = useApiKey();
const { apiKey, keys } = useApiKey();
const { models } = useProviderModels(providerId);
const firstModel = models[0]?.id ?? "";
@@ -45,7 +46,7 @@ export function MusicExampleCard({ providerId }: Props) {
(typeof window !== "undefined" ? window.location.origin : "http://localhost:20128") +
ENDPOINT_PATH,
headers: {
Authorization: `Bearer ${apiKey || "<your-api-key>"}`,
Authorization: "Bearer <your-api-key>",
"Content-Type": "application/json",
},
body: buildBody(),
@@ -58,13 +59,18 @@ export function MusicExampleCard({ providerId }: Props) {
setAudioUrl(null);
const t0 = performance.now();
try {
// Authenticate via the dashboard session cookie — never send the masked
// apiKey as a Bearer token (it is not a real credential; see #9935).
const headers: Record<string, string> = {
"Content-Type": "application/json",
"x-connection-id": providerId,
};
const playgroundKeyId = resolvePlaygroundKeyId(apiKey, keys);
if (playgroundKeyId) headers[PLAYGROUND_KEY_ID_HEADER] = playgroundKeyId;
const res = await fetch(ENDPOINT_PATH, {
method: "POST",
headers: {
Authorization: `Bearer ${apiKey}`,
"Content-Type": "application/json",
"x-connection-id": providerId,
},
credentials: "same-origin",
headers,
body: JSON.stringify(buildBody()),
});
const elapsed = performance.now() - t0;

View File

@@ -5,6 +5,7 @@ import { useTranslations } from "next-intl";
import { useApiKey } from "../../providers/hooks/useApiKey";
import { useProviderModels } from "../../providers/hooks/useProviderModels";
import { buildCurl } from "../../providers/utils/buildCurl";
import { PLAYGROUND_KEY_ID_HEADER, resolvePlaygroundKeyId } from "../../providers/utils/playgroundAuth";
import { PlaygroundCard } from "./PlaygroundCard";
interface Props {
@@ -25,7 +26,7 @@ function extractError(data: unknown): string | null {
export function OcrExampleCard({ providerId }: Props) {
const t = useTranslations("miniPlayground");
const { apiKey } = useApiKey();
const { apiKey, keys } = useApiKey();
const { models } = useProviderModels(providerId);
const firstModel = models[0]?.id ?? "";
@@ -47,7 +48,7 @@ export function OcrExampleCard({ providerId }: Props) {
(typeof window !== "undefined" ? window.location.origin : "http://localhost:20128") +
ENDPOINT_PATH,
headers: {
Authorization: `Bearer ${apiKey || "<your-api-key>"}`,
Authorization: "Bearer <your-api-key>",
"Content-Type": "application/json",
},
body: buildBody(),
@@ -59,13 +60,18 @@ export function OcrExampleCard({ providerId }: Props) {
setResult(undefined);
const t0 = performance.now();
try {
// Authenticate via the dashboard session cookie — never send the masked
// apiKey as a Bearer token (it is not a real credential; see #9935).
const headers: Record<string, string> = {
"Content-Type": "application/json",
"x-connection-id": providerId,
};
const playgroundKeyId = resolvePlaygroundKeyId(apiKey, keys);
if (playgroundKeyId) headers[PLAYGROUND_KEY_ID_HEADER] = playgroundKeyId;
const res = await fetch(ENDPOINT_PATH, {
method: "POST",
headers: {
Authorization: `Bearer ${apiKey}`,
"Content-Type": "application/json",
"x-connection-id": providerId,
},
credentials: "same-origin",
headers,
body: JSON.stringify(buildBody()),
});
const data: unknown = await res.json();

View File

@@ -6,6 +6,7 @@ import { useApiKey } from "../../providers/hooks/useApiKey";
import { useProviderModels } from "../../providers/hooks/useProviderModels";
import { PlaygroundCard } from "./PlaygroundCard";
import { buildCurl } from "../../providers/utils/buildCurl";
import { PLAYGROUND_KEY_ID_HEADER, resolvePlaygroundKeyId } from "../../providers/utils/playgroundAuth";
interface Props {
providerId: string;
@@ -39,7 +40,7 @@ function SttResultRenderer(data: unknown) {
export function SttExampleCard({ providerId }: Props) {
const t = useTranslations("miniPlayground");
const { apiKey } = useApiKey();
const { apiKey, keys } = useApiKey();
const { models } = useProviderModels(providerId);
// Show only speech-to-text models. Providers like OpenRouter expose a large
@@ -72,7 +73,7 @@ export function SttExampleCard({ providerId }: Props) {
(typeof window !== "undefined" ? window.location.origin : "http://localhost:20128") +
ENDPOINT_PATH,
headers: {
Authorization: `Bearer ${apiKey || "<your-api-key>"}`,
Authorization: "Bearer <your-api-key>",
},
body: {
model: qualify(effectiveModel),
@@ -105,12 +106,15 @@ export function SttExampleCard({ providerId }: Props) {
formData.append("model", qualify(effectiveModel));
formData.append("file", file);
// Authenticate via the dashboard session cookie — never send the masked
// apiKey as a Bearer token (it is not a real credential; see #9935).
const headers: Record<string, string> = { "x-connection-id": providerId };
const playgroundKeyId = resolvePlaygroundKeyId(apiKey, keys);
if (playgroundKeyId) headers[PLAYGROUND_KEY_ID_HEADER] = playgroundKeyId;
const res = await fetch(ENDPOINT_PATH, {
method: "POST",
headers: {
Authorization: `Bearer ${apiKey}`,
"x-connection-id": providerId,
},
credentials: "same-origin",
headers,
body: formData,
});
const data: unknown = await res.json();

View File

@@ -5,6 +5,7 @@ import { useTranslations } from "next-intl";
import { useApiKey } from "../../providers/hooks/useApiKey";
import { useProviderModels } from "../../providers/hooks/useProviderModels";
import { buildCurl } from "../../providers/utils/buildCurl";
import { PLAYGROUND_KEY_ID_HEADER, resolvePlaygroundKeyId } from "../../providers/utils/playgroundAuth";
import { PlaygroundCard } from "./PlaygroundCard";
interface Props {
@@ -26,7 +27,7 @@ function extractError(data: unknown): string | null {
export function TtsExampleCard({ providerId }: Props) {
const t = useTranslations("miniPlayground");
const { apiKey } = useApiKey();
const { apiKey, keys } = useApiKey();
const { models } = useProviderModels(providerId);
const firstModel = models[0]?.id ?? "tts-1";
@@ -54,7 +55,7 @@ export function TtsExampleCard({ providerId }: Props) {
(typeof window !== "undefined" ? window.location.origin : "http://localhost:20128") +
ENDPOINT_PATH,
headers: {
Authorization: `Bearer ${apiKey || "<your-api-key>"}`,
Authorization: "Bearer <your-api-key>",
"Content-Type": "application/json",
},
body: buildBody(),
@@ -69,13 +70,18 @@ export function TtsExampleCard({ providerId }: Props) {
}
const t0 = performance.now();
try {
// Authenticate via the dashboard session cookie — never send the masked
// apiKey as a Bearer token (it is not a real credential; see #9935).
const headers: Record<string, string> = {
"Content-Type": "application/json",
"x-connection-id": providerId,
};
const playgroundKeyId = resolvePlaygroundKeyId(apiKey, keys);
if (playgroundKeyId) headers[PLAYGROUND_KEY_ID_HEADER] = playgroundKeyId;
const res = await fetch(ENDPOINT_PATH, {
method: "POST",
headers: {
Authorization: `Bearer ${apiKey}`,
"Content-Type": "application/json",
"x-connection-id": providerId,
},
credentials: "same-origin",
headers,
body: JSON.stringify(buildBody()),
});
const elapsed = performance.now() - t0;

View File

@@ -5,6 +5,7 @@ import { useTranslations } from "next-intl";
import { useApiKey } from "../../providers/hooks/useApiKey";
import { useProviderModels } from "../../providers/hooks/useProviderModels";
import { buildCurl } from "../../providers/utils/buildCurl";
import { PLAYGROUND_KEY_ID_HEADER, resolvePlaygroundKeyId } from "../../providers/utils/playgroundAuth";
import { PlaygroundCard } from "./PlaygroundCard";
interface Props {
@@ -48,7 +49,7 @@ function VideoResultRenderer(data: unknown, unsupportedText: string) {
export function VideoExampleCard({ providerId }: Props) {
const t = useTranslations("miniPlayground");
const { apiKey } = useApiKey();
const { apiKey, keys } = useApiKey();
const { models } = useProviderModels(providerId);
const firstModel = models[0]?.id ?? "";
@@ -66,7 +67,7 @@ export function VideoExampleCard({ providerId }: Props) {
(typeof window !== "undefined" ? window.location.origin : "http://localhost:20128") +
ENDPOINT_PATH,
headers: {
Authorization: `Bearer ${apiKey || "<your-api-key>"}`,
Authorization: "Bearer <your-api-key>",
"Content-Type": "application/json",
},
body: buildBody(),
@@ -78,13 +79,18 @@ export function VideoExampleCard({ providerId }: Props) {
setResult(undefined);
const t0 = performance.now();
try {
// Authenticate via the dashboard session cookie — never send the masked
// apiKey as a Bearer token (it is not a real credential; see #9935).
const headers: Record<string, string> = {
"Content-Type": "application/json",
"x-connection-id": providerId,
};
const playgroundKeyId = resolvePlaygroundKeyId(apiKey, keys);
if (playgroundKeyId) headers[PLAYGROUND_KEY_ID_HEADER] = playgroundKeyId;
const res = await fetch(ENDPOINT_PATH, {
method: "POST",
headers: {
Authorization: `Bearer ${apiKey}`,
"Content-Type": "application/json",
"x-connection-id": providerId,
},
credentials: "same-origin",
headers,
body: JSON.stringify(buildBody()),
});
const data: unknown = await res.json();

View File

@@ -4,6 +4,7 @@ import { useState } from "react";
import { useTranslations } from "next-intl";
import { useApiKey } from "../../providers/hooks/useApiKey";
import { buildCurl } from "../../providers/utils/buildCurl";
import { PLAYGROUND_KEY_ID_HEADER, resolvePlaygroundKeyId } from "../../providers/utils/playgroundAuth";
import { PlaygroundCard } from "./PlaygroundCard";
interface Props {
@@ -34,7 +35,7 @@ function extractError(data: unknown): string | null {
export function WebFetchExampleCard({ providerId }: Props) {
const t = useTranslations("miniPlayground");
const { apiKey } = useApiKey();
const { apiKey, keys } = useApiKey();
const [url, setUrl] = useState<string>("https://example.com");
const [format, setFormat] = useState<FetchFormat>("markdown");
@@ -50,7 +51,7 @@ export function WebFetchExampleCard({ providerId }: Props) {
(typeof window !== "undefined" ? window.location.origin : "http://localhost:20128") +
ENDPOINT_PATH,
headers: {
Authorization: `Bearer ${apiKey || "<your-api-key>"}`,
Authorization: "Bearer <your-api-key>",
"Content-Type": "application/json",
},
body: buildBody(),
@@ -62,13 +63,18 @@ export function WebFetchExampleCard({ providerId }: Props) {
setResult(undefined);
const t0 = performance.now();
try {
// Authenticate via the dashboard session cookie — never send the masked
// apiKey as a Bearer token (it is not a real credential; see #9935).
const headers: Record<string, string> = {
"Content-Type": "application/json",
"x-connection-id": providerId,
};
const playgroundKeyId = resolvePlaygroundKeyId(apiKey, keys);
if (playgroundKeyId) headers[PLAYGROUND_KEY_ID_HEADER] = playgroundKeyId;
const res = await fetch(ENDPOINT_PATH, {
method: "POST",
headers: {
Authorization: `Bearer ${apiKey}`,
"Content-Type": "application/json",
"x-connection-id": providerId,
},
credentials: "same-origin",
headers,
body: JSON.stringify(buildBody()),
});
const data: unknown = await res.json();

View File

@@ -4,6 +4,7 @@ import { useState } from "react";
import { useTranslations } from "next-intl";
import { useApiKey } from "../../providers/hooks/useApiKey";
import { buildCurl } from "../../providers/utils/buildCurl";
import { PLAYGROUND_KEY_ID_HEADER, resolvePlaygroundKeyId } from "../../providers/utils/playgroundAuth";
import { PlaygroundCard } from "./PlaygroundCard";
interface Props {
@@ -64,7 +65,7 @@ function SearchResultRenderer(data: unknown, fallbackTitle: (number: number) =>
export function WebSearchExampleCard({ providerId }: Props) {
const t = useTranslations("miniPlayground");
const { apiKey } = useApiKey();
const { apiKey, keys } = useApiKey();
const [query, setQuery] = useState<string>(() => t("webSearchSample"));
const [numResults, setNumResults] = useState<number>(5);
@@ -79,7 +80,7 @@ export function WebSearchExampleCard({ providerId }: Props) {
(typeof window !== "undefined" ? window.location.origin : "http://localhost:20128") +
ENDPOINT_PATH,
headers: {
Authorization: `Bearer ${apiKey || "<your-api-key>"}`,
Authorization: "Bearer <your-api-key>",
"Content-Type": "application/json",
},
body: buildBody(),
@@ -91,13 +92,18 @@ export function WebSearchExampleCard({ providerId }: Props) {
setResult(undefined);
const t0 = performance.now();
try {
// Authenticate via the dashboard session cookie — never send the masked
// apiKey as a Bearer token (it is not a real credential; see #9935).
const headers: Record<string, string> = {
"Content-Type": "application/json",
"x-connection-id": providerId,
};
const playgroundKeyId = resolvePlaygroundKeyId(apiKey, keys);
if (playgroundKeyId) headers[PLAYGROUND_KEY_ID_HEADER] = playgroundKeyId;
const res = await fetch(ENDPOINT_PATH, {
method: "POST",
headers: {
Authorization: `Bearer ${apiKey}`,
"Content-Type": "application/json",
"x-connection-id": providerId,
},
credentials: "same-origin",
headers,
body: JSON.stringify(buildBody()),
});
const data: unknown = await res.json();

View File

@@ -0,0 +1,34 @@
/**
* playgroundAuth — shared helpers so the dashboard mini-playgrounds authenticate
* upstream requests via the dashboard session instead of a raw Bearer token.
*
* `/api/keys` only ever exposes MASKED key values (sk-xxxx****yyyy — see
* `maskStoredApiKey` in `src/lib/apiKeyExposure.ts`). Sending that masked
* string as `Authorization: Bearer` is never a valid credential and 401s
* under `REQUIRE_API_KEY` (#9935). The fix mirrors `LlmChatCard.tsx` (#3503):
* requests go out with `credentials: "same-origin"` and no Bearer header: the
* gateway falls through to the dashboard session. When a specific key is
* selected we forward only its id via `PLAYGROUND_KEY_ID_HEADER` so the
* gateway can still apply that key's policy (allowed_models, etc.)
* server-side — the secret itself never reaches the browser.
*/
/** 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). */
export 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 when there is no match, which falls through to the dashboard
* session (full access, any model).
*/
export function resolvePlaygroundKeyId(
selectedMaskedKey: string,
keys: { id: string; key: string }[]
): string | null {
if (!selectedMaskedKey) return null;
return keys.find((k) => k.key === selectedMaskedKey)?.id ?? null;
}

View File

@@ -0,0 +1,36 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
import { maskStoredApiKey } from "../../src/lib/apiKeyExposure";
const COMPONENT_DIR = resolve("src/app/(dashboard)/dashboard/media-providers/components");
const EXAMPLE_CARDS = [
"WebSearchExampleCard.tsx",
"WebFetchExampleCard.tsx",
"ImageExampleCard.tsx",
"TtsExampleCard.tsx",
"SttExampleCard.tsx",
"OcrExampleCard.tsx",
"MusicExampleCard.tsx",
"EmbeddingExampleCard.tsx",
"VideoExampleCard.tsx",
];
const FIXED_REFERENCE = "LlmChatCard.tsx";
const MASKED_BEARER = /\bBearer\s*\$?\{?\s*apiKey/;
test("every media ExampleCard avoids sending a masked apiKey as Bearer (#9935)", () => {
for (const file of [...EXAMPLE_CARDS, FIXED_REFERENCE]) {
const src = readFileSync(resolve(COMPONENT_DIR, file), "utf8");
assert.ok(
!MASKED_BEARER.test(src),
`${file} still sends the (masked) apiKey as an Authorization: Bearer token — will 401 under REQUIRE_API_KEY`
);
}
});
test("masked value is never a real API key (repro of the 401 trigger)", () => {
const real = "sk-abcdef0123456789wxyz";
const masked = maskStoredApiKey(real);
assert.notEqual(masked, real);
});