Skip to content

Commit df1f5df

Browse files
See USee U
authored andcommitted
fix(opencode): escape OAuth callback error pages to close XSS
Removing the shared branded OAuth page renderer (batch-4 deletion) left each callback handler interpolating the provider-supplied `error_description` into inline HTML unescaped — reflected XSS on the loopback callback. Escape the error via the existing @/util/html helper in the codex, mcp, xai, and snowflake-cortex callbacks, and update the mcp test to assert the escaped minimal page.
1 parent ff2ad00 commit df1f5df

5 files changed

Lines changed: 14 additions & 8 deletions

File tree

packages/opencode/src/mcp/oauth-callback.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { createConnection } from "net"
22
import { createServer } from "http"
3+
import { escapeHtml } from "@/util/html"
34
import { OAUTH_CALLBACK_PORT, OAUTH_CALLBACK_PATH, parseRedirectUri } from "./oauth-provider"
45

56
const OAUTH_CALLBACK_HOST = "127.0.0.1"
@@ -56,7 +57,7 @@ function handleRequest(req: import("http").IncomingMessage, res: import("http").
5657
if (!state) {
5758
const errorMsg = "Missing required state parameter - potential CSRF attack"
5859
res.writeHead(400, { "Content-Type": "text/html; charset=utf-8" })
59-
res.end(`<html><body>Authorization failed: ${errorMsg}</body></html>`)
60+
res.end(`<html><body>Authorization failed: ${escapeHtml(errorMsg)}</body></html>`)
6061
return
6162
}
6263

@@ -70,7 +71,7 @@ function handleRequest(req: import("http").IncomingMessage, res: import("http").
7071
pending.reject(new Error(errorMsg))
7172
}
7273
res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" })
73-
res.end(`<html><body>Authorization failed: ${errorMsg}</body></html>`)
74+
res.end(`<html><body>Authorization failed: ${escapeHtml(errorMsg)}</body></html>`)
7475
stopIfIdle()
7576
return
7677
}
@@ -85,7 +86,7 @@ function handleRequest(req: import("http").IncomingMessage, res: import("http").
8586
if (!pendingAuths.has(state)) {
8687
const errorMsg = "Invalid or expired state parameter - potential CSRF attack"
8788
res.writeHead(400, { "Content-Type": "text/html; charset=utf-8" })
88-
res.end(`<html><body>Authorization failed: ${errorMsg}</body></html>`)
89+
res.end(`<html><body>Authorization failed: ${escapeHtml(errorMsg)}</body></html>`)
8990
return
9091
}
9192

packages/opencode/src/plugin/openai/codex.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import type { Hooks, PluginInput } from "@opencode-ai/plugin"
22
import { InstallationVersion } from "@opencode-ai/core/installation/version"
33
import { OAUTH_DUMMY_KEY } from "../../auth"
4+
import { escapeHtml } from "@/util/html"
45
import os from "os"
56
import { setTimeout as sleep } from "node:timers/promises"
67
import { createServer } from "http"
@@ -137,8 +138,10 @@ async function refreshAccessToken(refreshToken: string, issuer = ISSUER): Promis
137138
return response.json()
138139
}
139140

140-
// Kept as a named export for plugin.codex tests; delegates to the shared branded page.
141-
export const renderOAuthError = (error: string) => `<html><body>Authorization failed: ${error}</body></html>`
141+
// Kept as a named export for plugin.codex tests. Escapes the provider-supplied
142+
// error before interpolation so a malicious error_description can't inject markup.
143+
export const renderOAuthError = (error: string) =>
144+
`<html><body>Authorization failed: ${escapeHtml(error)}</body></html>`
142145

143146
interface PendingOAuth {
144147
pkce: PkceCodes

packages/opencode/src/plugin/snowflake-cortex.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import type { Hooks, PluginInput } from "@opencode-ai/plugin"
22
import { OAUTH_DUMMY_KEY } from "../auth"
3+
import { escapeHtml } from "@/util/html"
34
import { InstallationVersion } from "@opencode-ai/core/installation/version"
45
import { createServer } from "http"
56
import open from "open"
@@ -191,7 +192,7 @@ async function startOAuthServer() {
191192
const message = errorDescription || error
192193
current.reject(new Error(message))
193194
res.writeHead(200, { "Content-Type": "text/html" })
194-
res.end(`<html><body>Authorization failed: ${message}</body></html>`)
195+
res.end(`<html><body>Authorization failed: ${escapeHtml(message)}</body></html>`)
195196
return
196197
}
197198

packages/opencode/src/plugin/xai.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import type { Hooks, PluginInput } from "@opencode-ai/plugin"
22
import { OAUTH_DUMMY_KEY } from "../auth"
3+
import { escapeHtml } from "@/util/html"
34
import { createServer } from "http"
45
import { InstallationVersion } from "@opencode-ai/core/installation/version"
56

@@ -334,7 +335,7 @@ async function startOAuthServer(): Promise<{ port: number; redirectUri: string }
334335
pendingOAuth?.reject(new Error(errorMsg))
335336
pendingOAuth = undefined
336337
res.writeHead(200, { "Content-Type": "text/html" })
337-
res.end(`<html><body>Authorization failed: ${errorMsg}</body></html>`)
338+
res.end(`<html><body>Authorization failed: ${escapeHtml(errorMsg)}</body></html>`)
338339
return
339340
}
340341

packages/opencode/test/mcp/oauth-callback.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -101,7 +101,7 @@ describe("McpOAuthCallback.ensureRunning", () => {
101101
`${redirectUri}?state=test&error=access_denied&error_description=${encodeURIComponent("The user denied access")}`,
102102
)
103103

104-
expect(await response.text()).toContain('<pre class="detail" id="oc-detail">The user denied access</pre>')
104+
expect(await response.text()).toContain("Authorization failed: The user denied access")
105105
})
106106

107107
test("binds the callback server to IPv4 loopback", async () => {

0 commit comments

Comments
 (0)