From e117397d0ff6c6e529c5cb6b160de0df8bda25c7 Mon Sep 17 00:00:00 2001 From: Luke Parker <10430890+Hona@users.noreply.github.com> Date: Wed, 6 May 2026 09:32:51 +1000 Subject: [PATCH] fix(server): restore web terminal CSP allowances (#25937) --- packages/opencode/src/server/routes/ui.ts | 19 ++++++----- packages/opencode/src/server/shared/ui.ts | 17 ++++++---- .../opencode/test/server/httpapi-ui.test.ts | 33 +++++++++++++++++++ 3 files changed, 54 insertions(+), 15 deletions(-) diff --git a/packages/opencode/src/server/routes/ui.ts b/packages/opencode/src/server/routes/ui.ts index ce06b2b35e..608525b63a 100644 --- a/packages/opencode/src/server/routes/ui.ts +++ b/packages/opencode/src/server/routes/ui.ts @@ -1,10 +1,9 @@ import fs from "node:fs/promises" -import { createHash } from "node:crypto" import { AppFileSystem } from "@opencode-ai/core/filesystem" import { Hono } from "hono" import { proxy } from "hono/proxy" import { ProxyUtil } from "../proxy-util" -import { DEFAULT_CSP, UI_UPSTREAM, csp, embeddedUI, themePreloadHash, upstreamURL } from "../shared/ui" +import { UI_UPSTREAM, csp, cspForHtml, embeddedUI, upstreamURL } from "../shared/ui" export async function serveUI(request: Request) { const embeddedWebUI = await embeddedUI() @@ -17,8 +16,11 @@ export async function serveUI(request: Request) { if (await fs.exists(match)) { const mime = AppFileSystem.mimeType(match) const headers = new Headers({ "content-type": mime }) - if (mime.startsWith("text/html")) headers.set("content-security-policy", DEFAULT_CSP) - return new Response(new Uint8Array(await fs.readFile(match)), { headers }) + const body = new Uint8Array(await fs.readFile(match)) + if (mime.startsWith("text/html")) { + headers.set("content-security-policy", cspForHtml(new TextDecoder().decode(body))) + } + return new Response(body, { headers }) } return Response.json({ error: "Not Found" }, { status: 404 }) @@ -28,11 +30,10 @@ export async function serveUI(request: Request) { raw: request, headers: ProxyUtil.headers(request, { host: UI_UPSTREAM.host }), }) - const match = response.headers.get("content-type")?.includes("text/html") - ? themePreloadHash(await response.clone().text()) - : undefined - const hash = match ? createHash("sha256").update(match[2]).digest("base64") : "" - response.headers.set("Content-Security-Policy", csp(hash)) + response.headers.set( + "Content-Security-Policy", + response.headers.get("content-type")?.includes("text/html") ? cspForHtml(await response.clone().text()) : csp(), + ) return response } diff --git a/packages/opencode/src/server/shared/ui.ts b/packages/opencode/src/server/shared/ui.ts index 0328663da5..0e27dcf220 100644 --- a/packages/opencode/src/server/shared/ui.ts +++ b/packages/opencode/src/server/shared/ui.ts @@ -10,17 +10,21 @@ const embeddedUIPromise = Flag.OPENCODE_DISABLE_EMBEDDED_WEB_UI : // @ts-expect-error - generated file at build time import("opencode-web-ui.gen.ts").then((module) => module.default as Record).catch(() => null) -export const DEFAULT_CSP = - "default-src 'self'; script-src 'self' 'wasm-unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self' data:; media-src 'self' data:; connect-src *" export const UI_UPSTREAM = new URL("https://app.opencode.ai") export const csp = (hash = "") => - `default-src 'self'; script-src 'self' 'wasm-unsafe-eval'${hash ? ` 'sha256-${hash}'` : ""}; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self' data:; media-src 'self' data:; connect-src *` + `default-src 'self'; script-src 'self' 'wasm-unsafe-eval'${hash ? ` 'sha256-${hash}'` : ""}; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self' data:; media-src 'self' data:; connect-src * data:` +export const DEFAULT_CSP = csp() export function themePreloadHash(body: string) { return body.match(/]*\bsrc\s*=)[^>]*\bid=(['"])oc-theme-preload-script\1[^>]*>([\s\S]*?)<\/script>/i) } +export function cspForHtml(body: string) { + const match = themePreloadHash(body) + return csp(match ? createHash("sha256").update(match[2]).digest("base64") : "") +} + function requestBody(request: HttpServerRequest.HttpServerRequest) { if (request.method === "GET" || request.method === "HEAD") return HttpBody.empty const len = request.headers["content-length"] @@ -53,7 +57,9 @@ function notFound() { function embeddedUIResponse(file: string, body: Uint8Array) { const mime = AppFileSystem.mimeType(file) const headers = new Headers({ "content-type": mime }) - if (mime.startsWith("text/html")) headers.set("content-security-policy", DEFAULT_CSP) + if (mime.startsWith("text/html")) { + headers.set("content-security-policy", cspForHtml(new TextDecoder().decode(body))) + } return HttpServerResponse.raw(body, { headers }) } @@ -91,8 +97,7 @@ export function serveUIEffect( if (response.headers["content-type"]?.includes("text/html")) { const body = yield* response.text - const match = themePreloadHash(body) - headers.set("Content-Security-Policy", csp(match ? createHash("sha256").update(match[2]).digest("base64") : "")) + headers.set("Content-Security-Policy", cspForHtml(body)) return HttpServerResponse.text(body, { status: response.status, headers }) } diff --git a/packages/opencode/test/server/httpapi-ui.test.ts b/packages/opencode/test/server/httpapi-ui.test.ts index 85162f6a92..440aeaecb5 100644 --- a/packages/opencode/test/server/httpapi-ui.test.ts +++ b/packages/opencode/test/server/httpapi-ui.test.ts @@ -1,3 +1,4 @@ +import { createHash } from "node:crypto" import { afterEach, describe, expect, test } from "bun:test" import { Flag } from "@opencode-ai/core/flag/flag" import * as Log from "@opencode-ai/core/util/log" @@ -260,6 +261,38 @@ describe("HttpApi UI fallback", () => { expect(await response.text()).toBe("console.log('embedded')") }) + test("allows embedded UI terminal wasm and theme preload CSP", async () => { + Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = true + const script = 'document.documentElement.dataset.theme = "dark"' + + const response = await Effect.runPromise( + Effect.gen(function* () { + const fs = yield* AppFileSystem.Service + return yield* serveEmbeddedUIEffect( + "/", + { + ...fs, + readFile: (path) => { + return path === "/$bunfs/root/index.html" + ? Effect.succeed( + new TextEncoder().encode( + ``, + ), + ) + : Effect.die(`unexpected embedded UI path: ${path}`) + }, + }, + { "index.html": "/$bunfs/root/index.html" }, + ) + }).pipe(Effect.provide(AppFileSystem.defaultLayer), Effect.map(HttpServerResponse.toWeb)), + ) + + const csp = response.headers.get("content-security-policy") ?? "" + expect(csp).toContain("script-src 'self' 'wasm-unsafe-eval'") + expect(csp).toContain(`'sha256-${createHash("sha256").update(script).digest("base64")}'`) + expect(csp).toContain("connect-src * data:") + }) + test("keeps matched API routes ahead of the UI fallback", async () => { Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = true