From 3365d59771df095e76c8fee3e0793aa0e4966f5d Mon Sep 17 00:00:00 2001 From: umpireboulianne-design Date: Sat, 24 Oct 2026 03:16:12 -0400 Subject: [PATCH] security(appraisal-api): require JSON content type on appraisal requests (#537) --- services/appraisal-api/README.md | 6 ++ services/appraisal-api/src/appraisal.ts | 6 +- services/appraisal-api/src/body-limit.test.ts | 95 ++++++++++++++++++- services/appraisal-api/src/body-limit.ts | 60 ++++++++++++ services/appraisal-api/src/index.ts | 9 ++ services/appraisal-api/src/server.ts | 11 ++- 6 files changed, 182 insertions(+), 5 deletions(-) diff --git a/services/appraisal-api/README.md b/services/appraisal-api/README.md index 0385a6bd..a9949e9a 100644 --- a/services/appraisal-api/README.md +++ b/services/appraisal-api/README.md @@ -71,6 +71,12 @@ 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. +`POST /appraise` requires `Content-Type: application/json` (optional UTF-8 +charset parameters such as `application/json; charset=utf-8` are accepted). +Requests with a missing or unsupported media type are rejected with **HTTP 415** +`{ "error": "unsupported_media_type", "expected": "application/json" }` before +reading the body or issuing a 402 quote. + ## Schema fixtures The canonical request/response shapes and the per-failure-mode payloads live as diff --git a/services/appraisal-api/src/appraisal.ts b/services/appraisal-api/src/appraisal.ts index fb3f3009..021300f7 100644 --- a/services/appraisal-api/src/appraisal.ts +++ b/services/appraisal-api/src/appraisal.ts @@ -62,7 +62,7 @@ export interface AppraisalRequest { } export interface Appraisal { - model: typeof APPRAISALMODEL; + model: typeof APPRAISAL_MODEL; itemRef: string; inputsHash: string; fairValue: number; @@ -93,7 +93,7 @@ function canonical(value: unknown): string { const entries = Object.entries(value as Record) .filter(([, v]) => v !== undefined) .sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)) - .map(([k], v]) => `${JSON.stringify(k)}:${canonical(v)}`); + .map(([k, v]) => `${JSON.stringify(k)}:${canonical(v)}`); return `{${entries.join(",")}}`; } return JSON.stringify(value ?? null); @@ -275,7 +275,7 @@ export function appraise(req: AppraisalRequest): Appraisal { const suggestedMaxBid = round2(fairValue * (0.8 + 0.15 * confidence)); const rationale = [ - `base ${req.basePrice} USDC scaled by quality×${round2(qualityF)}, demand×ä{round2(demandF)}, scarcity×ä{round2(scarcityF)}, risk×ä{round2(riskF)}`, + `base ${req.basePrice} USDC scaled by quality×${round2(qualityF)}, demand×${round2(demandF)}, scarcity×${round2(scarcityF)}, risk×${round2(riskF)}`, `category '${req.category ?? "none"}' multiplier ×${categoryF}`, `${provided}/4 attributes supplied → confidence ${confidence}`, `suggested max bid is fair value × ${round2(0.8 + 0.15 * confidence)} to preserve margin`, diff --git a/services/appraisal-api/src/body-limit.test.ts b/services/appraisal-api/src/body-limit.test.ts index 7fd4e1d5..e333fc5f 100644 --- a/services/appraisal-api/src/body-limit.test.ts +++ b/services/appraisal-api/src/body-limit.test.ts @@ -6,10 +6,18 @@ import { once } from "node:events"; import { PassThrough } from "node:stream"; import { describe, test } from "node:test"; +import { Keypair, Networks } from "@stellar/stellar-sdk"; +import { STELLAR_TESTNET_CAIP2, USDC_TESTNET_ADDRESS } from "@x402/stellar"; + import { APPRAISAL_BODY_TOO_LARGE, + APPRAISAL_UNSUPPORTED_MEDIA_TYPE, + checkAppraisalContentType, + isJsonContentType, takeAppraisalRequestBody, } from "./body-limit.js"; +import { buildAppraisalServer } from "./server.js"; +import { validRequest } from "./fixtures/index.js"; function listen(server: http.Server): Promise { server.listen(0, "127.0.0.1"); @@ -24,7 +32,7 @@ function listen(server: http.Server): Promise { function fakeRequest(headers: http.IncomingHttpHeaders, body: Buffer): IncomingMessage { const stream = new PassThrough(); - const req = Object.assign(stream, { headers }) as IncomingMessage; + const req = Object.assign(stream, { headers }) as unknown as IncomingMessage; queueMicrotask(() => { stream.end(body); }); @@ -75,6 +83,91 @@ describe("appraisal request body cap", () => { }); assert.equal(allowed.status, 200); } finally { + server.closeAllConnections?.(); + server.close(); + } + }); +}); + +describe("appraisal JSON Content-Type enforcement", () => { + test("accepts application/json with and without UTF-8 charset parameters", () => { + for (const ct of [ + "application/json", + "Application/JSON", + "application/json; charset=utf-8", + "application/json; charset=UTF-8", + 'application/json; charset="utf-8"', + "application/json;charset=utf8", + ]) { + assert.equal(isJsonContentType(ct), true, `expected ${ct} to pass`); + assert.equal( + checkAppraisalContentType({ headers: { "content-type": ct } }), + null, + ); + } + }); + + test("rejects missing, non-JSON, or unsupported charset media types with stable 415", () => { + for (const ct of [ + undefined, + "", + " ", + "text/plain", + "text/html; charset=utf-8", + "application/x-www-form-urlencoded", + "multipart/form-data; boundary=---", + "application/xml", + "application/json; charset=iso-8859-1", + "application/json; invalid-param", + ]) { + assert.equal(isJsonContentType(ct), false, `expected ${String(ct)} to fail`); + const rejected = checkAppraisalContentType({ + headers: ct === undefined ? {} : { "content-type": ct }, + }); + assert.deepEqual(rejected, { + status: 415, + body: { + error: APPRAISAL_UNSUPPORTED_MEDIA_TYPE, + expected: "application/json", + }, + }); + } + }); + + test("POST /appraise returns 415 for non-JSON requests and 402 quote for JSON with charset", async () => { + const facilitator = Keypair.fromRawEd25519Seed(Buffer.alloc(32, 5)); + const payee = Keypair.fromRawEd25519Seed(Buffer.alloc(32, 6)); + const server = await buildAppraisalServer({ + facilitatorSecret: facilitator.secret(), + payTo: payee.publicKey(), + asset: USDC_TESTNET_ADDRESS, + price: 0.1, + network: STELLAR_TESTNET_CAIP2, + networkPassphrase: Networks.TESTNET, + rpcUrl: "https://soroban-testnet.stellar.org", + port: 0, + }); + const port = await listen(server); + try { + const textPlain = await fetch(`http://127.0.0.1:${port}/appraise`, { + method: "POST", + headers: { "content-type": "text/plain" }, + body: JSON.stringify(validRequest.value), + }); + assert.equal(textPlain.status, 415); + assert.deepEqual(await textPlain.json(), { + error: APPRAISAL_UNSUPPORTED_MEDIA_TYPE, + expected: "application/json", + }); + + const withCharset = await fetch(`http://127.0.0.1:${port}/appraise`, { + method: "POST", + headers: { "content-type": "application/json; charset=utf-8" }, + body: JSON.stringify(validRequest.value), + }); + assert.equal(withCharset.status, 402); + } finally { + server.closeAllConnections?.(); server.close(); } }); diff --git a/services/appraisal-api/src/body-limit.ts b/services/appraisal-api/src/body-limit.ts index 80c1269d..2e5c63f8 100644 --- a/services/appraisal-api/src/body-limit.ts +++ b/services/appraisal-api/src/body-limit.ts @@ -4,6 +4,66 @@ 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"; +/** Stable error code when `POST /appraise` omits or uses a non-JSON `Content-Type`. */ +export const APPRAISAL_UNSUPPORTED_MEDIA_TYPE = "unsupported_media_type"; + +export function appraisalUnsupportedMediaTypeBody(): { + error: typeof APPRAISAL_UNSUPPORTED_MEDIA_TYPE; + expected: "application/json"; +} { + return { + error: APPRAISAL_UNSUPPORTED_MEDIA_TYPE, + expected: "application/json", + }; +} + +/** + * Return true when `Content-Type` is `application/json`, allowing optional + * media-type parameters such as `; charset=utf-8` (case-insensitively). + */ +export function isJsonContentType( + headerValue: string | string[] | undefined, +): boolean { + const raw = Array.isArray(headerValue) ? headerValue[0] : headerValue; + if (typeof raw !== "string" || raw.trim() === "") return false; + const parts = raw.split(";").map((part) => part.trim()); + const mediaType = parts[0]?.toLowerCase() ?? ""; + if (mediaType !== "application/json") return false; + for (let i = 1; i < parts.length; i++) { + const param = parts[i]; + if (!param) return false; + const eqIdx = param.indexOf("="); + if (eqIdx <= 0) return false; + const name = param.slice(0, eqIdx).trim().toLowerCase(); + const rawVal = param.slice(eqIdx + 1).trim(); + const val = rawVal.replace(/^"(.*)"$/, "$1").trim().toLowerCase(); + if (!val) return false; + if (name === "charset" && val !== "utf-8" && val !== "utf8") { + return false; + } + } + return true; +} + +/** + * Validate `Content-Type` on `POST /appraise`. Returns a stable 415 response + * instruction when the media type is missing or not `application/json`. + */ +export function checkAppraisalContentType( + req: Pick, +): { + status: 415; + body: ReturnType; +} | null { + if (isJsonContentType(req.headers["content-type"])) { + return null; + } + return { + status: 415, + body: appraisalUnsupportedMediaTypeBody(), + }; +} + export class AppraisalBodyTooLargeError extends Error { readonly name = "AppraisalBodyTooLargeError"; readonly status = 413 as const; diff --git a/services/appraisal-api/src/index.ts b/services/appraisal-api/src/index.ts index 9198c6ae..4a8fe1b9 100644 --- a/services/appraisal-api/src/index.ts +++ b/services/appraisal-api/src/index.ts @@ -19,6 +19,15 @@ export { } from "./appraisal.js"; export { buildAppraisalServer } from "./server.js"; +export { + APPRAISAL_BODY_TOO_LARGE, + APPRAISAL_UNSUPPORTED_MEDIA_TYPE, + appraisalBodyTooLargeBody, + appraisalUnsupportedMediaTypeBody, + checkAppraisalContentType, + isJsonContentType, + takeAppraisalRequestBody, +} from "./body-limit.js"; export { AppraisalConfigError, configFromEnv, diff --git a/services/appraisal-api/src/server.ts b/services/appraisal-api/src/server.ts index 504f59e9..2e763f8f 100644 --- a/services/appraisal-api/src/server.ts +++ b/services/appraisal-api/src/server.ts @@ -38,7 +38,7 @@ 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 { checkAppraisalContentType, takeAppraisalRequestBody } from "./body-limit.js"; import type { AppraisalServerConfig } from "./config.js"; const APPRAISE_ROUTE = "POST /appraise"; @@ -157,6 +157,15 @@ export async function buildAppraisalServer( }); } + if (method === "POST" && url.pathname === "/appraise") { + const mediaError = checkAppraisalContentType(req); + if (mediaError) { + send(res, mediaError.status, {}, mediaError.body); + res.on("finish", () => req.destroy()); + return; + } + } + const taken = await takeAppraisalRequestBody(req, MAX_APPRAISAL_BODY_BYTES); if ("status" in taken) { // Fail closed before JSON parsing and before any payment offer.