fix(server): truncate schema-rejection reason to bound response + log size
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
This commit is contained in:
@@ -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<SchemaErrorMiddleware>()(
|
||||
@@ -14,10 +24,11 @@ export class SchemaErrorMiddleware extends HttpApiMiddleware.Service<SchemaError
|
||||
export const schemaErrorLayer = HttpApiMiddleware.layerSchemaErrorTransform(
|
||||
SchemaErrorMiddleware,
|
||||
(error) => {
|
||||
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 },
|
||||
),
|
||||
)
|
||||
|
||||
@@ -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) =>
|
||||
|
||||
Reference in New Issue
Block a user