Root cause
SSRF & network boundaries
feat(mcp-server): implement x402 MCP server (catalog -> tools -> pay -> fetch)
How AI contributed: Flawed AI-written codePublished Jun 26, 2026 in
morganoncode/cardano402
MCP server v0.1.x (GHSA-rp72-5v5q-2446) combines three flaws: (1) no spending limits - a connected client can request arbitrarily large payments from the wallet; (2) HTTP transports (Streamable/SSE) bind 0.0.0.0 with no authentication, exposing wallet-triggering endpoints on all interfaces; (3) SSRF - catalog.server.url returned by the remote /.well-known/x402.json manifest is used verbatim with only an allowInsecure flag and no private-CIDR/loopback check, and endpoint.path is not normalized, so a malicious catalog can redirect requests to internal hosts.
Root cause
feat(mcp-server): implement x402 MCP server (catalog -> tools -> pay -> fetch)
Fix
security(mcp-server): 0.1.2 — spending limits, loopback default, SSRF guard
AI-assisted fix: Claude Code · MorganOnCode
Case facts
@@ -0,0 +1,143 @@+// Map an x402 catalog to MCP tools.+//+// One tool per catalog endpoint, named with the same recipe the producer+// uses for `/.well-known/mcp/server-card.json` (see `src/catalog.ts` in the+// root repo). On invocation, the tool calls `payAndFetch`, surfaces the+// resource body as a text content block, and reports the payment txHash+// via `structuredContent`.+++import { Cardano402Error } from '@cardano402/core';+import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';+import { z } from 'zod';++import type { CatalogEndpoint, WellKnownX402 } from './catalog.js';+import { resolveBaseUrl, toolNameFor } from './catalog.js';+import { payAndFetch } from './payment.js';+import type { CardanoSigner } from './signer.js';++const ToolInputShape = {+ body: z+ .unknown()+ .optional()+ .describe(+ 'JSON body for POST/PUT/PATCH requests. Ignored for GET. Must match the endpoint inputSchema (if declared in the catalog).'+ ),+ query: z+ .record(z.string(), z.string())+ .optional()+ .describe('Optional query-string parameters appended to the endpoint URL.'),+};++export interface RegisterToolsOptions {+ catalog: WellKnownX402;+ catalogUrl: string;+ signer: CardanoSigner;+ /** Forwarded to `payAndFetch` for the resource-server HTTP call. */+ requestTimeoutMs?: number;+}++function describeEndpoint(endpoint: CatalogEndpoint): string {+ const base = endpoint.description ?? `${endpoint.method} ${endpoint.path}`;+ const price = `Price: ${endpoint.amount} ${endpoint.asset} on ${endpoint.network} -> ${endpoint.payTo}`;+ const schemaHint = endpoint.inputSchema+ ? `\n\nRequest body must match the JSON Schema:\n${JSON.stringify(endpoint.inputSchema, null, 2)}`+ : '';+ return `${base}\n\n${price}${schemaHint}`;+}++/**+ * Register one MCP tool per catalog endpoint on the supplied server.+ *+ * Returns the list of registered tool names so callers can log or assert+ * on them.+ */+export function registerTools(+ server: McpServer,+ options: RegisterToolsOptions+): string[] {+ const baseUrl = resolveBaseUrl(options.catalog, options.catalogUrl);+ const registered: string[] = [];+ const seen = new Set<string>();++ for (const endpoint of options.catalog.endpoints) {+ let name = toolNameFor(endpoint);+ if (seen.has(name)) {+ // Deterministic disambiguation: append `_2`, `_3`, ... — extremely+ // rare (would require two endpoints whose method+path collapse to+ // the same identifier) but cheap to guard against.+ let n = 2;+ while (seen.has(`${name}_${n}`)) n += 1;+ name = `${name}_${n}`;+ }+ seen.add(name);+ registered.push(name);++ server.registerTool(+ name,+ {+ description: describeEndpoint(endpoint),+ inputSchema: ToolInputShape,+ _meta: {+ 'cardano402/endpoint': {+ method: endpoint.method,+ path: endpoint.path,+ network: endpoint.network,+ asset: endpoint.asset,+ amount: endpoint.amount,+ payTo: endpoint.payTo,+ },@@ -0,0 +1,116 @@+// CardanoSigner abstraction + Lucid Evolution implementation.+//+// `payment.ts` only depends on the `CardanoSigner` interface, so unit tests+// can swap in a stub without dragging in CML wasm. The concrete Lucid signer+// loads its deps lazily for the same reason.++import type { LucidNetwork } from './config.js';++export interface SignPaymentArgs {+ payTo: string;+ /** Smallest-unit amount (lovelace for ADA, asset units for native tokens). */+ amount: bigint;+ /** "lovelace" or "policyId.assetNameHex" (cardano402 convention). */+ asset: string;+ /** TTL relative to "now", in seconds. */+ ttlSeconds: number;+}++export interface SignedPayment {+ /** Signed CBOR transaction, base64-encoded — the value of `payload.transaction`. */+ cborBase64: string;+ /** UTXO reference of the form `txHash#index`, one of the tx inputs. */+ nonce: string;+ /** Hex transaction id, useful for logging. */+ txHash: string;+}++export interface CardanoSigner {+ /** Bech32 address that will sign and fund the payment. */+ address(): Promise<string>;+ signPayment(args: SignPaymentArgs): Promise<SignedPayment>;+}++const BLOCKFROST_URLS: Record<LucidNetwork, string> = {+ Preview: 'https://cardano-preview.blockfrost.io/api/v0',+ Preprod: 'https://cardano-preprod.blockfrost.io/api/v0',+ Mainnet: 'https://cardano-mainnet.blockfrost.io/api/v0',+};++export interface LucidSeedSignerOptions {+ network: LucidNetwork;+ blockfrostKey: string;+ seedPhrase: string;+ /** Override the Blockfrost base URL — useful for local proxies and tests. */+ blockfrostUrl?: string;+}++/**+ * Create a `CardanoSigner` backed by Lucid Evolution + Blockfrost.+ *+ * Loads `@lucid-evolution/{lucid,provider}` lazily so callers that only+ * use the catalog / config code paths don't pay the CML wasm startup+ * cost or carry the dep in their bundle.+ */+export async function createLucidSeedSigner(+ options: LucidSeedSignerOptions+): Promise<CardanoSigner> {+ const { Lucid } = await import('@lucid-evolution/lucid');+ const { Blockfrost } = await import('@lucid-evolution/provider');++ const url = options.blockfrostUrl ?? BLOCKFROST_URLS[options.network];+ const provider = new Blockfrost(url, options.blockfrostKey);+ const lucid = await Lucid(provider, options.network);+ lucid.selectWallet.fromSeed(options.seedPhrase);++ return {+ async address(): Promise<string> {+ return lucid.wallet().address();+ },+ async signPayment({ payTo, amount, asset, ttlSeconds }: SignPaymentArgs): Promise<SignedPayment> {+ const assets =+ asset === 'lovelace'+ ? { lovelace: amount }+ : { [asset]: amount };++ const txBuilder = lucid.newTx().pay.ToAddress(payTo, assets);+ // Lucid's `validTo` takes an absolute POSIX ms timestamp. Stay a few+ // seconds under the catalog-advertised `maxTimeoutSeconds` so the+ // facilitator's TTL check still passes after broadcast latency and+ // mild client/server clock skew. Floor at 10s for tight TTLs.+ const safetySeconds = Math.min(5, Math.max(1, Math.floor(ttlSeconds * 0.05)));+ const validTo = Date.now() + Math.max(10, ttlSeconds - safetySeconds) * 1000;+ const built = await txBuilder+ .validTo(validTo)+ .complete();+ const signed = await built.sign.withWallet().complete();++ const cborHex = signed.toCBOR();+ const cborBase64 = Buffer.from(cborHex, 'hex').toString('base64');@@ -0,0 +1,280 @@+// The x402 payment cycle: HTTP -> 402 -> sign -> retry.+//+// Lives behind a single function so the MCP tool layer can stay thin.+// Reuses the wire-format schemas + header codec from `@cardano402/core`+// rather than reimplementing base64+JSON+zod plumbing here.++import {+ Cardano402HttpError,+ Cardano402NetworkError,+ Cardano402ValidationError,+ PaymentRequiredResponseSchema,+ PaymentSignaturePayloadSchema,+ type PaymentAccept,+ type PaymentRequiredResponse,+} from '@cardano402/core';++import type { CatalogEndpoint } from './catalog.js';+import type { CardanoSigner } from './signer.js';++export interface PayAndFetchOptions {+ baseUrl: string;+ endpoint: CatalogEndpoint;+ /** Optional JSON body for non-GET requests. */+ body?: unknown;+ /** Optional extra query string (e.g. `?foo=bar`). */+ query?: Record<string, string>;+ signer: CardanoSigner;+ timeoutMs?: number;+ fetchImpl?: typeof fetch;+}++export interface PayAndFetchResult {+ status: number;+ /** Parsed JSON if the response declared `application/json`, otherwise raw text. */+ body: unknown;+ /** Decoded X-Payment-Response, if the server returned one. */+ payment: {+ transaction: string;+ network: string;+ payer?: string;+ status?: string;+ } | null;+ contentType: string | null;+}++const X402_HEADER_PRIMARY = 'Payment-Signature';+const X402_HEADER_ALIAS = 'PAYMENT-SIGNATURE';+const X402_RESPONSE_HEADER_PRIMARY = 'x-payment-response';+const X402_RESPONSE_HEADER_ALIAS = 'payment-response';+const X402_REQUIRED_HEADER = 'payment-required';++function joinUrl(base: string, path: string, query?: Record<string, string>): string {+ const normalisedBase = base.replace(/\/+$/, '');+ const normalisedPath = path.startsWith('/') ? path : `/${path}`;+ const qs =+ query && Object.keys(query).length > 0+ ? `?${new URLSearchParams(query).toString()}`+ : '';+ return `${normalisedBase}${normalisedPath}${qs}`;+}++function decodePaymentRequired(headerValue: string): PaymentRequiredResponse {+ let json: string;+ try {+ json = Buffer.from(headerValue, 'base64').toString('utf-8');+ } catch (err) {+ throw new Cardano402NetworkError(+ `Payment-Required header was not valid base64: ${(err as Error).message}`,+ { cause: err }+ );+ }+ let parsed: unknown;+ try {+ parsed = JSON.parse(json);+ } catch (err) {+ throw new Cardano402NetworkError(+ `Payment-Required header was not valid JSON: ${(err as Error).message}`,+ { cause: err }+ );+ }+ const result = PaymentRequiredResponseSchema.safeParse(parsed);+ if (!result.success) {+ throw new Cardano402ValidationError(+ 'Payment-Required header did not match the expected schema',+ result.error.issues+ );+ }+ return result.data;+}@@ -0,0 +1,101 @@+// In-memory rolling-window spending tracker used to enforce per-call and+// per-day signing limits in @cardano402/mcp-server.+//+// All amounts are lovelace (1 ADA = 1_000_000 lovelace). The tracker is+// process-local — it intentionally does NOT persist across restarts, so a+// crashed/restarted MCP server starts from a fresh budget. The audit+// considered persistent forensics out of scope for 0.1.2.++export interface SpendCheckArgs {+ amount: bigint;+ payTo: string;+}++export interface SpendTrackerOptions {+ maxAmountPerCall: bigint;+ maxAmountPerDay: bigint;+ payToAllowlist?: string[];+ /** Window length in milliseconds. Defaults to 24h. Exposed for tests. */+ windowMs?: number;+ /** Clock override (returns ms-since-epoch). Defaults to Date.now. Tests inject. */+ now?: () => number;+}++interface SpendEntry {+ at: number;+ amount: bigint;+}++export class SpendLimitError extends Error {+ constructor(message: string, public readonly code: 'per_call' | 'per_day' | 'pay_to_allowlist') {+ super(message);+ this.name = 'SpendLimitError';+ }+}++/**+ * Track signed-amount spending against per-call and per-day caps.+ *+ * Call `assertCanSpend()` BEFORE handing the amount to the signer. Call+ * `record()` only after the signer returns successfully — that way a failed+ * sign doesn't burn budget against subsequent attempts.+ */+export class SpendTracker {+ private readonly maxPerCall: bigint;+ private readonly maxPerDay: bigint;+ private readonly allowlist: Set<string> | null;+ private readonly windowMs: number;+ private readonly now: () => number;+ private readonly history: SpendEntry[] = [];++ constructor(options: SpendTrackerOptions) {+ this.maxPerCall = options.maxAmountPerCall;+ this.maxPerDay = options.maxAmountPerDay;+ this.allowlist =+ options.payToAllowlist && options.payToAllowlist.length > 0+ ? new Set(options.payToAllowlist)+ : null;+ this.windowMs = options.windowMs ?? 24 * 60 * 60 * 1000;+ this.now = options.now ?? (() => Date.now());+ }++ /** Throws SpendLimitError if signing this amount would breach a limit. */+ assertCanSpend(args: SpendCheckArgs): void {+ if (this.allowlist && !this.allowlist.has(args.payTo)) {+ throw new SpendLimitError(+ `payTo ${args.payTo} is not in the configured allowlist`,+ 'pay_to_allowlist'+ );+ }+ if (args.amount > this.maxPerCall) {+ throw new SpendLimitError(+ `amount ${args.amount.toString()} exceeds per-call cap of ${this.maxPerCall.toString()} lovelace`,+ 'per_call'+ );+ }+ const used = this.spentInWindow();+ if (used + args.amount > this.maxPerDay) {+ throw new SpendLimitError(+ `amount ${args.amount.toString()} would push 24h spend from ${used.toString()} to ${(used + args.amount).toString()}, ` ++ `over the ${this.maxPerDay.toString()} lovelace per-day cap`,+ 'per_day'+ );+ }+ }++ /** Record a successful spend. Idempotent only at the entry level. */+ record(args: SpendCheckArgs): void {+ this.history.push({ at: this.now(), amount: args.amount });+ }@@ -2,7 +2,7 @@ // -> connect to the chosen transport. import { randomUUID } from 'node:crypto';-import { createServer as createHttpServer, type Server as HttpServer } from 'node:http';+import { createServer as createHttpServer, type IncomingMessage, type Server as HttpServer } from 'node:http'; import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';@@ -11,7 +11,9 @@ import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/ import { fetchCatalog } from './catalog.js'; import type { McpServerConfig } from './config.js'; import { VERSION as SERVER_VERSION } from './generated-version.js';+import type { ElicitConfirmation } from './payment.js'; import { createLucidSeedSigner, type CardanoSigner } from './signer.js';+import { SpendTracker } from './spend-tracker.js'; import { registerTools } from './tools.js'; export interface StartResult {@@ -21,6 +23,8 @@ export interface StartResult { signerAddress: string; /** Resolved HTTP listen port (only meaningful when transport is `http`). */ httpPort?: number;+ /** Resolved HTTP listen host (only meaningful when transport is `http`). */+ httpHost?: string; /** Tear down all open transports and HTTP listeners. */ stop(): Promise<void>; }@@ -32,6 +36,8 @@ export interface StartCardano402McpOptions extends McpServerConfig { fetchImpl?: typeof fetch; /** Optional structured logger — defaults to stderr so stdio stdout isn't polluted. */ log?: (message: string, extra?: Record<string, unknown>) => void;+ /** Override the elicitation callback (mainly for tests). */+ elicitOverride?: ElicitConfirmation; } const defaultLog = (msg: string, extra?: Record<string, unknown>): void => {@@ -41,6 +47,30 @@ const defaultLog = (msg: string, extra?: Record<string, unknown>): void => { ); }; +function isLoopbackHost(host: string): boolean {+ if (host === 'localhost' || host === '127.0.0.1' || host === '::1') return true;+ if (host.endsWith('.localhost')) return true;+ if (/^127\./.test(host)) return true;+ return false;+}++function isLoopbackOrigin(origin: string): boolean {+ try {+ const u = new URL(origin);+ return isLoopbackHost(u.hostname);+ } catch {+ return false;+ }+}++function readBearer(req: IncomingMessage): string | null {+ const header = req.headers['authorization'];+ const value = Array.isArray(header) ? header[0] : header;+ if (!value) return null;+ const m = /^Bearer\s+(.+)$/i.exec(value);+ return m ? m[1].trim() : null;+}+ /** * Boot a configured `@cardano402/mcp-server` instance. *@@ -56,6 +86,7 @@ export async function startCardano402Mcp( const catalog = await fetchCatalog(options.catalogUrl, { timeoutMs: options.requestTimeoutMs, fetchImpl: options.fetchImpl,+ allowInsecure: options.allowInsecure, }); log('catalog fetched', { endpoints: catalog.endpoints.length,@@ -77,11 +108,60 @@ export async function startCardano402Mcp( version: SERVER_VERSION, }); + const spendTracker = new SpendTracker({+ maxAmountPerCall: options.maxAmountPerCall,+ maxAmountPerDay: options.maxAmountPerDay,+ payToAllowlist: options.payToAllowlist,+ });++ // Default elicitation threshold = per-call cap, so any signing that+ // saturates the per-call limit requires a user accept. Operators can lower+ // the threshold to require confirmation on smaller amounts.+ const elicitationThreshold =@@ -16,6 +16,21 @@ import { import type { CatalogEndpoint } from './catalog.js'; import type { CardanoSigner } from './signer.js';+import type { SpendTracker } from './spend-tracker.js';++/**+ * Hook called before signing when the requested amount exceeds the+ * configured elicitation threshold. Implementations should surface an+ * MCP `elicitation/create` request to the client and return whether the+ * user accepted. Throwing or returning `false` aborts the signing.+ */+export type ElicitConfirmation = (args: {+ toolName: string;+ amount: bigint;+ asset: string;+ payTo: string;+ network: string;+}) => Promise<boolean>; export interface PayAndFetchOptions { baseUrl: string;@@ -27,6 +42,17 @@ export interface PayAndFetchOptions { signer: CardanoSigner; timeoutMs?: number; fetchImpl?: typeof fetch;+ /** Tracker enforcing per-call/per-day caps. Omit to skip enforcement. */+ spendTracker?: SpendTracker;+ /**+ * Lovelace threshold above which `elicit` must accept before signing.+ * Ignored if `elicit` is undefined.+ */+ elicitationThreshold?: bigint;+ /** Confirmation callback — typically wired to `mcpServer.server.elicitInput`. */+ elicit?: ElicitConfirmation;+ /** Tool name carried into the elicitation prompt; cosmetic only. */+ toolName?: string; } export interface PayAndFetchResult {@@ -44,7 +70,6 @@ export interface PayAndFetchResult { } const X402_HEADER_PRIMARY = 'Payment-Signature';-const X402_HEADER_ALIAS = 'PAYMENT-SIGNATURE'; const X402_RESPONSE_HEADER_PRIMARY = 'x-payment-response'; const X402_RESPONSE_HEADER_ALIAS = 'payment-response'; const X402_REQUIRED_HEADER = 'payment-required';@@ -241,12 +266,38 @@ export async function payAndFetch(options: PayAndFetchOptions): Promise<PayAndFe required.accepts[0]; ensureCatalogMatchesAccept(options.endpoint, accept); + // Pre-sign gates — these run BEFORE signer.signPayment so a failure here+ // never burns wallet UTXOs or daily budget.+ const amount = BigInt(accept.amount);+ if (options.spendTracker) {+ options.spendTracker.assertCanSpend({ amount, payTo: accept.payTo });+ }+ if (options.elicit && options.elicitationThreshold !== undefined && amount > options.elicitationThreshold) {+ const accepted = await options.elicit({+ toolName: options.toolName ?? 'unknown',+ amount,+ asset: accept.asset,+ payTo: accept.payTo,+ network: accept.network,+ });+ if (!accepted) {+ throw new Cardano402ValidationError(+ `signing declined by user via elicitation for amount ${amount.toString()} ${accept.asset} -> ${accept.payTo}`,+ []+ );+ }+ }+ const signed = await options.signer.signPayment({ payTo: accept.payTo,- amount: BigInt(accept.amount),+ amount, asset: accept.asset, ttlSeconds: accept.maxTimeoutSeconds, });+ // Only record AFTER a successful sign — failed signs don't burn budget.+ if (options.spendTracker) {+ options.spendTracker.record({ amount, payTo: accept.payTo });+ } const paymentPayload = PaymentSignaturePayloadSchema.parse({ x402Version: 2 as const,@@ -262,11 +313,12 @@ export async function payAndFetch(options: PayAndFetchOptions): Promise<PayAndFe // PaymentSignaturePayloadSchema has already validated structure (includingAI-assisted change a7938e43f1206a9a16841a2490c334759cdf2fa4c71452755f480805f31dc677 · Fix 9be63e9e7c4dad14f7d64eef0d8d4d9cb1113efa8265da7203ea6c86de54f0b6