Przeglądaj źródła

Fix HttpApi raw route authorization (#25154)

Kit Langton 4 miesięcy temu
rodzic
commit
3250b814ce

+ 18 - 1
packages/opencode/src/server/routes/instance/httpapi/handlers/sync.ts

@@ -13,6 +13,9 @@ import { Effect, Scope } from "effect"
 import { HttpApiBuilder } from "effect/unstable/httpapi"
 import { HttpApiBuilder } from "effect/unstable/httpapi"
 import { InstanceHttpApi } from "../api"
 import { InstanceHttpApi } from "../api"
 import { HistoryPayload, ReplayPayload } from "../groups/sync"
 import { HistoryPayload, ReplayPayload } from "../groups/sync"
+import * as Log from "@opencode-ai/core/util/log"
+
+const log = Log.create({ service: "server.sync" })
 
 
 export const syncHandlers = HttpApiBuilder.group(InstanceHttpApi, "sync", (handlers) =>
 export const syncHandlers = HttpApiBuilder.group(InstanceHttpApi, "sync", (handlers) =>
   Effect.gen(function* () {
   Effect.gen(function* () {
@@ -34,8 +37,22 @@ export const syncHandlers = HttpApiBuilder.group(InstanceHttpApi, "sync", (handl
         type: event.type,
         type: event.type,
         data: { ...event.data },
         data: { ...event.data },
       }))
       }))
+      const source = events[0].aggregateID
+      log.info("sync replay requested", {
+        sessionID: source,
+        events: events.length,
+        first: events[0]?.seq,
+        last: events.at(-1)?.seq,
+        directory: ctx.payload.directory,
+      })
       SyncEvent.replayAll(events)
       SyncEvent.replayAll(events)
-      return { sessionID: events[0].aggregateID }
+      log.info("sync replay complete", {
+        sessionID: source,
+        events: events.length,
+        first: events[0]?.seq,
+        last: events.at(-1)?.seq,
+      })
+      return { sessionID: source }
     })
     })
 
 
     const history = Effect.fn("SyncHttpApi.history")(function* (ctx: { payload: typeof HistoryPayload.Type }) {
     const history = Effect.fn("SyncHttpApi.history")(function* (ctx: { payload: typeof HistoryPayload.Type }) {

+ 60 - 9
packages/opencode/src/server/routes/instance/httpapi/middleware/authorization.ts

@@ -1,14 +1,18 @@
 import { ConfigService } from "@/effect/config-service"
 import { ConfigService } from "@/effect/config-service"
 import { Config, Context, Effect, Encoding, Layer, Option, Redacted } from "effect"
 import { Config, Context, Effect, Encoding, Layer, Option, Redacted } from "effect"
+import { HttpRouter, HttpServerRequest, HttpServerResponse } from "effect/unstable/http"
 import { HttpApiError, HttpApiMiddleware, HttpApiSecurity } from "effect/unstable/httpapi"
 import { HttpApiError, HttpApiMiddleware, HttpApiSecurity } from "effect/unstable/httpapi"
 
 
+const AUTH_TOKEN_QUERY = "auth_token"
+const UNAUTHORIZED = 401
+
 export class Authorization extends HttpApiMiddleware.Service<Authorization>()(
 export class Authorization extends HttpApiMiddleware.Service<Authorization>()(
   "@opencode/ExperimentalHttpApiAuthorization",
   "@opencode/ExperimentalHttpApiAuthorization",
   {
   {
     error: HttpApiError.UnauthorizedNoContent,
     error: HttpApiError.UnauthorizedNoContent,
     security: {
     security: {
       basic: HttpApiSecurity.basic,
       basic: HttpApiSecurity.basic,
-      authToken: HttpApiSecurity.apiKey({ in: "query", key: "auth_token" }),
+      authToken: HttpApiSecurity.apiKey({ in: "query", key: AUTH_TOKEN_QUERY }),
     },
     },
   },
   },
 ) {}
 ) {}
@@ -27,18 +31,27 @@ function validateCredential<A, E, R>(
   config: Context.Service.Shape<typeof ServerAuthConfig>,
   config: Context.Service.Shape<typeof ServerAuthConfig>,
 ) {
 ) {
   return Effect.gen(function* () {
   return Effect.gen(function* () {
-    if (Option.isNone(config.password) || config.password.value === "") return yield* effect
-
-    if (credential.username !== config.username) {
-      return yield* new HttpApiError.Unauthorized({})
-    }
-    if (Redacted.value(credential.password) !== config.password.value) {
-      return yield* new HttpApiError.Unauthorized({})
-    }
+    if (!isAuthRequired(config)) return yield* effect
+    if (!isCredentialAuthorized(credential, config)) return yield* new HttpApiError.Unauthorized({})
     return yield* effect
     return yield* effect
   })
   })
 }
 }
 
 
+function isAuthRequired(config: Context.Service.Shape<typeof ServerAuthConfig>) {
+  return Option.isSome(config.password) && config.password.value !== ""
+}
+
+function isCredentialAuthorized(
+  credential: { readonly username: string; readonly password: Redacted.Redacted },
+  config: Context.Service.Shape<typeof ServerAuthConfig>,
+) {
+  return (
+    Option.isSome(config.password) &&
+    credential.username === config.username &&
+    Redacted.value(credential.password) === config.password.value
+  )
+}
+
 function decodeCredential(input: string) {
 function decodeCredential(input: string) {
   const emptyCredential = {
   const emptyCredential = {
     username: "",
     username: "",
@@ -62,6 +75,44 @@ function decodeCredential(input: string) {
     )
     )
 }
 }
 
 
+function validateRawCredential<A, E, R>(
+  effect: Effect.Effect<A, E, R>,
+  credential: { readonly username: string; readonly password: Redacted.Redacted },
+  config: Context.Service.Shape<typeof ServerAuthConfig>,
+) {
+  if (!isAuthRequired(config)) return effect
+  if (!isCredentialAuthorized(credential, config))
+    return Effect.succeed(HttpServerResponse.empty({ status: UNAUTHORIZED }))
+  return effect
+}
+
+export const authorizationRouterMiddleware = HttpRouter.middleware()(
+  Effect.gen(function* () {
+    const config = yield* ServerAuthConfig
+    if (!isAuthRequired(config)) return (effect) => effect
+
+    return (effect) =>
+      Effect.gen(function* () {
+        const request = yield* HttpServerRequest.HttpServerRequest
+        const match = /^Basic\s+(.+)$/i.exec(request.headers.authorization ?? "")
+        if (match) {
+          return yield* decodeCredential(match[1]).pipe(
+            Effect.flatMap((credential) => validateRawCredential(effect, credential, config)),
+          )
+        }
+
+        const token = new URL(request.url, "http://localhost").searchParams.get(AUTH_TOKEN_QUERY)
+        if (token) {
+          return yield* decodeCredential(token).pipe(
+            Effect.flatMap((credential) => validateRawCredential(effect, credential, config)),
+          )
+        }
+
+        return yield* validateRawCredential(effect, { username: "", password: Redacted.make("") }, config)
+      })
+  }),
+)
+
 export const authorizationLayer = Layer.effect(
 export const authorizationLayer = Layer.effect(
   Authorization,
   Authorization,
   Effect.gen(function* () {
   Effect.gen(function* () {

+ 4 - 3
packages/opencode/src/server/routes/instance/httpapi/server.ts

@@ -38,7 +38,7 @@ import { Worktree } from "@/worktree"
 import { Workspace } from "@/control-plane/workspace"
 import { Workspace } from "@/control-plane/workspace"
 import { isAllowedCorsOrigin } from "@/server/cors"
 import { isAllowedCorsOrigin } from "@/server/cors"
 import { InstanceHttpApi, RootHttpApi } from "./api"
 import { InstanceHttpApi, RootHttpApi } from "./api"
-import { ServerAuthConfig, authorizationLayer } from "./middleware/authorization"
+import { ServerAuthConfig, authorizationLayer, authorizationRouterMiddleware } from "./middleware/authorization"
 import { eventRoute } from "./event"
 import { eventRoute } from "./event"
 import { configHandlers } from "./handlers/config"
 import { configHandlers } from "./handlers/config"
 import { controlHandlers } from "./handlers/control"
 import { controlHandlers } from "./handlers/control"
@@ -104,9 +104,10 @@ const instanceApiRoutes = HttpApiBuilder.layer(InstanceHttpApi).pipe(
 
 
 const rawInstanceRoutes = Layer.mergeAll(eventRoute, ptyConnectRoute).pipe(
 const rawInstanceRoutes = Layer.mergeAll(eventRoute, ptyConnectRoute).pipe(
   Layer.provide(
   Layer.provide(
-    instanceRouterMiddleware
+    authorizationRouterMiddleware
+      .combine(instanceRouterMiddleware)
       .combine(workspaceRouterMiddleware)
       .combine(workspaceRouterMiddleware)
-      .layer.pipe(Layer.provide(Socket.layerWebSocketConstructorGlobal)),
+      .layer.pipe(Layer.provide(Socket.layerWebSocketConstructorGlobal), Layer.provide(ServerAuthConfig.defaultLayer)),
   ),
   ),
 )
 )
 const instanceRoutes = Layer.mergeAll(rawInstanceRoutes, instanceApiRoutes).pipe(
 const instanceRoutes = Layer.mergeAll(rawInstanceRoutes, instanceApiRoutes).pipe(

+ 89 - 0
packages/opencode/test/server/httpapi-raw-route-auth.test.ts

@@ -0,0 +1,89 @@
+import { afterEach, describe, expect, test } from "bun:test"
+import { ConfigProvider, Layer } from "effect"
+import { HttpRouter } from "effect/unstable/http"
+import { Flag } from "@opencode-ai/core/flag/flag"
+import { Instance } from "../../src/project/instance"
+import { EventPaths } from "../../src/server/routes/instance/httpapi/event"
+import { PtyPaths } from "../../src/server/routes/instance/httpapi/groups/pty"
+import { ExperimentalHttpApiServer } from "../../src/server/routes/instance/httpapi/server"
+import { PtyID } from "../../src/pty/schema"
+import { resetDatabase } from "../fixture/db"
+import { tmpdir } from "../fixture/fixture"
+import * as Log from "@opencode-ai/core/util/log"
+
+void Log.init({ print: false })
+
+const originalHttpApi = Flag.OPENCODE_EXPERIMENTAL_HTTPAPI
+
+function app(input: { password?: string; username?: string }) {
+  Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = true
+  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 {
+    fetch: (request: Request) => handler(request, ExperimentalHttpApiServer.context),
+    request(input: string | URL | Request, init?: RequestInit) {
+      return this.fetch(input instanceof Request ? input : new Request(new URL(input, "http://localhost"), init))
+    },
+  }
+}
+
+function basic(username: string, password: string) {
+  return `Basic ${Buffer.from(`${username}:${password}`).toString("base64")}`
+}
+
+async function cancelBody(response: Response) {
+  await response.body?.cancel().catch(() => {})
+}
+
+afterEach(async () => {
+  Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = originalHttpApi
+  await Instance.disposeAll()
+  await resetDatabase()
+})
+
+describe("HttpApi raw route authorization", () => {
+  test("requires configured auth before opening the raw instance event stream", async () => {
+    await using tmp = await tmpdir({ git: true, config: { formatter: false, lsp: false } })
+    const server = app({ password: "secret" })
+    const headers = { "x-opencode-directory": tmp.path }
+
+    const missing = await server.request(EventPaths.event, { headers })
+    await cancelBody(missing)
+    expect(missing.status).toBe(401)
+
+    const authed = await server.request(EventPaths.event, {
+      headers: { ...headers, authorization: basic("opencode", "secret") },
+    })
+    await cancelBody(authed)
+    expect(authed.status).toBe(200)
+  })
+
+  test("requires configured auth before resolving the raw PTY websocket route", async () => {
+    await using tmp = await tmpdir({ git: true, config: { formatter: false, lsp: false } })
+    const server = app({ password: "secret" })
+    const route = PtyPaths.connect.replace(":ptyID", PtyID.ascending())
+    const headers = { "x-opencode-directory": tmp.path }
+
+    const missing = await server.request(route, { headers })
+    await cancelBody(missing)
+    expect(missing.status).toBe(401)
+
+    const authed = await server.request(route, {
+      headers: { ...headers, authorization: basic("opencode", "secret") },
+    })
+    await cancelBody(authed)
+    expect(authed.status).toBe(404)
+  })
+})

+ 5 - 1
packages/opencode/test/server/httpapi-sync.test.ts

@@ -1,4 +1,4 @@
-import { afterEach, describe, expect, test } from "bun:test"
+import { afterEach, describe, expect, mock, spyOn, test } from "bun:test"
 import { Effect } from "effect"
 import { Effect } from "effect"
 import { Flag } from "@opencode-ai/core/flag/flag"
 import { Flag } from "@opencode-ai/core/flag/flag"
 import { Instance } from "../../src/project/instance"
 import { Instance } from "../../src/project/instance"
@@ -24,6 +24,7 @@ function runSession<A, E>(fx: Effect.Effect<A, E, Session.Service>) {
 }
 }
 
 
 afterEach(async () => {
 afterEach(async () => {
+  mock.restore()
   Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = originalHttpApi
   Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = originalHttpApi
   Flag.OPENCODE_EXPERIMENTAL_WORKSPACES = originalWorkspaces
   Flag.OPENCODE_EXPERIMENTAL_WORKSPACES = originalWorkspaces
   await Instance.disposeAll()
   await Instance.disposeAll()
@@ -35,6 +36,7 @@ describe("sync HttpApi", () => {
     Flag.OPENCODE_EXPERIMENTAL_WORKSPACES = true
     Flag.OPENCODE_EXPERIMENTAL_WORKSPACES = true
     await using tmp = await tmpdir({ git: true, config: { formatter: false, lsp: false } })
     await using tmp = await tmpdir({ git: true, config: { formatter: false, lsp: false } })
     const headers = { "x-opencode-directory": tmp.path, "content-type": "application/json" }
     const headers = { "x-opencode-directory": tmp.path, "content-type": "application/json" }
+    const info = spyOn(Log.create({ service: "server.sync" }), "info")
 
 
     const session = await Instance.provide({
     const session = await Instance.provide({
       directory: tmp.path,
       directory: tmp.path,
@@ -78,6 +80,8 @@ describe("sync HttpApi", () => {
     })
     })
     expect(replayed.status).toBe(200)
     expect(replayed.status).toBe(200)
     expect(await replayed.json()).toEqual({ sessionID: session.id })
     expect(await replayed.json()).toEqual({ sessionID: session.id })
+    expect(info.mock.calls.some(([message]) => message === "sync replay requested")).toBe(true)
+    expect(info.mock.calls.some(([message]) => message === "sync replay complete")).toBe(true)
   })
   })
 
 
   test("matches legacy seq validation", async () => {
   test("matches legacy seq validation", async () => {