diff --git a/services/appraisal-api/README.md b/services/appraisal-api/README.md index 0385a6b..9b1bdfc 100644 --- a/services/appraisal-api/README.md +++ b/services/appraisal-api/README.md @@ -71,6 +71,11 @@ 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. +All appraisal quote challenges (`402`), paid appraisal responses (`200`), and +error responses include `Cache-Control: no-store`, `Pragma: no-cache`, +`Expires: 0`, and `X-Content-Type-Options: nosniff` so intermediary caches +never store or replay time-bound payment quotes or paid valuations. + ## 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 fb3f300..021300f 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 7fd4e1d..6ed1a53 100644 --- a/services/appraisal-api/src/body-limit.test.ts +++ b/services/appraisal-api/src/body-limit.test.ts @@ -6,10 +6,19 @@ 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, takeAppraisalRequestBody, } from "./body-limit.js"; +import { + APPRAISAL_RESPONSE_SECURITY_HEADERS, + buildAppraisalServer, + withNonCacheableHeaders, +} from "./server.js"; +import { validRequest } from "./fixtures/index.js"; function listen(server: http.Server): Promise { server.listen(0, "127.0.0.1"); @@ -24,7 +33,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 +84,71 @@ describe("appraisal request body cap", () => { }); assert.equal(allowed.status, 200); } finally { + server.closeAllConnections?.(); + server.close(); + } + }); +}); + +describe("appraisal response non-cacheable headers", () => { + test("withNonCacheableHeaders enforces Cache-Control: no-store and safe headers", () => { + const merged = withNonCacheableHeaders({ + "Cache-Control": "public, max-age=3600", + "X-Payment-Response": "settled", + }); + assert.equal(merged["cache-control"], "no-store"); + assert.equal(merged["pragma"], "no-cache"); + assert.equal(merged["expires"], "0"); + assert.equal(merged["x-content-type-options"], "nosniff"); + assert.equal(merged["x-payment-response"], "settled"); + assert.deepEqual(APPRAISAL_RESPONSE_SECURITY_HEADERS, { + "cache-control": "no-store", + pragma: "no-cache", + expires: "0", + "x-content-type-options": "nosniff", + }); + }); + + test("402 quote challenges and error responses carry Cache-Control: no-store and safe headers", async () => { + const facilitator = Keypair.fromRawEd25519Seed(Buffer.alloc(32, 3)); + const payee = Keypair.fromRawEd25519Seed(Buffer.alloc(32, 4)); + 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, + }, + { nowSeconds: () => 1_700_000_000 }, + ); + const port = await listen(server); + try { + const challenge = await fetch(`http://127.0.0.1:${port}/appraise`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(validRequest.value), + }); + assert.equal(challenge.status, 402); + assert.equal(challenge.headers.get("cache-control"), "no-store"); + assert.equal(challenge.headers.get("pragma"), "no-cache"); + assert.equal(challenge.headers.get("expires"), "0"); + assert.equal(challenge.headers.get("x-content-type-options"), "nosniff"); + + const emptyPost = await fetch(`http://127.0.0.1:${port}/appraise`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: "", + }); + assert.equal(emptyPost.status, 400); + assert.equal(emptyPost.headers.get("cache-control"), "no-store"); + assert.equal(emptyPost.headers.get("pragma"), "no-cache"); + assert.equal(emptyPost.headers.get("x-content-type-options"), "nosniff"); + } finally { + server.closeAllConnections?.(); server.close(); } }); diff --git a/services/appraisal-api/src/index.ts b/services/appraisal-api/src/index.ts index 9198c6a..eeebc33 100644 --- a/services/appraisal-api/src/index.ts +++ b/services/appraisal-api/src/index.ts @@ -18,7 +18,12 @@ export { type AppraisalAttributes, } from "./appraisal.js"; -export { buildAppraisalServer } from "./server.js"; +export { + APPRAISAL_RESPONSE_SECURITY_HEADERS, + buildAppraisalServer, + withNonCacheableHeaders, + type AppraisalServerOptions, +} from "./server.js"; export { AppraisalConfigError, configFromEnv, diff --git a/services/appraisal-api/src/server.ts b/services/appraisal-api/src/server.ts index 504f59e..667db96 100644 --- a/services/appraisal-api/src/server.ts +++ b/services/appraisal-api/src/server.ts @@ -43,6 +43,39 @@ import type { AppraisalServerConfig } from "./config.js"; const APPRAISE_ROUTE = "POST /appraise"; +/** Non-cacheable security headers applied to appraisal quotes and paid responses. */ +export const APPRAISAL_RESPONSE_SECURITY_HEADERS: Readonly> = + Object.freeze({ + "cache-control": "no-store", + pragma: "no-cache", + expires: "0", + "x-content-type-options": "nosniff", + }); + +/** + * Merge response headers with mandatory non-cacheable security headers. + * Case-insensitively prevents upstream headers from re-enabling response caching. + */ +export function withNonCacheableHeaders( + headers: Record = {}, +): Record { + const normalized: Record = {}; + for (const [key, value] of Object.entries(headers)) { + normalized[key.toLowerCase()] = value; + } + return { + ...normalized, + ...APPRAISAL_RESPONSE_SECURITY_HEADERS, + }; +} + +export interface AppraisalServerOptions { + /** Optional custom facilitator client (used by deterministic unit tests). */ + facilitatorClient?: FacilitatorClient; + /** Optional wall-clock override returning Unix seconds. */ + nowSeconds?: () => number; +} + /** Minimal HTTPAdapter over a Node request + already-read body. */ function makeAdapter( req: http.IncomingMessage, @@ -73,7 +106,10 @@ function send( ) { const payload = typeof body === "string" ? body : JSON.stringify(body ?? {}, null, 2); - res.writeHead(status, { "content-type": "application/json", ...headers }); + res.writeHead(status, { + "content-type": "application/json", + ...withNonCacheableHeaders(headers), + }); res.end(payload); } @@ -84,7 +120,7 @@ function writeInstructions(res: http.ServerResponse, i: HTTPResponseInstructions : JSON.stringify(i.body ?? {}, null, 2); res.writeHead(i.status, { "content-type": i.isHtml ? "text/html" : "application/json", - ...i.headers, + ...withNonCacheableHeaders(i.headers), }); res.end(body ?? ""); } @@ -92,24 +128,29 @@ function writeInstructions(res: http.ServerResponse, i: HTTPResponseInstructions /** Build (and initialize) the x402-gated appraisal HTTP server. */ export async function buildAppraisalServer( config: AppraisalServerConfig, + options: AppraisalServerOptions = {}, ): Promise { const rpcConfig = config.rpcUrl ? { url: config.rpcUrl } : undefined; - // In-process facilitator: verifies + settles on-chain (sponsors fees, submits). - const facilitatorSigner = createEd25519Signer(config.facilitatorSecret, config.network); - const facilitator = new x402Facilitator().register( - config.network, - new FacilitatorStellarScheme([facilitatorSigner], { rpcConfig }), - ); - // x402Facilitator already exposes verify/settle/getSupported; adapt the - // synchronous getSupported() to the async FacilitatorClient contract so we can - // wire it straight into the resource server with no HTTP hop. - const facilitatorClient: FacilitatorClient = { - verify: (payload, requirements) => facilitator.verify(payload, requirements), - settle: (payload, requirements) => facilitator.settle(payload, requirements), - getSupported: async (): Promise => - facilitator.getSupported() as unknown as SupportedResponse, - }; + const facilitatorClient: FacilitatorClient = + options.facilitatorClient ?? + (() => { + // In-process facilitator: verifies + settles on-chain (sponsors fees, submits). + const facilitatorSigner = createEd25519Signer(config.facilitatorSecret, config.network); + const facilitator = new x402Facilitator().register( + config.network, + new FacilitatorStellarScheme([facilitatorSigner], { rpcConfig }), + ); + // x402Facilitator already exposes verify/settle/getSupported; adapt the + // synchronous getSupported() to the async FacilitatorClient contract so we can + // wire it straight into the resource server with no HTTP hop. + return { + verify: (payload, requirements) => facilitator.verify(payload, requirements), + settle: (payload, requirements) => facilitator.settle(payload, requirements), + getSupported: async (): Promise => + facilitator.getSupported() as unknown as SupportedResponse, + }; + })(); // Resource server: builds payment requirements and orchestrates verify/settle. const serverScheme = new ServerStellarScheme().registerMoneyParser(async (amount) => ({ @@ -199,13 +240,16 @@ export async function buildAppraisalServer( // malformed body never costs the caller. let body: unknown; try { + const nowSeconds = options.nowSeconds + ? options.nowSeconds() + : Math.floor(globalThis.Date.now() / 1000); body = { appraisal: appraise(parseAppraisalRequest(parsedBody)), quote: buildAppraisalQuote({ asset: config.asset, price: config.price, destination: config.payTo, - nowSeconds: Math.floor(Date.now() / 1000), + nowSeconds, }), }; } catch (e) { @@ -220,7 +264,7 @@ export async function buildAppraisalServer( result.paymentPayload as PaymentPayload, result.paymentRequirements as PaymentRequirements, result.declaredExtensions, - { request: ctx, responseBody, responseHeaders: {} }, + { request: ctx, responseBody, responseHeaders: withNonCacheableHeaders() }, ); if (!settle.success) {