diff --git a/services/appraisal-api/README.md b/services/appraisal-api/README.md index 9ad02d3..0385a6b 100644 --- a/services/appraisal-api/README.md +++ b/services/appraisal-api/README.md @@ -67,6 +67,10 @@ The other endpoints are free (used for healthchecks / discovery): | GET | `/` | Discovery: price, asset, network | | GET | `/healthz` | Health check | +A `POST /appraise` body larger than 8192 bytes is rejected with **HTTP 413** +`{ "error": "payload_too_large", "limitBytes": 8192 }` before the bytes are +parsed as JSON, so the request never receives a 402 payment quote. + ## Schema fixtures The canonical request/response shapes and the per-failure-mode payloads live as diff --git a/services/appraisal-api/package.json b/services/appraisal-api/package.json index 62821cf..b796000 100644 --- a/services/appraisal-api/package.json +++ b/services/appraisal-api/package.json @@ -12,7 +12,7 @@ "main": "src/index.ts", "scripts": { "start": "node --import tsx src/run.ts", - "test": "node --import tsx --test src/appraisal.test.ts src/appraisal-fixtures.test.ts src/config.test.ts src/client.test.ts src/quote-guard.test.ts", + "test": "node --import tsx --test src/appraisal.test.ts src/appraisal-fixtures.test.ts src/config.test.ts src/client.test.ts src/quote-guard.test.ts src/body-limit.test.ts", "typecheck": "tsc --noEmit -p tsconfig.json" }, "dependencies": { diff --git a/services/appraisal-api/src/body-limit.test.ts b/services/appraisal-api/src/body-limit.test.ts new file mode 100644 index 0000000..7fd4e1d --- /dev/null +++ b/services/appraisal-api/src/body-limit.test.ts @@ -0,0 +1,81 @@ +// Copyright (c) 2026 Sub Rosa contributors +import assert from "node:assert/strict"; +import http from "node:http"; +import type { IncomingMessage } from "node:http"; +import { once } from "node:events"; +import { PassThrough } from "node:stream"; +import { describe, test } from "node:test"; + +import { + APPRAISAL_BODY_TOO_LARGE, + takeAppraisalRequestBody, +} from "./body-limit.js"; + +function listen(server: http.Server): Promise { + server.listen(0, "127.0.0.1"); + return once(server, "listening").then(() => { + const address = server.address(); + if (address === null || typeof address === "string") { + throw new Error("expected a TCP port"); + } + return address.port; + }); +} + +function fakeRequest(headers: http.IncomingHttpHeaders, body: Buffer): IncomingMessage { + const stream = new PassThrough(); + const req = Object.assign(stream, { headers }) as IncomingMessage; + queueMicrotask(() => { + stream.end(body); + }); + return req; +} + +describe("appraisal request body cap", () => { + test("rejects a declared Content-Length above the cap before the body is parsed", async () => { + const req = fakeRequest({ "content-length": "8193" }, Buffer.from("{}")); + const taken = await takeAppraisalRequestBody(req, 8192); + assert.ok("status" in taken); + assert.equal(taken.status, 413); + assert.deepEqual(taken.body, { + error: APPRAISAL_BODY_TOO_LARGE, + limitBytes: 8192, + }); + }); + + test("returns 413 for an oversized stream and still parses a small JSON body", async () => { + const server = http.createServer(async (req, res) => { + const taken = await takeAppraisalRequestBody(req, 32); + if ("status" in taken) { + res.writeHead(taken.status, { "content-type": "application/json" }); + res.end(JSON.stringify(taken.body)); + return; + } + JSON.parse(taken.raw.toString("utf8")); + res.writeHead(200, { "content-type": "application/json" }); + res.end(JSON.stringify({ ok: true })); + }); + const port = await listen(server); + try { + const oversized = await fetch(`http://127.0.0.1:${port}/appraise`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: "x".repeat(33), + }); + assert.equal(oversized.status, 413); + assert.deepEqual(await oversized.json(), { + error: APPRAISAL_BODY_TOO_LARGE, + limitBytes: 32, + }); + + const allowed = await fetch(`http://127.0.0.1:${port}/appraise`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ ok: true }), + }); + assert.equal(allowed.status, 200); + } finally { + server.close(); + } + }); +}); diff --git a/services/appraisal-api/src/body-limit.ts b/services/appraisal-api/src/body-limit.ts new file mode 100644 index 0000000..80c1269 --- /dev/null +++ b/services/appraisal-api/src/body-limit.ts @@ -0,0 +1,92 @@ +// Copyright (c) 2026 Sub Rosa contributors +import type { IncomingMessage } from "node:http"; + +/** Stable error code for an appraisal body above the configured byte cap. */ +export const APPRAISAL_BODY_TOO_LARGE = "payload_too_large"; + +export class AppraisalBodyTooLargeError extends Error { + readonly name = "AppraisalBodyTooLargeError"; + readonly status = 413 as const; + + constructor(readonly limitBytes: number) { + super("appraisal request body exceeds limit"); + } +} + +export function appraisalBodyTooLargeBody(limitBytes: number): { + error: typeof APPRAISAL_BODY_TOO_LARGE; + limitBytes: number; +} { + return { error: APPRAISAL_BODY_TOO_LARGE, limitBytes }; +} + +/** + * Read a request body without buffering past `maxBytes` and without parsing JSON. + * An over-limit `Content-Length` fails before any chunk is kept. A chunked body + * fails on the chunk that crosses the cap. The socket is left open so the caller + * can still write the 413 response. + */ +export function readCappedBody(req: IncomingMessage, maxBytes: number): Promise { + return new Promise((resolve, reject) => { + let settled = false; + const fail = () => { + if (settled) return; + settled = true; + req.pause(); + req.removeAllListeners("data"); + reject(new AppraisalBodyTooLargeError(maxBytes)); + }; + + const declared = req.headers["content-length"]; + if (declared !== undefined) { + const raw = Array.isArray(declared) ? declared[0] : declared; + const length = Number(raw); + if (!Number.isFinite(length) || length < 0 || length > maxBytes) { + fail(); + return; + } + } + + const chunks: Buffer[] = []; + let received = 0; + req.on("data", (chunk: Buffer | string) => { + if (settled) return; + const buf = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk); + received += buf.length; + if (received > maxBytes) { + fail(); + return; + } + chunks.push(buf); + }); + req.on("end", () => { + if (settled) return; + settled = true; + resolve(Buffer.concat(chunks)); + }); + req.on("error", (err: Error) => { + if (settled) return; + settled = true; + reject(err); + }); + }); +} + +/** Cap result for `POST /appraise`. Oversized bodies never reach `JSON.parse`. */ +export async function takeAppraisalRequestBody( + req: IncomingMessage, + maxBytes: number, +): Promise< + | { raw: Buffer } + | { status: 413; body: ReturnType } +> { + try { + const raw = await readCappedBody(req, maxBytes); + return { raw }; + } catch (err) { + if (err instanceof AppraisalBodyTooLargeError) { + return { status: 413, body: appraisalBodyTooLargeBody(err.limitBytes) }; + } + throw err; + } +} diff --git a/services/appraisal-api/src/server.ts b/services/appraisal-api/src/server.ts index aacf444..504f59e 100644 --- a/services/appraisal-api/src/server.ts +++ b/services/appraisal-api/src/server.ts @@ -38,19 +38,11 @@ import { ExactStellarScheme as FacilitatorStellarScheme } from "@x402/stellar/ex import { ExactStellarScheme as ServerStellarScheme } from "@x402/stellar/exact/server"; import { appraise, AppraisalInputError, buildAppraisalQuote, MAX_APPRAISAL_BODY_BYTES, parseAppraisalRequest } from "./appraisal.js"; +import { takeAppraisalRequestBody } from "./body-limit.js"; import type { AppraisalServerConfig } from "./config.js"; const APPRAISE_ROUTE = "POST /appraise"; -function readBody(req: http.IncomingMessage): Promise { - return new Promise((resolve, reject) => { - const chunks: Buffer[] = []; - req.on("data", (c) => chunks.push(c as Buffer)); - req.on("end", () => resolve(Buffer.concat(chunks))); - req.on("error", reject); - }); -} - /** Minimal HTTPAdapter over a Node request + already-read body. */ function makeAdapter( req: http.IncomingMessage, @@ -165,14 +157,16 @@ export async function buildAppraisalServer( }); } - const rawBody = await readBody(req); + const taken = await takeAppraisalRequestBody(req, MAX_APPRAISAL_BODY_BYTES); + if ("status" in taken) { + // Fail closed before JSON parsing and before any payment offer. + send(res, taken.status, {}, taken.body); + res.on("finish", () => req.destroy()); + return; + } + const rawBody = taken.raw; let parsedBody: unknown = undefined; if (rawBody.length > 0) { - // Fail closed before any payment offer: oversized bodies never get a - // 402 quote and paid retries with unsafe bodies never settle. - if (rawBody.length > MAX_APPRAISAL_BODY_BYTES) { - return send(res, 400, {}, { error: "Invalid appraisal request" }); - } try { parsedBody = JSON.parse(rawBody.toString("utf8")); } catch {