summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorKit Langton <[email protected]>2026-04-30 18:49:54 -0400
committerGitHub <[email protected]>2026-04-30 18:49:54 -0400
commite0305e47f32ee6e686bc359c6ff931faab59b2af (patch)
treead098bc427b465c454c6a5c329769da982d78558
parent76a0f0f619d4c66d10b60f685e0641d1880244c8 (diff)
downloadopencode-e0305e47f32ee6e686bc359c6ff931faab59b2af.tar.gz
opencode-e0305e47f32ee6e686bc359c6ff931faab59b2af.zip
Protect HttpApi web UI fallback with auth (#25169)
-rw-r--r--packages/opencode/src/server/routes/instance/httpapi/server.ts19
-rw-r--r--packages/opencode/src/server/routes/ui.ts137
-rw-r--r--packages/opencode/test/server/httpapi-ui.test.ts208
3 files changed, 312 insertions, 52 deletions
diff --git a/packages/opencode/src/server/routes/instance/httpapi/server.ts b/packages/opencode/src/server/routes/instance/httpapi/server.ts
index f62636bca..6e9012696 100644
--- a/packages/opencode/src/server/routes/instance/httpapi/server.ts
+++ b/packages/opencode/src/server/routes/instance/httpapi/server.ts
@@ -1,7 +1,8 @@
import { Context, Effect, Layer } from "effect"
import { HttpApiBuilder } from "effect/unstable/httpapi"
-import { HttpMiddleware, HttpRouter, HttpServer, HttpServerResponse } from "effect/unstable/http"
+import { FetchHttpClient, HttpMiddleware, HttpRouter, HttpServer } from "effect/unstable/http"
import * as Socket from "effect/unstable/socket/Socket"
+import { AppFileSystem } from "@opencode-ai/core/filesystem"
import { Account } from "@/account/account"
import { Agent } from "@/agent/agent"
import { Auth } from "@/auth"
@@ -38,7 +39,7 @@ import { Vcs } from "@/project/vcs"
import { Worktree } from "@/worktree"
import { Workspace } from "@/control-plane/workspace"
import { isAllowedCorsOrigin } from "@/server/cors"
-import { UIRoutes } from "@/server/routes/ui"
+import { serveUIEffect } from "@/server/routes/ui"
import { InstanceHttpApi, RootHttpApi } from "./api"
import { ServerAuthConfig, authorizationLayer, authorizationRouterMiddleware } from "./middleware/authorization"
import { eventRoute } from "./event"
@@ -120,18 +121,10 @@ const instanceRoutes = Layer.mergeAll(rawInstanceRoutes, instanceApiRoutes).pipe
]),
)
-const uiRoutes = lazy(() => UIRoutes())
const uiRoute = HttpRouter.add("*", "/*", (request) =>
- Effect.promise(async () =>
- uiRoutes().fetch(
- request.source instanceof Request
- ? request.source
- : new Request(new URL(request.originalUrl, "http://localhost"), {
- method: request.method,
- headers: request.headers,
- }),
- ),
- ).pipe(Effect.map(HttpServerResponse.fromWeb)),
+ serveUIEffect(request).pipe(Effect.provide(AppFileSystem.defaultLayer), Effect.provide(FetchHttpClient.layer)),
+).pipe(
+ Layer.provide(authorizationRouterMiddleware.layer.pipe(Layer.provide(ServerAuthConfig.defaultLayer))),
)
export const routes = Layer.mergeAll(rootApiRoutes, instanceRoutes, uiRoute).pipe(
diff --git a/packages/opencode/src/server/routes/ui.ts b/packages/opencode/src/server/routes/ui.ts
index 5e47e6bf7..322f63cdd 100644
--- a/packages/opencode/src/server/routes/ui.ts
+++ b/packages/opencode/src/server/routes/ui.ts
@@ -1,9 +1,13 @@
import { Flag } from "@opencode-ai/core/flag/flag"
+import { AppFileSystem } from "@opencode-ai/core/filesystem"
+import { Effect, Stream } from "effect"
+import { HttpBody, HttpClient, HttpClientRequest, HttpServerRequest, HttpServerResponse } from "effect/unstable/http"
import { Hono } from "hono"
import { proxy } from "hono/proxy"
import { getMimeType } from "hono/utils/mime"
import { createHash } from "node:crypto"
import fs from "node:fs/promises"
+import { ProxyUtil } from "../proxy-util"
const embeddedUIPromise = Flag.OPENCODE_DISABLE_EMBEDDED_WEB_UI
? Promise.resolve(null)
@@ -12,44 +16,119 @@ const embeddedUIPromise = Flag.OPENCODE_DISABLE_EMBEDDED_WEB_UI
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 'self' data:"
+const UI_UPSTREAM = new URL("https://app.opencode.ai")
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 'self' data:`
-export const UIRoutes = (): Hono =>
- new Hono().all("/*", async (c) => {
- const embeddedWebUI = await embeddedUIPromise
- const path = c.req.path
+function themePreloadHash(body: string) {
+ return body.match(
+ /<script\b(?![^>]*\bsrc\s*=)[^>]*\bid=(['"])oc-theme-preload-script\1[^>]*>([\s\S]*?)<\/script>/i,
+ )
+}
+
+function requestBody(request: HttpServerRequest.HttpServerRequest) {
+ if (request.method === "GET" || request.method === "HEAD") return HttpBody.empty
+ const len = request.headers["content-length"]
+ return HttpBody.stream(
+ request.stream,
+ request.headers["content-type"],
+ len === undefined ? undefined : Number(len),
+ )
+}
+
+function proxyResponseHeaders(headers: Record<string, string>) {
+ const result = new Headers(headers)
+ // FetchHttpClient exposes decoded response bodies, so forwarding upstream
+ // transfer metadata makes browsers decode already-decoded assets again.
+ result.delete("content-encoding")
+ result.delete("content-length")
+ return result
+}
+
+function upstreamURL(path: string) {
+ return new URL(path, UI_UPSTREAM).toString()
+}
+
+function embeddedUI() {
+ if (Flag.OPENCODE_DISABLE_EMBEDDED_WEB_UI) return Promise.resolve(null)
+ return embeddedUIPromise
+}
+
+export async function serveUI(request: Request) {
+ const embeddedWebUI = await embeddedUI()
+ const path = new URL(request.url).pathname
+
+ if (embeddedWebUI) {
+ const match = embeddedWebUI[path.replace(/^\//, "")] ?? embeddedWebUI["index.html"] ?? null
+ if (!match) return Response.json({ error: "Not Found" }, { status: 404 })
+
+ if (await fs.exists(match)) {
+ const mime = getMimeType(match) ?? "text/plain"
+ 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 })
+ }
+
+ return Response.json({ error: "Not Found" }, { status: 404 })
+ }
+
+ const response = await proxy(upstreamURL(path), {
+ 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))
+ return response
+}
+
+export function serveUIEffect(request: HttpServerRequest.HttpServerRequest) {
+ return Effect.gen(function* () {
+ const embeddedWebUI = yield* Effect.promise(() => embeddedUI())
+ const path = new URL(request.url, "http://localhost").pathname
if (embeddedWebUI) {
const match = embeddedWebUI[path.replace(/^\//, "")] ?? embeddedWebUI["index.html"] ?? null
- if (!match) return c.json({ error: "Not Found" }, 404)
+ if (!match) return HttpServerResponse.jsonUnsafe({ error: "Not Found" }, { status: 404 })
- if (await fs.exists(match)) {
+ const fs = yield* AppFileSystem.Service
+ if (yield* fs.existsSafe(match)) {
const mime = getMimeType(match) ?? "text/plain"
- c.header("Content-Type", mime)
- if (mime.startsWith("text/html")) {
- c.header("Content-Security-Policy", DEFAULT_CSP)
- }
- return c.body(new Uint8Array(await fs.readFile(match)))
- } else {
- return c.json({ error: "Not Found" }, 404)
+ const headers = new Headers({ "content-type": mime })
+ if (mime.startsWith("text/html")) headers.set("content-security-policy", DEFAULT_CSP)
+ return HttpServerResponse.raw(yield* fs.readFile(match), { headers })
}
- } else {
- const response = await proxy(`https://app.opencode.ai${path}`, {
- raw: c.req.raw,
- headers: {
- ...Object.fromEntries(c.req.raw.headers.entries()),
- host: "app.opencode.ai",
- },
- })
- const match = response.headers.get("content-type")?.includes("text/html")
- ? (await response.clone().text()).match(
- /<script\b(?![^>]*\bsrc\s*=)[^>]*\bid=(['"])oc-theme-preload-script\1[^>]*>([\s\S]*?)<\/script>/i,
- )
- : undefined
- const hash = match ? createHash("sha256").update(match[2]).digest("base64") : ""
- response.headers.set("Content-Security-Policy", csp(hash))
- return response
+
+ return HttpServerResponse.jsonUnsafe({ error: "Not Found" }, { status: 404 })
}
+
+ const response = yield* HttpClient.execute(
+ HttpClientRequest.make(request.method)(upstreamURL(path), {
+ headers: ProxyUtil.headers(request.headers, { host: UI_UPSTREAM.host }),
+ body: requestBody(request),
+ }),
+ )
+ const headers = proxyResponseHeaders(response.headers)
+
+ 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") : ""),
+ )
+ return HttpServerResponse.text(body, { status: response.status, headers })
+ }
+
+ headers.set("Content-Security-Policy", csp())
+ return HttpServerResponse.stream(response.stream.pipe(Stream.catchCause(() => Stream.empty)), {
+ status: response.status,
+ headers,
+ })
})
+}
+
+export const UIRoutes = (): Hono => new Hono().all("/*", (c) => serveUI(c.req.raw))
diff --git a/packages/opencode/test/server/httpapi-ui.test.ts b/packages/opencode/test/server/httpapi-ui.test.ts
index 9ca8b49f1..9dd2ea77c 100644
--- a/packages/opencode/test/server/httpapi-ui.test.ts
+++ b/packages/opencode/test/server/httpapi-ui.test.ts
@@ -1,30 +1,130 @@
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"
+import { ConfigProvider, Effect, Layer } from "effect"
+import {
+ HttpClient,
+ HttpClientRequest,
+ HttpClientResponse,
+ HttpRouter,
+ HttpServer,
+ HttpServerRequest,
+ HttpServerResponse,
+} from "effect/unstable/http"
+import { AppFileSystem } from "@opencode-ai/core/filesystem"
+import { ServerAuthConfig, authorizationRouterMiddleware } from "../../src/server/routes/instance/httpapi/middleware/authorization"
+import { ExperimentalHttpApiServer } from "../../src/server/routes/instance/httpapi/server"
+import { serveUIEffect } from "../../src/server/routes/ui"
import { Server } from "../../src/server/server"
void Log.init({ print: false })
const original = {
OPENCODE_EXPERIMENTAL_HTTPAPI: Flag.OPENCODE_EXPERIMENTAL_HTTPAPI,
- fetch: globalThis.fetch,
+ OPENCODE_DISABLE_EMBEDDED_WEB_UI: Flag.OPENCODE_DISABLE_EMBEDDED_WEB_UI,
+ OPENCODE_SERVER_PASSWORD: Flag.OPENCODE_SERVER_PASSWORD,
+ OPENCODE_SERVER_USERNAME: Flag.OPENCODE_SERVER_USERNAME,
+ envPassword: process.env.OPENCODE_SERVER_PASSWORD,
+ envUsername: process.env.OPENCODE_SERVER_USERNAME,
}
afterEach(() => {
Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = original.OPENCODE_EXPERIMENTAL_HTTPAPI
- globalThis.fetch = original.fetch
+ Flag.OPENCODE_DISABLE_EMBEDDED_WEB_UI = original.OPENCODE_DISABLE_EMBEDDED_WEB_UI
+ Flag.OPENCODE_SERVER_PASSWORD = original.OPENCODE_SERVER_PASSWORD
+ Flag.OPENCODE_SERVER_USERNAME = original.OPENCODE_SERVER_USERNAME
+ restoreEnv("OPENCODE_SERVER_PASSWORD", original.envPassword)
+ restoreEnv("OPENCODE_SERVER_USERNAME", original.envUsername)
})
+function restoreEnv(key: string, value: string | undefined) {
+ if (value === undefined) {
+ delete process.env[key]
+ return
+ }
+ process.env[key] = value
+}
+
+function app(input?: { password?: string; username?: string }) {
+ const handler = HttpRouter.toWebHandler(
+ ExperimentalHttpApiServer.routes.pipe(
+ Layer.provide(
+ ConfigProvider.layer(
+ ConfigProvider.fromUnknown({
+ OPENCODE_SERVER_PASSWORD: input?.password,
+ OPENCODE_SERVER_USERNAME: input?.username,
+ }),
+ ),
+ ),
+ ),
+ { disableLogger: true },
+ ).handler
+ return {
+ request(input: string | URL | Request, init?: RequestInit) {
+ return handler(
+ input instanceof Request ? input : new Request(new URL(input, "http://localhost"), init),
+ ExperimentalHttpApiServer.context,
+ )
+ },
+ }
+}
+
+function uiApp(input?: {
+ password?: string
+ username?: string
+ client?: Layer.Layer<HttpClient.HttpClient>
+}) {
+ const handler = HttpRouter.toWebHandler(
+ HttpRouter.add("*", "/*", (request) =>
+ serveUIEffect(request).pipe(
+ Effect.provide(AppFileSystem.defaultLayer),
+ Effect.provide(input?.client ?? httpClient(new Response("ui"))),
+ ),
+ ).pipe(
+ Layer.provide(authorizationRouterMiddleware.layer.pipe(Layer.provide(ServerAuthConfig.defaultLayer))),
+ Layer.provide(HttpServer.layerServices),
+ Layer.provide(
+ ConfigProvider.layer(
+ ConfigProvider.fromUnknown({
+ OPENCODE_SERVER_PASSWORD: input?.password,
+ OPENCODE_SERVER_USERNAME: input?.username,
+ }),
+ ),
+ ),
+ ),
+ { disableLogger: true },
+ ).handler
+ return {
+ request(input: string | URL | Request, init?: RequestInit) {
+ return handler(
+ input instanceof Request ? input : new Request(new URL(input, "http://localhost"), init),
+ ExperimentalHttpApiServer.context,
+ )
+ },
+ }
+}
+
+function httpClient(response: Response, onRequest?: (request: HttpClientRequest.HttpClientRequest) => void) {
+ return Layer.succeed(
+ HttpClient.HttpClient,
+ HttpClient.make((request) => {
+ onRequest?.(request)
+ return Effect.succeed(HttpClientResponse.fromWeb(request, response))
+ }),
+ )
+}
+
describe("HttpApi UI fallback", () => {
test("serves the web UI through the experimental backend", async () => {
Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = true
+ Flag.OPENCODE_DISABLE_EMBEDDED_WEB_UI = true
let proxiedUrl: string | undefined
- globalThis.fetch = ((input: RequestInfo | URL) => {
- proxiedUrl = String(input instanceof Request ? input.url : input)
- return Promise.resolve(new Response("<html>opencode</html>", { headers: { "content-type": "text/html" } }))
- }) as typeof fetch
- const response = await Server.Default().app.request("/")
+ const response = await uiApp({
+ client: httpClient(new Response("<html>opencode</html>", { headers: { "content-type": "text/html" } }), (request) => {
+ proxiedUrl = request.url
+ }),
+ }).request("/")
expect(response.status).toBe(200)
expect(response.headers.get("content-type")).toContain("text/html")
@@ -32,14 +132,102 @@ describe("HttpApi UI fallback", () => {
expect(proxiedUrl).toBe("https://app.opencode.ai/")
})
+ test("strips upstream transfer encoding headers from proxied assets", async () => {
+ Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = true
+ Flag.OPENCODE_DISABLE_EMBEDDED_WEB_UI = true
+ let proxiedUrl: string | undefined
+
+ const response = await Effect.runPromise(
+ serveUIEffect(HttpServerRequest.fromWeb(new Request("http://localhost/assets/app.js"))).pipe(
+ Effect.provide(AppFileSystem.defaultLayer),
+ Effect.provide(
+ Layer.succeed(
+ HttpClient.HttpClient,
+ HttpClient.make((request) => {
+ proxiedUrl = request.url
+ return Effect.succeed(
+ HttpClientResponse.fromWeb(
+ request,
+ new Response("console.log('ok')", {
+ headers: {
+ "content-encoding": "br",
+ "content-length": "999",
+ "content-type": "text/javascript",
+ },
+ }),
+ ),
+ )
+ }),
+ ),
+ ),
+ Effect.map(HttpServerResponse.toWeb),
+ ),
+ )
+
+ expect(response.status).toBe(200)
+ expect(proxiedUrl).toBe("https://app.opencode.ai/assets/app.js")
+ expect(response.headers.get("content-encoding")).toBeNull()
+ expect(response.headers.get("content-length")).not.toBe("999")
+ expect(response.headers.get("content-type")).toContain("text/javascript")
+ expect(await response.text()).toBe("console.log('ok')")
+ })
+
test("keeps matched API routes ahead of the UI fallback", async () => {
Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = true
- globalThis.fetch = (() => {
- throw new Error("UI fallback should not handle matched API routes")
- }) as unknown as typeof fetch
const response = await Server.Default().app.request("/session/nope")
expect(response.status).toBe(404)
})
+
+ test("requires server password for the web UI", async () => {
+ Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = true
+ Flag.OPENCODE_DISABLE_EMBEDDED_WEB_UI = true
+
+ const response = await uiApp({ password: "secret", username: "opencode" }).request("/")
+
+ expect(response.status).toBe(401)
+ })
+
+ test("accepts auth token for the web UI", async () => {
+ Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = true
+ Flag.OPENCODE_DISABLE_EMBEDDED_WEB_UI = true
+
+ const response = await uiApp({
+ password: "secret",
+ username: "opencode",
+ client: httpClient(new Response("<html>opencode</html>", { headers: { "content-type": "text/html" } })),
+ }).request(
+ `/?auth_token=${btoa("opencode:secret")}`,
+ )
+
+ expect(response.status).toBe(200)
+ expect(await response.text()).toBe("<html>opencode</html>")
+ })
+
+ test("accepts basic auth for the web UI", async () => {
+ Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = true
+ Flag.OPENCODE_DISABLE_EMBEDDED_WEB_UI = true
+
+ const response = await uiApp({ password: "secret", username: "opencode" }).request("/", {
+ headers: { authorization: `Basic ${btoa("opencode:secret")}` },
+ })
+
+ expect(response.status).toBe(200)
+ })
+
+ test("allows web UI preflight without auth", async () => {
+ Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = true
+
+ const response = await app({ password: "secret", username: "opencode" }).request("/", {
+ method: "OPTIONS",
+ headers: {
+ origin: "http://localhost:3000",
+ "access-control-request-method": "GET",
+ },
+ })
+
+ expect(response.status).toBe(204)
+ expect(response.headers.get("access-control-allow-origin")).toBe("http://localhost:3000")
+ })
})