diff --git a/packages/opencode/src/mcp/oauth-callback.ts b/packages/opencode/src/mcp/oauth-callback.ts index 3696862e9abb..e132cbf02b61 100644 --- a/packages/opencode/src/mcp/oauth-callback.ts +++ b/packages/opencode/src/mcp/oauth-callback.ts @@ -1,5 +1,6 @@ import { createConnection } from "net" import { createServer } from "http" +import { escapeHtml } from "@/util/html" import { OAUTH_CALLBACK_PORT, OAUTH_CALLBACK_PATH, parseRedirectUri } from "./oauth-provider" // Current callback server configuration (may differ from defaults if custom redirectUri is used) @@ -26,15 +27,6 @@ const HTML_SUCCESS = ` ` -function escapeHtml(value: string) { - return value - .replaceAll("&", "&") - .replaceAll("<", "<") - .replaceAll(">", ">") - .replaceAll('"', """) - .replaceAll("'", "'") -} - const HTML_ERROR = (error: string) => ` diff --git a/packages/opencode/src/plugin/openai/codex.ts b/packages/opencode/src/plugin/openai/codex.ts index b0d20b328c2d..93c22ea6af3a 100644 --- a/packages/opencode/src/plugin/openai/codex.ts +++ b/packages/opencode/src/plugin/openai/codex.ts @@ -5,6 +5,7 @@ import os from "os" import { setTimeout as sleep } from "node:timers/promises" import { createServer } from "http" import { OpenAIWebSocketPool } from "./ws-pool" +import { escapeHtml } from "@/util/html" const CLIENT_ID = "app_EMoamEEZ73f0CkXaXp7hrann" const ISSUER = "https://auth.openai.com" @@ -178,7 +179,7 @@ const HTML_SUCCESS = ` ` -const HTML_ERROR = (error: string) => ` +export const renderOAuthError = (error: string) => ` OpenCode - Codex Authorization Failed @@ -221,7 +222,7 @@ const HTML_ERROR = (error: string) => `

Authorization Failed

An error occurred during authorization.

