From 834524d626ebe3df3d8c1e3b2aa8b7636d5e3e0f Mon Sep 17 00:00:00 2001 From: "coderabbitai[bot]" <136622811+coderabbitai[bot]@users.noreply.github.com> Date: Tue, 10 Nov 2026 23:50:28 +0000 Subject: [PATCH 01/11] fix(webview): prevent CSP injection through OpenRouter URLs --- src/core/webview/ClineProvider.ts | 13 +++--- .../webview/__tests__/ClineProvider.spec.ts | 44 +++++++++++++++++++ src/core/webview/webviewCsp.ts | 19 ++++++++ 3 files changed, 68 insertions(+), 8 deletions(-) create mode 100644 src/core/webview/webviewCsp.ts diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index 6cec3bdbe7..1bd953f059 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -154,6 +154,7 @@ import { import { readTaskMessages } from "../task-persistence/taskMessages" import { readFileWithMissingRetry } from "../task-persistence/readFileWithMissingRetry" import { getNonce } from "./getNonce" +import { getOpenRouterCspOrigin, escapeCspAttribute } from "./webviewCsp" import { getUri } from "./getUri" import { omitOriginalContentFromExtensionMessage } from "./stripOriginalContent" import { REQUESTY_BASE_URL } from "../../shared/utils/requesty" @@ -1848,9 +1849,7 @@ export class ClineProvider // Get the OpenRouter base URL from configuration const { apiConfiguration } = await this.getState() - const openRouterBaseUrl = apiConfiguration.openRouterBaseUrl || "https://openrouter.ai" - // Extract the domain for CSP - const openRouterDomain = openRouterBaseUrl.match(/^(https?:\/\/[^/]+)/)?.[1] || "https://openrouter.ai" + const openRouterDomain = getOpenRouterCspOrigin(apiConfiguration.openRouterBaseUrl) const stylesUri = getUri(webview, this.contextProxy.extensionUri, [ "webview-ui", @@ -1897,7 +1896,7 @@ export class ClineProvider - + ', "https://example.com"], + ["https://example.com;script-src", "https://openrouter.ai"], + [`https://example.com; script-src-elem 'unsafe-inline'">`, "https://openrouter.ai"], + ["https://example.com'", "https://openrouter.ai"], + ['https://example.com">', "https://openrouter.ai"], + ["javascript:attack()", "https://openrouter.ai"], + ["file:///tmp/test", "https://openrouter.ai"], + ["invalid", "https://openrouter.ai"], + ])("builds safe production and HMR CSPs for endpoint %s", async (baseUrl, origin) => { + const state = await provider.getState() + vi.spyOn(provider, "getState").mockResolvedValue({ + ...state, + apiConfiguration: { ...state.apiConfiguration, openRouterBaseUrl: baseUrl }, + }) + vi.mocked(axios.get).mockResolvedValue({ data: "" }) + + for (const build of ["getHtmlContent", "getHMRHtmlContent"] as const) { + const html = await provider[build](mockWebviewView.webview) + const csp = html.match(/http-equiv="Content-Security-Policy" content="([^"]*)"/)?.[1] + expect(csp).toBeDefined() + expect(csp).toContain(`connect-src vscode-webview://test-csp-source ${origin} `) + expect(csp?.match(/script-src /g)).toHaveLength(1) + expect(csp).toContain("'nonce-") + expect(html).not.toContain("attack()") + expect(html).not.toContain("password") + } + }) + test("resolveWebviewView sets up webview correctly in development mode even if local server is not running", async () => { const developmentContext = { ...mockContext, extensionMode: vscode.ExtensionMode.Development } provider = new ClineProvider( diff --git a/src/core/webview/webviewCsp.ts b/src/core/webview/webviewCsp.ts new file mode 100644 index 0000000000..0969ca1a40 --- /dev/null +++ b/src/core/webview/webviewCsp.ts @@ -0,0 +1,19 @@ +/** Return one CSP source, never configuration-supplied policy or markup. */ +export function getOpenRouterCspOrigin(baseUrl?: string): string { + try { + const url = new URL(baseUrl || "https://openrouter.ai") + // URL hostnames can contain CSP delimiters such as semicolons and quotes. + // Allow DNS names, IPv4 and bracketed IPv6, including local proxy hosts. + if ((url.protocol === "https:" || url.protocol === "http:") && /^[a-z0-9._:[\]-]+$/i.test(url.hostname)) { + return url.origin + } + } catch { + // Invalid endpoints must not prevent the webview from rendering safely. + } + return "https://openrouter.ai" +} + +/** Escape a complete CSP for a double-quoted HTML attribute. */ +export function escapeCspAttribute(policy: string): string { + return policy.replace(/&/g, "&").replace(/"/g, """).replace(//g, ">") +} From 1638b42f214dc623196c9a983afbd2756fce64bf Mon Sep 17 00:00:00 2001 From: Elliott de Launay Date: Sat, 14 Nov 2026 19:55:38 +0000 Subject: [PATCH 02/11] test(webview): add unit coverage for webviewCsp helpers --- .../webview/__tests__/ClineProvider.spec.ts | 36 ++++++------ src/core/webview/__tests__/webviewCsp.spec.ts | 57 +++++++++++++++++++ 2 files changed, 74 insertions(+), 19 deletions(-) create mode 100644 src/core/webview/__tests__/webviewCsp.spec.ts diff --git a/src/core/webview/__tests__/ClineProvider.spec.ts b/src/core/webview/__tests__/ClineProvider.spec.ts index 1e6b48740f..bf489e9dc8 100644 --- a/src/core/webview/__tests__/ClineProvider.spec.ts +++ b/src/core/webview/__tests__/ClineProvider.spec.ts @@ -3057,38 +3057,36 @@ describe("ClineProvider", () => { const webview = { ...mockWebviewView.webview, cspSource: 'https://test/?x="">' } for (const build of ["getHtmlContent", "getHMRHtmlContent"] as const) { const html = await provider[build](webview) - const csp = html.match(/http-equiv="Content-Security-Policy" content="([^"]*)"/)?.[1] + // Capture through the stylesheet link so a raw quote in any cspSource cannot truncate the check. + const csp = html.match( + /http-equiv="Content-Security-Policy" content="([\s\S]*?)">\s*]/) + expect(csp).not.toMatch(/["<>]/) } }) - test.each([ - [undefined, "https://openrouter.ai"], - ["https://user:password@EXAMPLE.com:8443/api/v1?key=secret#fragment", "https://example.com:8443"], - ["http://localhost:3000/api/v1", "http://localhost:3000"], - ["http://[::1]:3000/api", "http://[::1]:3000"], - ['https://example.com/path;script-src?x=">', "https://example.com"], - ["https://example.com;script-src", "https://openrouter.ai"], - [`https://example.com; script-src-elem 'unsafe-inline'">`, "https://openrouter.ai"], - ["https://example.com'", "https://openrouter.ai"], - ['https://example.com">', "https://openrouter.ai"], - ["javascript:attack()", "https://openrouter.ai"], - ["file:///tmp/test", "https://openrouter.ai"], - ["invalid", "https://openrouter.ai"], - ])("builds safe production and HMR CSPs for endpoint %s", async (baseUrl, origin) => { + test("builds the production and HMR CSPs from the webviewCsp helpers", async () => { const state = await provider.getState() vi.spyOn(provider, "getState").mockResolvedValue({ ...state, - apiConfiguration: { ...state.apiConfiguration, openRouterBaseUrl: baseUrl }, + apiConfiguration: { + ...state.apiConfiguration, + // The origin helper must strip credentials and paths; the escape helper must escape quotes. + openRouterBaseUrl: + 'https://user:password@example.com:8443/api;script-src?x=">', + }, }) vi.mocked(axios.get).mockResolvedValue({ data: "" }) for (const build of ["getHtmlContent", "getHMRHtmlContent"] as const) { const html = await provider[build](mockWebviewView.webview) - const csp = html.match(/http-equiv="Content-Security-Policy" content="([^"]*)"/)?.[1] + const csp = html.match( + /http-equiv="Content-Security-Policy" content="([\s\S]*?)">\s* { + it.each([ + [undefined, "https://openrouter.ai"], + ["https://openrouter.ai/api/v1", "https://openrouter.ai"], + // Credentials, paths, queries and fragments are stripped, not echoed. + ["https://user:password@EXAMPLE.com:8443/api/v1?key=secret#fragment", "https://example.com:8443"], + // Local proxy hosts, IPv4 and bracketed IPv6 stay allowed. + ["http://localhost:3000/api/v1", "http://localhost:3000"], + ["http://127.0.0.1:3000/api", "http://127.0.0.1:3000"], + ["http://[::1]:3000/api", "http://[::1]:3000"], + ["http://my_proxy:8080", "http://my_proxy:8080"], + // CSP delimiters and markup in the path or host fall back to the default. + ['https://example.com/path;script-src?x=">', "https://example.com"], + ["https://example.com;script-src", "https://openrouter.ai"], + [`https://example.com; script-src-elem 'unsafe-inline'">`, "https://openrouter.ai"], + ["https://example.com'", "https://openrouter.ai"], + ['https://example.com">', "https://openrouter.ai"], + // The hostname regex rejects wildcard and list syntax that CSP would misread. + ["https://*.example.com", "https://openrouter.ai"], + ["https://a,b.com", "https://openrouter.ai"], + // The protocol allowlist rejects non-http(s) schemes before the host is read. + ["javascript:attack()", "https://openrouter.ai"], + ["file:///tmp/test", "https://openrouter.ai"], + ["ftp://example.com", "https://openrouter.ai"], + ["ws://example.com", "https://openrouter.ai"], + // Malformed input falls back to the default. + ["invalid", "https://openrouter.ai"], + ["", "https://openrouter.ai"], + ])("maps endpoint %s to origin %s", (baseUrl, origin) => { + expect(getOpenRouterCspOrigin(baseUrl)).toBe(origin) + }) +}) + +describe("escapeCspAttribute", () => { + it.each([ + ["&", "&"], + ['"', """], + ["<", "<"], + [">", ">"], + ])("escapes %s as %s", (raw, escaped) => { + expect(escapeCspAttribute(`a ${raw} b`)).toBe(`a ${escaped} b`) + }) + + it("leaves safe characters unchanged", () => { + expect(escapeCspAttribute("default-src 'none'; script-src 'nonce-abc'")).toBe( + "default-src 'none'; script-src 'nonce-abc'", + ) + }) + + it("escapes every character so no raw markup survives", () => { + expect(escapeCspAttribute('&">', }, diff --git a/src/core/webview/__tests__/webviewCsp.spec.ts b/src/core/webview/__tests__/webviewCsp.spec.ts index 937704bcf9..4ebe92d7b7 100644 --- a/src/core/webview/__tests__/webviewCsp.spec.ts +++ b/src/core/webview/__tests__/webviewCsp.spec.ts @@ -13,12 +13,15 @@ describe("getOpenRouterCspOrigin", () => { ["http://127.0.0.1:3000/api", "http://127.0.0.1:3000"], ["http://[::1]:3000/api", "http://[::1]:3000"], ["http://my_proxy:8080", "http://my_proxy:8080"], + ["https://my-proxy.example.com:8443/api", "https://my-proxy.example.com:8443"], // CSP delimiters and markup in the path or host fall back to the default. ['https://example.com/path;script-src?x=">', "https://example.com"], ["https://example.com;script-src", "https://openrouter.ai"], [`https://example.com; script-src-elem 'unsafe-inline'">`, "https://openrouter.ai"], ["https://example.com'", "https://openrouter.ai"], ['https://example.com">', "https://openrouter.ai"], + // Only the hostname regex rejects this quote; the URL parses without it. + ['https://example.com"', "https://openrouter.ai"], // The hostname regex rejects wildcard and list syntax that CSP would misread. ["https://*.example.com", "https://openrouter.ai"], ["https://a,b.com", "https://openrouter.ai"], From dd09f9151933512f606921e0603149f39bc61e25 Mon Sep 17 00:00:00 2001 From: Elliott de Launay Date: Sun, 15 Nov 2026 00:25:38 +0000 Subject: [PATCH 05/11] fix(webview): move provider lookups to the extension host --- packages/types/src/vscode-extension-host.ts | 11 + src/core/webview/ClineProvider.ts | 15 +- .../webview/__tests__/ClineProvider.spec.ts | 98 +++------ .../__tests__/providerInfoHandler.spec.ts | 168 +++++++++++++++ src/core/webview/__tests__/webviewCsp.spec.ts | 60 ------ src/core/webview/providerInfoHandler.ts | 195 ++++++++++++++++++ src/core/webview/webviewCsp.ts | 19 -- src/core/webview/webviewMessageHandler.ts | 4 + .../__tests__/providerInfoHooks.spec.tsx | 173 ++++++++++++++++ .../__tests__/useOpenRouterKeyInfo.spec.tsx | 123 ----------- .../useOpenRouterModelProviders.spec.tsx | 190 ----------------- .../__tests__/useRequestyKeyInfo.spec.tsx | 122 ----------- .../ui/hooks/requestProviderInfo.ts | 38 ++++ .../ui/hooks/useOpenRouterKeyInfo.ts | 49 +---- .../ui/hooks/useOpenRouterModelProviders.ts | 86 +------- .../components/ui/hooks/useRequestyKeyInfo.ts | 43 +--- webview-ui/src/utils/test-utils.tsx | 33 --- 17 files changed, 648 insertions(+), 779 deletions(-) create mode 100644 src/core/webview/__tests__/providerInfoHandler.spec.ts delete mode 100644 src/core/webview/__tests__/webviewCsp.spec.ts create mode 100644 src/core/webview/providerInfoHandler.ts delete mode 100644 src/core/webview/webviewCsp.ts create mode 100644 webview-ui/src/components/ui/hooks/__tests__/providerInfoHooks.spec.tsx delete mode 100644 webview-ui/src/components/ui/hooks/__tests__/useOpenRouterKeyInfo.spec.tsx delete mode 100644 webview-ui/src/components/ui/hooks/__tests__/useOpenRouterModelProviders.spec.tsx delete mode 100644 webview-ui/src/components/ui/hooks/__tests__/useRequestyKeyInfo.spec.tsx create mode 100644 webview-ui/src/components/ui/hooks/requestProviderInfo.ts diff --git a/packages/types/src/vscode-extension-host.ts b/packages/types/src/vscode-extension-host.ts index 8a006c0639..c58ab6145c 100644 --- a/packages/types/src/vscode-extension-host.ts +++ b/packages/types/src/vscode-extension-host.ts @@ -27,6 +27,15 @@ import type { WorktreeIncludeStatus } from "./worktree.js" * ExtensionMessage * Extension -> Webview | CLI */ +/** Provider lookups the extension host runs for the webview, keeping provider origins out of the CSP. */ +export type ProviderInfoKind = "openRouterKeyInfo" | "openRouterModelProviders" | "requestyKeyInfo" + +export interface ProviderInfoParams { + apiKey?: string + baseUrl?: string + modelId?: string +} + export interface ExtensionMessage { type: | "action" @@ -66,6 +75,7 @@ export interface ExtensionMessage { | "ttsStart" | "ttsStop" | "fileSearchResults" + | "providerInfo" | "toggleApiConfigPin" | "acceptInput" | "setHistoryPreviewCollapsed" @@ -549,6 +559,7 @@ export interface WebviewMessage { | "resetState" | typeof RouterModelsMessageType.flushRouterModels | typeof RouterModelsMessageType.requestRouterModels + | "requestProviderInfo" | typeof OpenAiModelsMessageType.requestOpenAiModels | typeof OllamaModelsMessageType.requestOllamaModels | typeof LmStudioModelsMessageType.requestLmStudioModels diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index 1bd953f059..333c646ff9 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -154,7 +154,6 @@ import { import { readTaskMessages } from "../task-persistence/taskMessages" import { readFileWithMissingRetry } from "../task-persistence/readFileWithMissingRetry" import { getNonce } from "./getNonce" -import { getOpenRouterCspOrigin, escapeCspAttribute } from "./webviewCsp" import { getUri } from "./getUri" import { omitOriginalContentFromExtensionMessage } from "./stripOriginalContent" import { REQUESTY_BASE_URL } from "../../shared/utils/requesty" @@ -1847,10 +1846,6 @@ export class ClineProvider const nonce = getNonce() - // Get the OpenRouter base URL from configuration - const { apiConfiguration } = await this.getState() - const openRouterDomain = getOpenRouterCspOrigin(apiConfiguration.openRouterBaseUrl) - const stylesUri = getUri(webview, this.contextProxy.extensionUri, [ "webview-ui", "build", @@ -1887,7 +1882,7 @@ export class ClineProvider `img-src ${webview.cspSource} https://storage.googleapis.com https://img.clerk.com https://avatars.githubusercontent.com https://lh3.googleusercontent.com data:`, `media-src ${webview.cspSource}`, `script-src 'unsafe-eval' ${webview.cspSource} https://*.posthog.com http://${localServerUrl} http://0.0.0.0:${localPort} 'nonce-${nonce}'`, - `connect-src ${webview.cspSource} ${openRouterDomain} https://* https://*.posthog.com ws://${localServerUrl} ws://0.0.0.0:${localPort} http://${localServerUrl} http://0.0.0.0:${localPort}`, + `connect-src ${webview.cspSource} https://* https://*.posthog.com ws://${localServerUrl} ws://0.0.0.0:${localPort} http://${localServerUrl} http://0.0.0.0:${localPort}`, ] return /*html*/ ` @@ -1896,7 +1891,7 @@ export class ClineProvider - + ', - }, - }) - vi.mocked(axios.get).mockResolvedValue({ data: "" }) + // Provider lookups run in the extension host, so configured endpoints must never reach the CSP. + test.each(["getHtmlContent", "getHMRHtmlContent"] as const)( + "%s keeps configured provider endpoints out of the CSP", + async (build) => { + const state = await provider.getState() + vi.spyOn(provider, "getState").mockResolvedValue({ + ...state, + apiConfiguration: { + ...state.apiConfiguration, + openRouterBaseUrl: "https://evil.example;script-src 'unsafe-inline'\">", + requestyBaseUrl: "https://requesty.example", + }, + }) + vi.mocked(axios.get).mockResolvedValue({ data: "" }) - for (const build of ["getHtmlContent", "getHMRHtmlContent"] as const) { const html = await provider[build](mockWebviewView.webview) const csp = html.match( /http-equiv="Content-Security-Policy" content="([\s\S]*?)">\s* { - const state = await provider.getState() - vi.spyOn(provider, "getState").mockResolvedValue({ - ...state, - apiConfiguration: { ...state.apiConfiguration, openRouterBaseUrl: baseUrl }, - }) - vi.mocked(axios.get).mockResolvedValue({ data: "" }) - - for (const build of ["getHtmlContent", "getHMRHtmlContent"] as const) { - const html = await provider[build](mockWebviewView.webview) - const csp = html.match( - /http-equiv="Content-Security-Policy" content="([\s\S]*?)">\s* { const developmentContext = { ...mockContext, extensionMode: vscode.ExtensionMode.Development } @@ -3143,7 +3107,7 @@ describe("ClineProvider", () => { // Verify Content Security Policy contains the necessary PostHog domains expect(mockWebviewView.webview.html).toContain( - "connect-src vscode-webview://test-csp-source https://openrouter.ai https://api.requesty.ai https://us.i.posthog.com", + "connect-src vscode-webview://test-csp-source https://us.i.posthog.com", ) // Extract the script-src directive section and verify required security elements diff --git a/src/core/webview/__tests__/providerInfoHandler.spec.ts b/src/core/webview/__tests__/providerInfoHandler.spec.ts new file mode 100644 index 0000000000..0101295f07 --- /dev/null +++ b/src/core/webview/__tests__/providerInfoHandler.spec.ts @@ -0,0 +1,168 @@ +import type { WebviewMessage } from "@roo-code/types" + +import type { ClineProvider } from "../ClineProvider" +import { handleRequestProviderInfo } from "../providerInfoHandler" + +const KEY_INFO = { + label: "sk-or-v1-...abc", + usage: 12.5, + is_free_tier: false, + is_provisioning_key: false, + rate_limit: { requests: 60, interval: "1m" }, + limit: 100, +} + +const REQUESTY_KEY_INFO = { + name: "key", + monthly_limit: "100", + monthly_spend: "5", + org_balance: "95", + config: {}, +} + +const ENDPOINTS = { + data: { + id: "openai/gpt-4o", + name: "GPT-4o", + description: "desc", + architecture: { input_modalities: ["text", "image"], output_modalities: ["text"] }, + endpoints: [ + { + name: "OpenAI | gpt-4o", + tag: "openai", + context_length: 128000, + max_completion_tokens: 16384, + pricing: { prompt: "0.000005", completion: "0.000015", input_cache_read: "0.0000025" }, + }, + ], + }, +} + +const jsonResponse = (body: unknown, status = 200) => new Response(JSON.stringify(body), { status }) + +describe("handleRequestProviderInfo", () => { + const postMessageToWebview = vi.fn() + const provider = { postMessageToWebview, log: vi.fn() } as unknown as ClineProvider // Only these two members are used. + let fetchSpy: ReturnType + + const send = (values: WebviewMessage["values"]) => + handleRequestProviderInfo(provider, { type: "requestProviderInfo", requestId: "req-1", values }) + + beforeEach(() => { + postMessageToWebview.mockReset() + fetchSpy = vi.spyOn(globalThis, "fetch") + vi.spyOn(console, "error").mockImplementation(() => {}) + }) + + afterEach(() => { + vi.restoreAllMocks() + }) + + it("fetches OpenRouter key info from the default endpoint with a bearer token", async () => { + fetchSpy.mockResolvedValue(jsonResponse({ data: KEY_INFO })) + + await send({ kind: "openRouterKeyInfo", apiKey: "sk-or-v1-test" }) + + expect(fetchSpy.mock.calls[0]![0]).toBe("https://openrouter.ai/api/v1/key") + expect(fetchSpy.mock.calls[0]![1]).toMatchObject({ headers: { Authorization: "Bearer sk-or-v1-test" } }) + expect(postMessageToWebview).toHaveBeenCalledWith({ + type: "providerInfo", + requestId: "req-1", + values: { data: KEY_INFO }, + }) + }) + + it("uses a caller-supplied OpenRouter base URL", async () => { + fetchSpy.mockResolvedValue(jsonResponse({ data: KEY_INFO })) + + await send({ kind: "openRouterKeyInfo", apiKey: "k", baseUrl: "https://proxy.example.com/api/v1" }) + + expect(fetchSpy.mock.calls[0]![0]).toBe("https://proxy.example.com/api/v1/key") + }) + + it.each(["openRouterKeyInfo", "requestyKeyInfo"])( + "returns null without a request when %s has no apiKey", + async (kind) => { + await send({ kind }) + + expect(fetchSpy).not.toHaveBeenCalled() + expect(postMessageToWebview).toHaveBeenCalledWith({ + type: "providerInfo", + requestId: "req-1", + values: { data: null }, + }) + }, + ) + + it("fetches Requesty key info from the api service URL", async () => { + fetchSpy.mockResolvedValue(jsonResponse(REQUESTY_KEY_INFO)) + + await send({ kind: "requestyKeyInfo", apiKey: "rk" }) + + expect(fetchSpy.mock.calls[0]![0]).toBe("https://api.requesty.ai/x/apikey") + expect(postMessageToWebview).toHaveBeenCalledWith({ + type: "providerInfo", + requestId: "req-1", + values: { data: REQUESTY_KEY_INFO }, + }) + }) + + it("maps OpenRouter endpoints to model info keyed by provider tag", async () => { + fetchSpy.mockResolvedValue(jsonResponse(ENDPOINTS)) + + await send({ kind: "openRouterModelProviders", modelId: "openai/gpt-4o" }) + + expect(fetchSpy.mock.calls[0]![0]).toBe("https://openrouter.ai/api/v1/models/openai/gpt-4o/endpoints") + const { values } = postMessageToWebview.mock.calls[0]![0] + expect(values.data.openai).toMatchObject({ + label: "openai", + maxTokens: 16384, + contextWindow: 128000, + supportsImages: true, + supportsPromptCache: true, + inputPrice: 5, + outputPrice: 15, + cacheReadsPrice: 2.5, + }) + }) + + it("returns no providers for an image-output model", async () => { + const body = { data: { ...ENDPOINTS.data, architecture: { output_modalities: ["image"] } } } + fetchSpy.mockResolvedValue(jsonResponse(body)) + + await send({ kind: "openRouterModelProviders", modelId: "m" }) + + expect(postMessageToWebview.mock.calls[0]![0].values).toEqual({ data: {} }) + }) + + it("returns null for a schema-invalid key response", async () => { + fetchSpy.mockResolvedValue(jsonResponse({ data: { label: 1 } })) + + await send({ kind: "openRouterKeyInfo", apiKey: "k" }) + + expect(postMessageToWebview.mock.calls[0]![0].values).toEqual({ data: null }) + }) + + it("posts an error without the configured URL when the request fails", async () => { + fetchSpy.mockResolvedValue(jsonResponse({}, 401)) + + await send({ kind: "openRouterKeyInfo", apiKey: "k", baseUrl: "https://secret.example.com" }) + + expect(postMessageToWebview).toHaveBeenCalledWith({ + type: "providerInfo", + requestId: "req-1", + error: "Provider info request failed", + }) + }) + + it.each([undefined, "unknown", "__proto__", "constructor"])("rejects unknown kind %s", async (kind) => { + await send({ kind }) + + expect(fetchSpy).not.toHaveBeenCalled() + expect(postMessageToWebview).toHaveBeenCalledWith({ + type: "providerInfo", + requestId: "req-1", + error: "Unknown provider info kind", + }) + }) +}) diff --git a/src/core/webview/__tests__/webviewCsp.spec.ts b/src/core/webview/__tests__/webviewCsp.spec.ts deleted file mode 100644 index 4ebe92d7b7..0000000000 --- a/src/core/webview/__tests__/webviewCsp.spec.ts +++ /dev/null @@ -1,60 +0,0 @@ -import { describe, expect, it } from "vitest" - -import { escapeCspAttribute, getOpenRouterCspOrigin } from "../webviewCsp" - -describe("getOpenRouterCspOrigin", () => { - it.each([ - [undefined, "https://openrouter.ai"], - ["https://openrouter.ai/api/v1", "https://openrouter.ai"], - // Credentials, paths, queries and fragments are stripped, not echoed. - ["https://user:password@EXAMPLE.com:8443/api/v1?key=secret#fragment", "https://example.com:8443"], - // Local proxy hosts, IPv4 and bracketed IPv6 stay allowed. - ["http://localhost:3000/api/v1", "http://localhost:3000"], - ["http://127.0.0.1:3000/api", "http://127.0.0.1:3000"], - ["http://[::1]:3000/api", "http://[::1]:3000"], - ["http://my_proxy:8080", "http://my_proxy:8080"], - ["https://my-proxy.example.com:8443/api", "https://my-proxy.example.com:8443"], - // CSP delimiters and markup in the path or host fall back to the default. - ['https://example.com/path;script-src?x=">', "https://example.com"], - ["https://example.com;script-src", "https://openrouter.ai"], - [`https://example.com; script-src-elem 'unsafe-inline'">`, "https://openrouter.ai"], - ["https://example.com'", "https://openrouter.ai"], - ['https://example.com">', "https://openrouter.ai"], - // Only the hostname regex rejects this quote; the URL parses without it. - ['https://example.com"', "https://openrouter.ai"], - // The hostname regex rejects wildcard and list syntax that CSP would misread. - ["https://*.example.com", "https://openrouter.ai"], - ["https://a,b.com", "https://openrouter.ai"], - // The protocol allowlist rejects non-http(s) schemes before the host is read. - ["javascript:attack()", "https://openrouter.ai"], - ["file:///tmp/test", "https://openrouter.ai"], - ["ftp://example.com", "https://openrouter.ai"], - ["ws://example.com", "https://openrouter.ai"], - // Malformed input falls back to the default. - ["invalid", "https://openrouter.ai"], - ["", "https://openrouter.ai"], - ])("maps endpoint %s to origin %s", (baseUrl, origin) => { - expect(getOpenRouterCspOrigin(baseUrl)).toBe(origin) - }) -}) - -describe("escapeCspAttribute", () => { - it.each([ - ["&", "&"], - ['"', """], - ["<", "<"], - [">", ">"], - ])("escapes %s as %s", (raw, escaped) => { - expect(escapeCspAttribute(`a ${raw} b`)).toBe(`a ${escaped} b`) - }) - - it("leaves safe characters unchanged", () => { - expect(escapeCspAttribute("default-src 'none'; script-src 'nonce-abc'")).toBe( - "default-src 'none'; script-src 'nonce-abc'", - ) - }) - - it("escapes every character so no raw markup survives", () => { - expect(escapeCspAttribute('&">