From 251ce4a2ab7338f2f50cb5f8f02934c83082b3cc Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Wed, 12 Aug 2026 13:14:58 +0000 Subject: [PATCH] fix(opencode): validate upgrade requests --- .../routes/instance/httpapi/groups/global.ts | 9 ++-- .../instance/httpapi/handlers/global.ts | 4 ++ .../test/server/httpapi-global.test.ts | 43 ++++++++++++++++++- 3 files changed, 51 insertions(+), 5 deletions(-) diff --git a/packages/opencode/src/server/routes/instance/httpapi/groups/global.ts b/packages/opencode/src/server/routes/instance/httpapi/groups/global.ts index 61daefe8a2d4..c255d128c0c6 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/groups/global.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/groups/global.ts @@ -5,7 +5,8 @@ import { InstanceDisposed } from "@/server/event" import "@opencode-ai/core/account" import "@/server/event" import { Schema } from "effect" -import { HttpApi, HttpApiEndpoint, HttpApiError, HttpApiGroup, HttpApiSchema, OpenApi } from "effect/unstable/httpapi" +import { HttpApi, HttpApiEndpoint, HttpApiError, HttpApiGroup, OpenApi } from "effect/unstable/httpapi" +import semver from "semver" import { described } from "./metadata" const GlobalHealth = Schema.Struct({ @@ -48,7 +49,9 @@ const GlobalEventSchema = Schema.Struct({ }).annotate({ identifier: "GlobalEvent" }) export const GlobalUpgradeInput = Schema.Struct({ - target: Schema.optional(Schema.String), + target: Schema.optional( + Schema.String.check(Schema.makeFilter((value) => semver.valid(value) !== null || "Expected a semantic version")), + ), }) const GlobalUpgradeResult = Schema.Union([ @@ -121,7 +124,7 @@ export const GlobalApi = HttpApi.make("global").add( }), ), HttpApiEndpoint.post("upgrade", GlobalPaths.upgrade, { - payload: [HttpApiSchema.NoContent, GlobalUpgradeInput], + payload: GlobalUpgradeInput, success: described(GlobalUpgradeResult, "Upgrade result"), error: HttpApiError.BadRequest, }).annotateMerge( diff --git a/packages/opencode/src/server/routes/instance/httpapi/handlers/global.ts b/packages/opencode/src/server/routes/instance/httpapi/handlers/global.ts index c1f588d5a146..997cbf5f19d7 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/handlers/global.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/handlers/global.ts @@ -129,6 +129,10 @@ export const globalHandlers = HttpApiBuilder.group(RootHttpApi, "global", (handl const upgradeRaw = Effect.fn("GlobalHttpApi.upgradeRaw")(function* (ctx: { request: HttpServerRequest.HttpServerRequest }) { + const contentType = ctx.request.headers["content-type"]?.split(";", 1)[0].trim().toLowerCase() + if (contentType !== "application/json") { + return HttpServerResponse.jsonUnsafe({ success: false, error: "Expected application/json" }, { status: 415 }) + } const body = yield* Effect.orDie(ctx.request.text) const json = parseBody(body) if (json === undefined) { diff --git a/packages/opencode/test/server/httpapi-global.test.ts b/packages/opencode/test/server/httpapi-global.test.ts index bcbe7aecbba4..bb97954994ca 100644 --- a/packages/opencode/test/server/httpapi-global.test.ts +++ b/packages/opencode/test/server/httpapi-global.test.ts @@ -43,9 +43,12 @@ const apiLayer = HttpRouter.serve( const it = testEffect(apiLayer) describe("global HttpApi", () => { - it.live("upgrades to latest when the request body is omitted", () => + it.live("upgrades to latest with an empty JSON payload", () => Effect.gen(function* () { - const response = yield* HttpClient.post(GlobalPaths.upgrade) + const response = yield* HttpClientRequest.post(GlobalPaths.upgrade).pipe( + HttpClientRequest.bodyJson({}), + Effect.flatMap(HttpClient.execute), + ) expect(response.status).toBe(200) expect(yield* response.json).toEqual({ success: true, version: "9.9.9" }) @@ -63,4 +66,40 @@ describe("global HttpApi", () => { expect(yield* response.json).toEqual({ success: false, error: "Invalid request body" }) }), ) + + it.live("rejects non-JSON upgrade payloads", () => + Effect.gen(function* () { + const response = yield* HttpClientRequest.post(GlobalPaths.upgrade).pipe( + HttpClientRequest.setBody(HttpBody.text('{"target":"1.2.3"}', "text/plain")), + HttpClient.execute, + ) + + expect(response.status).toBe(415) + expect(yield* response.json).toEqual({ success: false, error: "Expected application/json" }) + }), + ) + + it.live("rejects non-semver upgrade targets", () => + Effect.gen(function* () { + const response = yield* HttpClientRequest.post(GlobalPaths.upgrade).pipe( + HttpClientRequest.bodyJson({ target: "latest" }), + Effect.flatMap(HttpClient.execute), + ) + + expect(response.status).toBe(400) + expect(yield* response.json).toEqual({ success: false, error: "Invalid request body" }) + }), + ) + + it.live("accepts semantic version upgrade targets", () => + Effect.gen(function* () { + const response = yield* HttpClientRequest.post(GlobalPaths.upgrade).pipe( + HttpClientRequest.bodyJson({ target: "1.2.3-beta.1" }), + Effect.flatMap(HttpClient.execute), + ) + + expect(response.status).toBe(200) + expect(yield* response.json).toEqual({ success: true, version: "1.2.3-beta.1" }) + }), + ) })