chore(opencode): consolidate escape logic (#32360)
This commit is contained in:
parent
a9a4b2f00f
commit
a774c62eac
7 changed files with 44 additions and 51 deletions
|
|
@ -1,5 +1,6 @@
|
||||||
import { createConnection } from "net"
|
import { createConnection } from "net"
|
||||||
import { createServer } from "http"
|
import { createServer } from "http"
|
||||||
|
import { escapeHtml } from "@/util/html"
|
||||||
import { OAUTH_CALLBACK_PORT, OAUTH_CALLBACK_PATH, parseRedirectUri } from "./oauth-provider"
|
import { OAUTH_CALLBACK_PORT, OAUTH_CALLBACK_PATH, parseRedirectUri } from "./oauth-provider"
|
||||||
|
|
||||||
// Current callback server configuration (may differ from defaults if custom redirectUri is used)
|
// Current callback server configuration (may differ from defaults if custom redirectUri is used)
|
||||||
|
|
@ -26,15 +27,6 @@ const HTML_SUCCESS = `<!DOCTYPE html>
|
||||||
</body>
|
</body>
|
||||||
</html>`
|
</html>`
|
||||||
|
|
||||||
function escapeHtml(value: string) {
|
|
||||||
return value
|
|
||||||
.replaceAll("&", "&")
|
|
||||||
.replaceAll("<", "<")
|
|
||||||
.replaceAll(">", ">")
|
|
||||||
.replaceAll('"', """)
|
|
||||||
.replaceAll("'", "'")
|
|
||||||
}
|
|
||||||
|
|
||||||
const HTML_ERROR = (error: string) => `<!DOCTYPE html>
|
const HTML_ERROR = (error: string) => `<!DOCTYPE html>
|
||||||
<html>
|
<html>
|
||||||
<head>
|
<head>
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ import os from "os"
|
||||||
import { setTimeout as sleep } from "node:timers/promises"
|
import { setTimeout as sleep } from "node:timers/promises"
|
||||||
import { createServer } from "http"
|
import { createServer } from "http"
|
||||||
import { OpenAIWebSocketPool } from "./ws-pool"
|
import { OpenAIWebSocketPool } from "./ws-pool"
|
||||||
|
import { escapeHtml } from "@/util/html"
|
||||||
|
|
||||||
const CLIENT_ID = "app_EMoamEEZ73f0CkXaXp7hrann"
|
const CLIENT_ID = "app_EMoamEEZ73f0CkXaXp7hrann"
|
||||||
const ISSUER = "https://auth.openai.com"
|
const ISSUER = "https://auth.openai.com"
|
||||||
|
|
@ -178,7 +179,7 @@ const HTML_SUCCESS = `<!doctype html>
|
||||||
</body>
|
</body>
|
||||||
</html>`
|
</html>`
|
||||||
|
|
||||||
const HTML_ERROR = (error: string) => `<!doctype html>
|
export const renderOAuthError = (error: string) => `<!doctype html>
|
||||||
<html>
|
<html>
|
||||||
<head>
|
<head>
|
||||||
<title>OpenCode - Codex Authorization Failed</title>
|
<title>OpenCode - Codex Authorization Failed</title>
|
||||||
|
|
@ -221,7 +222,7 @@ const HTML_ERROR = (error: string) => `<!doctype html>
|
||||||
<div class="container">
|
<div class="container">
|
||||||
<h1>Authorization Failed</h1>
|
<h1>Authorization Failed</h1>
|
||||||
<p>An error occurred during authorization.</p>
|
<p>An error occurred during authorization.</p>
|
||||||
<div class="error">${error}</div>
|
<div class="error">${escapeHtml(error)}</div>
|
||||||
</div>
|
</div>
|
||||||
</body>
|
</body>
|
||||||
</html>`
|
</html>`
|
||||||
|
|
@ -254,8 +255,8 @@ async function startOAuthServer(): Promise<{ port: number; redirectUri: string }
|
||||||
const errorMsg = errorDescription || error
|
const errorMsg = errorDescription || error
|
||||||
pendingOAuth?.reject(new Error(errorMsg))
|
pendingOAuth?.reject(new Error(errorMsg))
|
||||||
pendingOAuth = undefined
|
pendingOAuth = undefined
|
||||||
res.writeHead(200, { "Content-Type": "text/html" })
|
res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" })
|
||||||
res.end(HTML_ERROR(errorMsg))
|
res.end(renderOAuthError(errorMsg))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -263,8 +264,8 @@ async function startOAuthServer(): Promise<{ port: number; redirectUri: string }
|
||||||
const errorMsg = "Missing authorization code"
|
const errorMsg = "Missing authorization code"
|
||||||
pendingOAuth?.reject(new Error(errorMsg))
|
pendingOAuth?.reject(new Error(errorMsg))
|
||||||
pendingOAuth = undefined
|
pendingOAuth = undefined
|
||||||
res.writeHead(400, { "Content-Type": "text/html" })
|
res.writeHead(400, { "Content-Type": "text/html; charset=utf-8" })
|
||||||
res.end(HTML_ERROR(errorMsg))
|
res.end(renderOAuthError(errorMsg))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -272,8 +273,8 @@ async function startOAuthServer(): Promise<{ port: number; redirectUri: string }
|
||||||
const errorMsg = "Invalid state - potential CSRF attack"
|
const errorMsg = "Invalid state - potential CSRF attack"
|
||||||
pendingOAuth?.reject(new Error(errorMsg))
|
pendingOAuth?.reject(new Error(errorMsg))
|
||||||
pendingOAuth = undefined
|
pendingOAuth = undefined
|
||||||
res.writeHead(400, { "Content-Type": "text/html" })
|
res.writeHead(400, { "Content-Type": "text/html; charset=utf-8" })
|
||||||
res.end(HTML_ERROR(errorMsg))
|
res.end(renderOAuthError(errorMsg))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -284,7 +285,7 @@ async function startOAuthServer(): Promise<{ port: number; redirectUri: string }
|
||||||
.then((tokens) => current.resolve(tokens))
|
.then((tokens) => current.resolve(tokens))
|
||||||
.catch((err) => current.reject(err))
|
.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)
|
res.end(HTML_SUCCESS)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ import type { Hooks, PluginInput } from "@opencode-ai/plugin"
|
||||||
import { OAUTH_DUMMY_KEY } from "../auth"
|
import { OAUTH_DUMMY_KEY } from "../auth"
|
||||||
import { createServer } from "http"
|
import { createServer } from "http"
|
||||||
import { InstallationVersion } from "@opencode-ai/core/installation/version"
|
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
|
// 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
|
// 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)
|
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 {
|
interface TokenResponse {
|
||||||
access_token: string
|
access_token: string
|
||||||
refresh_token: string
|
refresh_token: string
|
||||||
|
|
|
||||||
8
packages/opencode/src/util/html.ts
Normal file
8
packages/opencode/src/util/html.ts
Normal file
|
|
@ -0,0 +1,8 @@
|
||||||
|
export function escapeHtml(value: string) {
|
||||||
|
return value
|
||||||
|
.replaceAll("&", "&")
|
||||||
|
.replaceAll("<", "<")
|
||||||
|
.replaceAll(">", ">")
|
||||||
|
.replaceAll('"', """)
|
||||||
|
.replaceAll("'", "'")
|
||||||
|
}
|
||||||
|
|
@ -4,6 +4,7 @@ import {
|
||||||
parseJwtClaims,
|
parseJwtClaims,
|
||||||
extractAccountIdFromClaims,
|
extractAccountIdFromClaims,
|
||||||
extractAccountId,
|
extractAccountId,
|
||||||
|
renderOAuthError,
|
||||||
type IdTokenClaims,
|
type IdTokenClaims,
|
||||||
} from "../../src/plugin/openai/codex"
|
} from "../../src/plugin/openai/codex"
|
||||||
|
|
||||||
|
|
@ -14,6 +15,14 @@ function createTestJwt(payload: object): string {
|
||||||
}
|
}
|
||||||
|
|
||||||
describe("plugin.codex", () => {
|
describe("plugin.codex", () => {
|
||||||
|
test("escapes provider errors in callback HTML", () => {
|
||||||
|
const error = `</div><script>alert("xss" & 'more')</script>`
|
||||||
|
const html = renderOAuthError(error)
|
||||||
|
|
||||||
|
expect(html).toContain("</div><script>alert("xss" & 'more')</script>")
|
||||||
|
expect(html).not.toContain(error)
|
||||||
|
})
|
||||||
|
|
||||||
describe("parseJwtClaims", () => {
|
describe("parseJwtClaims", () => {
|
||||||
test("parses valid JWT with claims", () => {
|
test("parses valid JWT with claims", () => {
|
||||||
const payload = { email: "test@example.com", chatgpt_account_id: "acc-123" }
|
const payload = { email: "test@example.com", chatgpt_account_id: "acc-123" }
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,6 @@ import { describe, expect, test } from "bun:test"
|
||||||
import {
|
import {
|
||||||
accessTokenIsExpiring,
|
accessTokenIsExpiring,
|
||||||
buildAuthorizeUrl,
|
buildAuthorizeUrl,
|
||||||
escapeHtml,
|
|
||||||
pollDeviceCodeToken,
|
pollDeviceCodeToken,
|
||||||
requestDeviceCode,
|
requestDeviceCode,
|
||||||
XaiAuthPlugin,
|
XaiAuthPlugin,
|
||||||
|
|
@ -103,19 +102,6 @@ describe("plugin.xai", () => {
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe("escapeHtml", () => {
|
|
||||||
test("escapes HTML metacharacters", () => {
|
|
||||||
expect(escapeHtml(`</div><script>alert(1)</script><div class="x">`)).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", () => {
|
describe("loader", () => {
|
||||||
test("returns no options unless stored auth is OAuth and exposes methods in order", async () => {
|
test("returns no options unless stored auth is OAuth and exposes methods in order", async () => {
|
||||||
const hooks = await XaiAuthPlugin({} as any)
|
const hooks = await XaiAuthPlugin({} as any)
|
||||||
|
|
|
||||||
15
packages/opencode/test/util/html.test.ts
Normal file
15
packages/opencode/test/util/html.test.ts
Normal file
|
|
@ -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(`</div><script>alert(1)</script><div class="x">`)).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("&<")
|
||||||
|
})
|
||||||
|
})
|
||||||
Loading…
Reference in a new issue