mirror of
https://github.com/anomalyco/opencode.git
synced 2026-05-07 09:11:42 +00:00
fix(server): restore web terminal CSP allowances (#25937)
This commit is contained in:
parent
1fbc13a1b4
commit
e117397d0f
3 changed files with 54 additions and 15 deletions
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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<string, string>).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(/<script\b(?![^>]*\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 })
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
`<html><head><script id="oc-theme-preload-script">${script}</script></head></html>`,
|
||||
),
|
||||
)
|
||||
: 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
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue