Explorar el Código

chore(opencode): consolidate escape logic (#32360)

Aiden Cline hace 2 meses
padre
commit
a774c62eac

+ 1 - 9
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 = `<!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>

+ 10 - 9
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 = `<!doctype html>
   </body>
 </html>`
 
-const HTML_ERROR = (error: string) => `<!doctype html>
+export const renderOAuthError = (error: string) => `<!doctype html>
 <html>
   <head>
     <title>OpenCode - Codex Authorization Failed</title>
@@ -221,7 +222,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>`
@@ -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
     }

+ 1 - 19
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 "&amp;"
-      case "<":
-        return "&lt;"
-      case ">":
-        return "&gt;"
-      case '"':
-        return "&quot;"
-      case "'":
-        return "&#39;"
-      default:
-        return char
-    }
-  })
-}
-
 interface TokenResponse {
   access_token: string
   refresh_token: string

+ 8 - 0
packages/opencode/src/util/html.ts

@@ -0,0 +1,8 @@
+export function escapeHtml(value: string) {
+  return value
+    .replaceAll("&", "&amp;")
+    .replaceAll("<", "&lt;")
+    .replaceAll(">", "&gt;")
+    .replaceAll('"', "&quot;")
+    .replaceAll("'", "&#39;")
+}

+ 9 - 0
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 = `</div><script>alert("xss" & 'more')</script>`
+    const html = renderOAuthError(error)
+
+    expect(html).toContain("&lt;/div&gt;&lt;script&gt;alert(&quot;xss&quot; &amp; &#39;more&#39;)&lt;/script&gt;")
+    expect(html).not.toContain(error)
+  })
+
   describe("parseJwtClaims", () => {
     test("parses valid JWT with claims", () => {
       const payload = { email: "test@example.com", chatgpt_account_id: "acc-123" }

+ 0 - 14
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(`</div><script>alert(1)</script><div class="x">`)).toBe(
-        "&lt;/div&gt;&lt;script&gt;alert(1)&lt;/script&gt;&lt;div class=&quot;x&quot;&gt;",
-      )
-      expect(escapeHtml("a & b")).toBe("a &amp; b")
-      expect(escapeHtml("it's fine")).toBe("it&#39;s fine")
-      expect(escapeHtml("invalid_grant")).toBe("invalid_grant")
-      expect(escapeHtml("")).toBe("")
-      expect(escapeHtml("&<")).toBe("&amp;&lt;")
-    })
-  })
-
   describe("loader", () => {
     test("returns no options unless stored auth is OAuth and exposes methods in order", async () => {
       const hooks = await XaiAuthPlugin({} as any)

+ 15 - 0
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(`</div><script>alert(1)</script><div class="x">`)).toBe(
+      "&lt;/div&gt;&lt;script&gt;alert(1)&lt;/script&gt;&lt;div class=&quot;x&quot;&gt;",
+    )
+    expect(escapeHtml("a & b")).toBe("a &amp; b")
+    expect(escapeHtml("it's fine")).toBe("it&#39;s fine")
+    expect(escapeHtml("invalid_grant")).toBe("invalid_grant")
+    expect(escapeHtml("")).toBe("")
+    expect(escapeHtml("&<")).toBe("&amp;&lt;")
+  })
+})