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
21 changes: 15 additions & 6 deletions packages/opencode/src/mcp/oauth-callback.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,15 @@ const HTML_SUCCESS = `<!DOCTYPE html>
</body>
</html>`

function escapeHtml(value: string) {
return value
.replaceAll("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;")
.replaceAll("'", "&#39;")
}

const HTML_ERROR = (error: string) => `<!DOCTYPE html>
<html>
<head>
Expand All @@ -42,7 +51,7 @@ const HTML_ERROR = (error: string) => `<!DOCTYPE html>
<div class="container">
<h1>Authorization Failed</h1>
<p>An error occurred during authorization.</p>
<div class="error">${error}</div>
<div class="error">${escapeHtml(error)}</div>
</div>
</body>
</html>`
Expand Down Expand Up @@ -87,7 +96,7 @@ function handleRequest(req: import("http").IncomingMessage, res: import("http").
// Enforce state parameter presence
if (!state) {
const errorMsg = "Missing required state parameter - potential CSRF attack"
res.writeHead(400, { "Content-Type": "text/html" })
res.writeHead(400, { "Content-Type": "text/html; charset=utf-8" })
res.end(HTML_ERROR(errorMsg))
return
}
Expand All @@ -101,21 +110,21 @@ function handleRequest(req: import("http").IncomingMessage, res: import("http").
cleanupStateIndex(state)
pending.reject(new Error(errorMsg))
}
res.writeHead(200, { "Content-Type": "text/html" })
res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" })
res.end(HTML_ERROR(errorMsg))
return
}

if (!code) {
res.writeHead(400, { "Content-Type": "text/html" })
res.writeHead(400, { "Content-Type": "text/html; charset=utf-8" })
res.end(HTML_ERROR("No authorization code provided"))
return
}

// Validate state parameter
if (!pendingAuths.has(state)) {
const errorMsg = "Invalid or expired state parameter - potential CSRF attack"
res.writeHead(400, { "Content-Type": "text/html" })
res.writeHead(400, { "Content-Type": "text/html; charset=utf-8" })
res.end(HTML_ERROR(errorMsg))
return
}
Expand All @@ -127,7 +136,7 @@ function handleRequest(req: import("http").IncomingMessage, res: import("http").
cleanupStateIndex(state)
pending.resolve(code)

res.writeHead(200, { "Content-Type": "text/html" })
res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" })
res.end(HTML_SUCCESS)
}

Expand Down
26 changes: 26 additions & 0 deletions packages/opencode/test/mcp/oauth-callback.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,4 +31,30 @@ describe("McpOAuthCallback.ensureRunning", () => {
await McpOAuthCallback.ensureRunning("http://127.0.0.1:18000/custom/callback")
expect(McpOAuthCallback.isRunning()).toBe(true)
})

test("escapes provider error markup in callback HTML", async () => {
const redirectUri = "http://127.0.0.1:18001/custom/callback"
await McpOAuthCallback.ensureRunning(redirectUri)

const error = `<script>alert("xss" & 'more')</script>`
const response = await fetch(
`${redirectUri}?state=test&error=access_denied&error_description=${encodeURIComponent(error)}`,
)
const body = await response.text()

expect(response.headers.get("content-type")).toBe("text/html; charset=utf-8")
expect(body).toContain("&lt;script&gt;alert(&quot;xss&quot; &amp; &#39;more&#39;)&lt;/script&gt;")
expect(body).not.toContain(error)
})

test("keeps normal provider errors readable", async () => {
const redirectUri = "http://127.0.0.1:18002/custom/callback"
await McpOAuthCallback.ensureRunning(redirectUri)

const response = await fetch(
`${redirectUri}?state=test&error=access_denied&error_description=${encodeURIComponent("The user denied access")}`,
)

expect(await response.text()).toContain('<div class="error">The user denied access</div>')
})
})
Loading