Fix HttpApi raw route authorization (#25154)

This commit is contained in:
Kit Langton
2026-04-30 15:55:20 -04:00
committed by GitHub
parent 0e9d9282c6
commit 3250b814ce
5 changed files with 176 additions and 14 deletions

View File

@@ -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 }) {

View File

@@ -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 (!isAuthRequired(config)) return yield* effect
if (!isCredentialAuthorized(credential, config)) return yield* new HttpApiError.Unauthorized({})
if (credential.username !== config.username) {
return yield* new HttpApiError.Unauthorized({})
}
if (Redacted.value(credential.password) !== config.password.value) {
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* () {

View File

@@ -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(

View File

@@ -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)
})
})

View File

@@ -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 () => {