Explorar o código

Protect HttpApi web UI fallback with auth (#25169)

Kit Langton hai 3 meses
pai
achega
e0305e47f3

+ 6 - 13
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(

+ 108 - 29
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))

+ 198 - 10
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")
+  })
 })