From 3250b814ce8a3523898d52fa68ee0e5e7ddb129f Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Thu, 30 Apr 2026 15:55:20 -0400 Subject: [PATCH] Fix HttpApi raw route authorization (#25154) --- .../routes/instance/httpapi/handlers/sync.ts | 19 +++- .../httpapi/middleware/authorization.ts | 69 ++++++++++++-- .../server/routes/instance/httpapi/server.ts | 7 +- .../server/httpapi-raw-route-auth.test.ts | 89 +++++++++++++++++++ .../opencode/test/server/httpapi-sync.test.ts | 6 +- 5 files changed, 176 insertions(+), 14 deletions(-) create mode 100644 packages/opencode/test/server/httpapi-raw-route-auth.test.ts diff --git a/packages/opencode/src/server/routes/instance/httpapi/handlers/sync.ts b/packages/opencode/src/server/routes/instance/httpapi/handlers/sync.ts index 2ff4177f31..fbe1249939 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/handlers/sync.ts +++ b/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 { InstanceHttpApi } from "../api" 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) => Effect.gen(function* () { @@ -34,8 +37,22 @@ export const syncHandlers = HttpApiBuilder.group(InstanceHttpApi, "sync", (handl type: event.type, 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) - 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 }) { diff --git a/packages/opencode/src/server/routes/instance/httpapi/middleware/authorization.ts b/packages/opencode/src/server/routes/instance/httpapi/middleware/authorization.ts index b246140a00..e022a568ac 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/middleware/authorization.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/middleware/authorization.ts @@ -1,14 +1,18 @@ import { ConfigService } from "@/effect/config-service" 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" +const AUTH_TOKEN_QUERY = "auth_token" +const UNAUTHORIZED = 401 + export class Authorization extends HttpApiMiddleware.Service()( "@opencode/ExperimentalHttpApiAuthorization", { error: HttpApiError.UnauthorizedNoContent, security: { 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( config: Context.Service.Shape, ) { 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 }) } +function isAuthRequired(config: Context.Service.Shape) { + return Option.isSome(config.password) && config.password.value !== "" +} + +function isCredentialAuthorized( + credential: { readonly username: string; readonly password: Redacted.Redacted }, + config: Context.Service.Shape, +) { + return ( + Option.isSome(config.password) && + credential.username === config.username && + Redacted.value(credential.password) === config.password.value + ) +} + function decodeCredential(input: string) { const emptyCredential = { username: "", @@ -62,6 +75,44 @@ function decodeCredential(input: string) { ) } +function validateRawCredential( + effect: Effect.Effect, + credential: { readonly username: string; readonly password: Redacted.Redacted }, + config: Context.Service.Shape, +) { + 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( Authorization, Effect.gen(function* () { diff --git a/packages/opencode/src/server/routes/instance/httpapi/server.ts b/packages/opencode/src/server/routes/instance/httpapi/server.ts index caca845be3..62fa18743a 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/server.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/server.ts @@ -38,7 +38,7 @@ import { Worktree } from "@/worktree" import { Workspace } from "@/control-plane/workspace" import { isAllowedCorsOrigin } from "@/server/cors" import { InstanceHttpApi, RootHttpApi } from "./api" -import { ServerAuthConfig, authorizationLayer } from "./middleware/authorization" +import { ServerAuthConfig, authorizationLayer, authorizationRouterMiddleware } from "./middleware/authorization" import { eventRoute } from "./event" import { configHandlers } from "./handlers/config" import { controlHandlers } from "./handlers/control" @@ -104,9 +104,10 @@ const instanceApiRoutes = HttpApiBuilder.layer(InstanceHttpApi).pipe( const rawInstanceRoutes = Layer.mergeAll(eventRoute, ptyConnectRoute).pipe( Layer.provide( - instanceRouterMiddleware + authorizationRouterMiddleware + .combine(instanceRouterMiddleware) .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( diff --git a/packages/opencode/test/server/httpapi-raw-route-auth.test.ts b/packages/opencode/test/server/httpapi-raw-route-auth.test.ts new file mode 100644 index 0000000000..af373d933b --- /dev/null +++ b/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) + }) +}) diff --git a/packages/opencode/test/server/httpapi-sync.test.ts b/packages/opencode/test/server/httpapi-sync.test.ts index 5fa6784a13..f51a714575 100644 --- a/packages/opencode/test/server/httpapi-sync.test.ts +++ b/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 { Flag } from "@opencode-ai/core/flag/flag" import { Instance } from "../../src/project/instance" @@ -24,6 +24,7 @@ function runSession(fx: Effect.Effect) { } afterEach(async () => { + mock.restore() Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = originalHttpApi Flag.OPENCODE_EXPERIMENTAL_WORKSPACES = originalWorkspaces await Instance.disposeAll() @@ -35,6 +36,7 @@ describe("sync HttpApi", () => { Flag.OPENCODE_EXPERIMENTAL_WORKSPACES = true await using tmp = await tmpdir({ git: true, config: { formatter: false, lsp: false } }) 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({ directory: tmp.path, @@ -78,6 +80,8 @@ describe("sync HttpApi", () => { }) expect(replayed.status).toBe(200) 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 () => {