Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
91 changes: 91 additions & 0 deletions src/malformed-json.test.ts
Original file line number Diff line number Diff line change
@@ -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");
});
});
2 changes: 1 addition & 1 deletion src/middleware/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}

/**
Expand Down
29 changes: 29 additions & 0 deletions src/routes/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*/
Expand All @@ -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" &&
Expand Down
Loading