Skip to content
Merged
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
4 changes: 4 additions & 0 deletions services/appraisal-api/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion services/appraisal-api/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
81 changes: 81 additions & 0 deletions services/appraisal-api/src/body-limit.test.ts
Original file line number Diff line number Diff line change
@@ -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<number> {
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();
}
});
});
92 changes: 92 additions & 0 deletions services/appraisal-api/src/body-limit.ts
Original file line number Diff line number Diff line change
@@ -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<Buffer> {
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<typeof appraisalBodyTooLargeBody> }
> {
try {
const raw = await readCappedBody(req, maxBytes);
return { raw };
} catch (err) {
if (err instanceof AppraisalBodyTooLargeError) {
return { status: 413, body: appraisalBodyTooLargeBody(err.limitBytes) };
}
throw err;
}
}
24 changes: 9 additions & 15 deletions services/appraisal-api/src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Buffer> {
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,
Expand Down Expand Up @@ -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 {
Expand Down
Loading