From 7df8cd1de761ab73918f88a6124d0b954bb7469a Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Sat, 9 May 2026 23:28:37 -0400 Subject: [PATCH] fix(server): truncate schema-rejection reason to bound response + log size MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Edge-case audit on PR #26631 caught two real exposures: 1. DoS amplification: Effect's Issue formatter recursively dumps the rejected `actual` value with no truncation. A 5K-element invalid array produced a 358 KB 400 response. Cap to 1 KB. 2. Secret echo: a token mis-posted to a typed endpoint (e.g. `{aggregate:"sk-..."}`) was mirrored verbatim in `data.message` AND in the warn log. Same cap mitigates — the field path is preserved, the rejected value is truncated. Adds two regression tests: - Query rejection (was uncovered; reachable in production) - 50 KB invalid payload → response body stays < 2 KB --- .../httpapi/middleware/schema-error.ts | 15 ++++++- .../server/httpapi-schema-error-body.test.ts | 40 +++++++++++++++++++ 2 files changed, 53 insertions(+), 2 deletions(-) diff --git a/packages/opencode/src/server/routes/instance/httpapi/middleware/schema-error.ts b/packages/opencode/src/server/routes/instance/httpapi/middleware/schema-error.ts index e5a4314d0f..2a8b54f322 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/middleware/schema-error.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/middleware/schema-error.ts @@ -5,6 +5,16 @@ import * as Log from "@opencode-ai/core/util/log" const log = Log.create({ service: "server" }) +// Effect's Issue formatter recursively dumps the rejected `actual` value with +// no truncation, so a 5KB invalid array produces a ~360KB string. Cap to keep +// 4xx responses small and avoid mirroring entire request payloads (which may +// contain secrets) into the response body and log file. +const REASON_LIMIT = 1024 +function truncateReason(reason: string) { + if (reason.length <= REASON_LIMIT) return reason + return reason.slice(0, REASON_LIMIT) + `… (${reason.length - REASON_LIMIT} more chars)` +} + // Default Respondable returns an empty 400 body. Match the NamedError shape // used by other 4xx/5xx so the SDK's `wrapClientError` extracts `.data.message`. export class SchemaErrorMiddleware extends HttpApiMiddleware.Service()( @@ -14,10 +24,11 @@ export class SchemaErrorMiddleware extends HttpApiMiddleware.Service { - log.warn("schema rejection", { kind: error.kind, reason: error.cause.message }) + const reason = truncateReason(error.cause.message) + log.warn("schema rejection", { kind: error.kind, reason }) return Effect.succeed( HttpServerResponse.jsonUnsafe( - { name: "BadRequest", data: { message: error.cause.message, kind: error.kind } }, + { name: "BadRequest", data: { message: reason, kind: error.kind } }, { status: 400 }, ), ) diff --git a/packages/opencode/test/server/httpapi-schema-error-body.test.ts b/packages/opencode/test/server/httpapi-schema-error-body.test.ts index c0f0cebd3d..fe6a1caad0 100644 --- a/packages/opencode/test/server/httpapi-schema-error-body.test.ts +++ b/packages/opencode/test/server/httpapi-schema-error-body.test.ts @@ -102,6 +102,46 @@ describe("schema-rejection wire shape", () => { ), ) + it.live( + "Query schema rejection returns NamedError-shaped JSON", + withTmp({ git: true, config: { formatter: false, lsp: false } }, (tmp) => + Effect.gen(function* () { + // /find/file?limit=999999 violates the limit constraint check. + const url = `/find/file?query=foo&limit=999999&directory=${encodeURIComponent(tmp.path)}` + const res = yield* Effect.promise(async () => Server.Default().app.request(url)) + const body = yield* Effect.promise(async () => res.text()) + expect(res.status).toBe(400) + const parsed = JSON.parse(body) + expect(parsed).toMatchObject({ name: "BadRequest", data: { kind: "Query" } }) + }), + ), + ) + + it.live( + "rejected request body never echoes back unbounded — message is capped", + // Defense against DoS-amplification + secret-echo: Effect's Issue formatter + // dumps the rejected `actual` verbatim. A multi-MB invalid array would + // become a multi-MB 400 response and log line. Cap kicks in around 1KB. + withTmp({ git: true, config: { formatter: false, lsp: false } }, (tmp) => + Effect.gen(function* () { + const huge = "X".repeat(50_000) + const res = yield* Effect.promise(async () => + Server.Default().app.request(SyncPaths.history, { + method: "POST", + headers: { "x-opencode-directory": tmp.path, "content-type": "application/json" }, + body: JSON.stringify({ aggregate: huge }), + }), + ) + const body = yield* Effect.promise(async () => res.text()) + expect(res.status).toBe(400) + // 1 KB cap + small JSON envelope ≈ <2 KB — never tens of KB. + expect(body.length).toBeLessThan(2 * 1024) + const parsed = JSON.parse(body) + expect(parsed.data.message).not.toContain(huge) + }), + ), + ) + it.live( "response-encode failure: corrupted stored row returns NamedError-shaped JSON with field path", withTmp({ config: { formatter: false, lsp: false } }, (tmp) =>