-
${error}
+
${escapeHtml(error)}
` @@ -254,8 +255,8 @@ async function startOAuthServer(): Promise<{ port: number; redirectUri: string } const errorMsg = errorDescription || error pendingOAuth?.reject(new Error(errorMsg)) pendingOAuth = undefined - res.writeHead(200, { "Content-Type": "text/html" }) - res.end(HTML_ERROR(errorMsg)) + res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" }) + res.end(renderOAuthError(errorMsg)) return } @@ -263,8 +264,8 @@ async function startOAuthServer(): Promise<{ port: number; redirectUri: string } const errorMsg = "Missing authorization code" pendingOAuth?.reject(new Error(errorMsg)) pendingOAuth = undefined - res.writeHead(400, { "Content-Type": "text/html" }) - res.end(HTML_ERROR(errorMsg)) + res.writeHead(400, { "Content-Type": "text/html; charset=utf-8" }) + res.end(renderOAuthError(errorMsg)) return } @@ -272,8 +273,8 @@ async function startOAuthServer(): Promise<{ port: number; redirectUri: string } const errorMsg = "Invalid state - potential CSRF attack" pendingOAuth?.reject(new Error(errorMsg)) pendingOAuth = undefined - res.writeHead(400, { "Content-Type": "text/html" }) - res.end(HTML_ERROR(errorMsg)) + res.writeHead(400, { "Content-Type": "text/html; charset=utf-8" }) + res.end(renderOAuthError(errorMsg)) return } @@ -284,7 +285,7 @@ async function startOAuthServer(): Promise<{ port: number; redirectUri: string } .then((tokens) => current.resolve(tokens)) .catch((err) => current.reject(err)) - res.writeHead(200, { "Content-Type": "text/html" }) + res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" }) res.end(HTML_SUCCESS) return } diff --git a/packages/opencode/src/plugin/xai.ts b/packages/opencode/src/plugin/xai.ts index e932396a1f1a..8340794da7d4 100644 --- a/packages/opencode/src/plugin/xai.ts +++ b/packages/opencode/src/plugin/xai.ts @@ -2,6 +2,7 @@ import type { Hooks, PluginInput } from "@opencode-ai/plugin" import { OAUTH_DUMMY_KEY } from "../auth" import { createServer } from "http" import { InstallationVersion } from "@opencode-ai/core/installation/version" +import { escapeHtml } from "@/util/html" // Public Grok-CLI OAuth client. xAI's auth server rejects loopback OAuth from // non-allowlisted clients, so we reuse the Grok-CLI client_id that xAI ships @@ -74,25 +75,6 @@ function generateState(): string { return base64UrlEncode(crypto.getRandomValues(new Uint8Array(32)).buffer) } -export function escapeHtml(value: string): string { - return value.replace(/[&<>"']/g, (char) => { - switch (char) { - case "&": - return "&" - case "<": - return "<" - case ">": - return ">" - case '"': - return """ - case "'": - return "'" - default: - return char - } - }) -} - interface TokenResponse { access_token: string refresh_token: string diff --git a/packages/opencode/src/util/html.ts b/packages/opencode/src/util/html.ts new file mode 100644 index 000000000000..55028613f7b7 --- /dev/null +++ b/packages/opencode/src/util/html.ts @@ -0,0 +1,8 @@ +export function escapeHtml(value: string) { + return value + .replaceAll("&", "&") + .replaceAll("<", "<") + .replaceAll(">", ">") + .replaceAll('"', """) + .replaceAll("'", "'") +} diff --git a/packages/opencode/test/plugin/codex.test.ts b/packages/opencode/test/plugin/codex.test.ts index a375fe4ee10d..7142bb3e20c9 100644 --- a/packages/opencode/test/plugin/codex.test.ts +++ b/packages/opencode/test/plugin/codex.test.ts @@ -4,6 +4,7 @@ import { parseJwtClaims, extractAccountIdFromClaims, extractAccountId, + renderOAuthError, type IdTokenClaims, } from "../../src/plugin/openai/codex" @@ -14,6 +15,14 @@ function createTestJwt(payload: object): string { } describe("plugin.codex", () => { + test("escapes provider errors in callback HTML", () => { + const error = `` + const html = renderOAuthError(error) + + expect(html).toContain("</div><script>alert("xss" & 'more')</script>") + expect(html).not.toContain(error) + }) + describe("parseJwtClaims", () => { test("parses valid JWT with claims", () => { const payload = { email: "test@example.com", chatgpt_account_id: "acc-123" } diff --git a/packages/opencode/test/plugin/xai.test.ts b/packages/opencode/test/plugin/xai.test.ts index 35ff9075aeae..4139471a8bdb 100644 --- a/packages/opencode/test/plugin/xai.test.ts +++ b/packages/opencode/test/plugin/xai.test.ts @@ -2,7 +2,6 @@ import { describe, expect, test } from "bun:test" import { accessTokenIsExpiring, buildAuthorizeUrl, - escapeHtml, pollDeviceCodeToken, requestDeviceCode, XaiAuthPlugin, @@ -103,19 +102,6 @@ describe("plugin.xai", () => { }) }) - describe("escapeHtml", () => { - test("escapes HTML metacharacters", () => { - expect(escapeHtml(`
`)).toBe( - "</div><script>alert(1)</script><div class="x">", - ) - expect(escapeHtml("a & b")).toBe("a & b") - expect(escapeHtml("it's fine")).toBe("it's fine") - expect(escapeHtml("invalid_grant")).toBe("invalid_grant") - expect(escapeHtml("")).toBe("") - expect(escapeHtml("&<")).toBe("&<") - }) - }) - describe("loader", () => { test("returns no options unless stored auth is OAuth and exposes methods in order", async () => { const hooks = await XaiAuthPlugin({} as any) diff --git a/packages/opencode/test/util/html.test.ts b/packages/opencode/test/util/html.test.ts new file mode 100644 index 000000000000..952d5b58e6c7 --- /dev/null +++ b/packages/opencode/test/util/html.test.ts @@ -0,0 +1,15 @@ +import { describe, expect, test } from "bun:test" +import { escapeHtml } from "../../src/util/html" + +describe("escapeHtml", () => { + test("escapes HTML metacharacters", () => { + expect(escapeHtml(`
`)).toBe( + "</div><script>alert(1)</script><div class="x">", + ) + expect(escapeHtml("a & b")).toBe("a & b") + expect(escapeHtml("it's fine")).toBe("it's fine") + expect(escapeHtml("invalid_grant")).toBe("invalid_grant") + expect(escapeHtml("")).toBe("") + expect(escapeHtml("&<")).toBe("&<") + }) +})