Skip to content
Open
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 services/appraisal-api/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 3 additions & 3 deletions services/appraisal-api/src/appraisal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,7 @@ export interface AppraisalRequest {
}

export interface Appraisal {
model: typeof APPRAISALMODEL;
model: typeof APPRAISAL_MODEL;
itemRef: string;
inputsHash: string;
fairValue: number;
Expand Down Expand Up @@ -93,7 +93,7 @@ function canonical(value: unknown): string {
const entries = Object.entries(value as Record<string, unknown>)
.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);
Expand Down Expand Up @@ -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`,
Expand Down
95 changes: 94 additions & 1 deletion services/appraisal-api/src/body-limit.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<number> {
server.listen(0, "127.0.0.1");
Expand All @@ -24,7 +32,7 @@ function listen(server: http.Server): Promise<number> {

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);
});
Expand Down Expand Up @@ -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();
}
});
Expand Down
60 changes: 60 additions & 0 deletions services/appraisal-api/src/body-limit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<IncomingMessage, "headers">,
): {
status: 415;
body: ReturnType<typeof appraisalUnsupportedMediaTypeBody>;
} | 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;
Expand Down
9 changes: 9 additions & 0 deletions services/appraisal-api/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
11 changes: 10 additions & 1 deletion services/appraisal-api/src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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.
Expand Down