From 92850abe169adfce065500499d4ed3fffd3c26a1 Mon Sep 17 00:00:00 2001 From: CleanDev-Fix <219162456+CleanDev-Fix@users.noreply.github.com> Date: Wed, 24 Jun 2026 23:27:52 -0400 Subject: [PATCH] fix: return 400 for malformed json bodies --- README.md | 6 +++ src/malformed-json.test.ts | 91 ++++++++++++++++++++++++++++++++++++++ src/middleware/index.ts | 2 +- src/routes/errors.ts | 29 ++++++++++++ 4 files changed, 127 insertions(+), 1 deletion(-) create mode 100644 src/malformed-json.test.ts diff --git a/README.md b/README.md index fa721c7..bdb876d 100644 --- a/README.md +++ b/README.md @@ -68,6 +68,12 @@ agentpay-backend/ | `npm run dev` | Run with ts-node | | `npm start` | Run production build | +## Error responses + +Malformed JSON request bodies return `400 invalid_request` with a stable +`Malformed JSON request body` message and the request id. Parser internals, +request body snippets, and stack traces are not included in the response. + ## CI/CD On push/PR to `main`, GitHub Actions runs: diff --git a/src/malformed-json.test.ts b/src/malformed-json.test.ts new file mode 100644 index 0000000..f27faac --- /dev/null +++ b/src/malformed-json.test.ts @@ -0,0 +1,91 @@ +import { describe, it } from "node:test"; +import assert from "node:assert"; +import express, { type NextFunction, type Request, type Response } from "express"; +import request from "supertest"; +import { createApp } from "./index.js"; +import { installErrorHandlers } from "./routes/errors.js"; +import type { AgentPayRequest } from "./types.js"; + +const malformedJsonMessage = "Malformed JSON request body"; + +function assertMalformedJsonResponse( + res: request.Response, + leakedFragment: string +): void { + assert.strictEqual(res.status, 400); + assert.strictEqual(res.body.error, "invalid_request"); + assert.strictEqual(res.body.message, malformedJsonMessage); + assert.ok(res.body.requestId); + assert.strictEqual(res.body.method, undefined); + assert.strictEqual(res.body.path, undefined); + assert.ok(!JSON.stringify(res.body).includes(leakedFragment)); + assert.ok(!JSON.stringify(res.body).includes("SyntaxError")); +} + +void describe("malformed JSON handling", () => { + for (const [label, body, leakedFragment] of [ + ["truncated JSON", '{"agent":', '{"agent":'], + ["trailing comma", '{"agent":"agent-a",}', "agent-a"], + ["plain text with JSON content type", "not-json-body", "not-json-body"], + ] as const) { + void it(`returns a stable 400 for ${label}`, async () => { + const app = createApp(); + + const res = await request(app) + .post("/api/v1/usage") + .set("Content-Type", "application/json") + .send(body); + + assertMalformedJsonResponse(res, leakedFragment); + }); + } + + void it("continues to accept valid JSON bodies", async () => { + const app = createApp(); + + const res = await request(app).post("/api/v1/usage").send({ + agent: "agent-json-ok", + serviceId: "svc-json-ok", + requests: 3, + }); + + assert.strictEqual(res.status, 201); + assert.deepStrictEqual(res.body, { + agent: "agent-json-ok", + serviceId: "svc-json-ok", + total: 3, + }); + }); + + void it("keeps oversized JSON bodies mapped to 413", async () => { + const app = createApp(); + + const res = await request(app) + .post("/api/v1/usage") + .send({ value: "x".repeat(101 * 1024) }); + + assert.strictEqual(res.status, 413); + assert.strictEqual(res.body.error, "payload_too_large"); + assert.strictEqual(res.body.message, "request body exceeds the 100 KiB limit"); + assert.ok(res.body.requestId); + }); + + void it("keeps genuine server errors mapped to 500", async () => { + const app = express(); + app.use((req: Request, _res: Response, next: NextFunction) => { + (req as AgentPayRequest).id = "test-request-id"; + next(); + }); + app.get("/boom", () => { + throw new Error("database unavailable"); + }); + installErrorHandlers(app); + + const res = await request(app).get("/boom"); + + assert.strictEqual(res.status, 500); + assert.strictEqual(res.body.error, "internal_error"); + assert.strictEqual(res.body.message, "database unavailable"); + assert.strictEqual(res.body.requestId, "test-request-id"); + }); +}); diff --git a/src/middleware/index.ts b/src/middleware/index.ts index 53859a7..2fe6e9d 100644 --- a/src/middleware/index.ts +++ b/src/middleware/index.ts @@ -20,9 +20,9 @@ import type { AgentPayRequest } from "../types.js"; */ export function installPreRouteMiddleware(app: Application): void { app.use(createCorsMiddleware()); + app.use(requestIdMiddleware); app.use(express.json({ limit: "100kb" })); app.use(securityHeadersMiddleware); - app.use(requestIdMiddleware); } /** diff --git a/src/routes/errors.ts b/src/routes/errors.ts index fd76332..8d02fc5 100644 --- a/src/routes/errors.ts +++ b/src/routes/errors.ts @@ -6,6 +6,22 @@ import { } from "express"; import { getRequestId } from "../types.js"; +type BodyParserError = Error & { + type?: string; + status?: number; + statusCode?: number; +}; + +function isBodyParserError(err: unknown): err is BodyParserError { + if (!(err instanceof Error)) return false; + const candidate = err as BodyParserError; + return ( + candidate.type === "entity.parse.failed" || + (err instanceof SyntaxError && + (candidate.status === 400 || candidate.statusCode === 400)) + ); +} + /** * Installs the terminal 404 and error handlers after all route modules. */ @@ -18,7 +34,20 @@ export function installErrorHandlers(app: Application): void { }); }); + /** + * Converts body-parser failures into stable client errors before falling back + * to the generic server-error envelope. + */ app.use((err: unknown, req: Request, res: Response, _next: NextFunction) => { + if (isBodyParserError(err)) { + res.status(400).json({ + error: "invalid_request", + message: "Malformed JSON request body", + requestId: getRequestId(req), + }); + return; + } + if ( err && typeof err === "object" &&