Skip to content

Latest commit

 

History

History
435 lines (363 loc) · 37.5 KB

File metadata and controls

435 lines (363 loc) · 37.5 KB

Security model

SECURITY.md is the policy and the disclosure process. This page is the engineering detail: what the trust boundaries are, and what we deliberately do not defend.

Trust boundaries

       UNTRUSTED                    SEMI-TRUSTED                       TRUSTED
  ────────────────────       ──────────────────────────       ─────────────────────────

  agent input          ────► gateway process            ────► merchant backend
  payment proofs             validates all agent input,       administrator configured,
  authorization proofs       holds no keys                    assumed to be yours
  protocol traffic

                             configuration              ◄──── administrator
                             environment                ◄──── operator

Everything from an agent is untrusted and validated. Configuration is trusted input supplied by whoever runs the gateway - which is why backend URLs may only come from configuration.

Secret handling

Never logged, never persisted, never returned:

  • private keys, seed phrases, mnemonics
  • Authorization headers and backend API secrets
  • the PAYMENT-SIGNATURE header and raw payment authorisation payloads
  • the Agent-Authorization header, AP2 presentations, their disclosures, and the merchant checkout JWT they bind
  • signature, secret, apiKey, signerPrivateKey, adminToken and token fields, at the top level and one level deep (see below)

Enforcement is pino redaction on the logger plus explicit exclusion in the receipt store.

The logger's depth limit is real. fast-redact wildcards match a single level, so REDACT_PATHS covers privateKey and wallet.privateKey but not a.b.privateKey. Nothing leaks today because every call site funnels caught errors through describeError, which extracts only {message, name} - but that is a property of the call sites, not of the redaction config. Do not log a raw object that may carry a secret at depth ≥ 2. The receipt store's redaction (src/storage/receipts/redact.ts) has no such limit: it is a recursive key-pattern strip at every depth. Both have tests. Resolved ${VAR} values are never printed, even in configuration error messages - errors name the variable, not the value.

Authorization trust (AP2)

A separate trust anchor from payment, and a deliberately small one. The key policy, the two issuer lists and the rotation procedure are in ap2.md.

Every AP2 verification key is a public key an operator wrote into config.yaml. The gateway performs no key discovery of any kind: no JWKS endpoint, no jku, no x5u, no issuer metadata fetch, no revocation call. A JWK is validated member by member at load against an allowlist, so private material and anything naming a URL is refused without the check having to name it. That closes an SSRF surface before it exists: no code path lets a presented mandate cause an outbound request, and removing a key from the config is the revocation.

The algorithm comes from local policy, never from the JWT header: ES256 over P-256, one entry, so alg: none and the HMAC family are excluded by construction rather than by a check that has to remember them. iss and kid select which configured key verifies a mandate, and an unrecognised pair is refused - there is no "try every key" fallback that would make kid advisory.

Digests are taken over the bytes that arrived - the compact checkout JWT as presented, and the issuer-signed token - never over a re-serialised object. A normalised payload has a different digest, and hashing it would check a document other than the one being verified.

SSRF

The gateway makes outbound HTTP calls to URLs it was configured with:

  • backend URLs are administrator-controlled configuration only;
  • dynamic, agent- or user-controlled backend URLs are forbidden - no code path constructs a backend URL from request input beyond {param} substitution into a configured template, with each value URL-encoded. A parameter may only appear once the authority is complete: a template whose {param} reaches the scheme, host or port is refused at config load, because caller input would otherwise choose which host the gateway calls and every path-position defence (containment check, encodeURIComponent) is inert there;
  • redirects are not followed (redirect: 'manual'); a 3xx is a BACKEND_ERROR;
  • every call is bounded by an explicit timeout.

Path parameters are additionally checked for dot segments: . and .. are rejected as INPUT_INVALID rather than URL-encoded, because encodeURIComponent does not escape . and the WHATWG URL parser then normalises .. away - which would let a caller remove path segments and reach a parent endpoint the operator never exposed. After substitution the constructed path is asserted to still begin with the template's literal prefix.

Caller input can never override a query parameter the operator baked into backend.url. A collision is rejected, not silently applied - otherwise an input key named after an embedded ?apikey=… would replace it.

Not implemented: an IP/CIDR allowlist or a private-address blocklist. If you configure http://169.254.169.254/…, the gateway will call it. Treat configuration as privileged.

A backend URL produced by agent-commerce import openapi is configuration too: it comes from the document's servers or from --base-url, both supplied by the operator at import time, and it lands in a file a human reviews before it is merged. A relative or unresolvable server URL is refused rather than guessed at. The importer makes no network requests of its own.

Backend response relay

On a non-2xx backend response, the gateway states the status code to the caller (ours to say) but does not forward the backend's response body. A merchant backend in verbose/dev-error mode routinely emits stack traces, internal hostnames or SQL fragments - a free, often-unauthenticated resource call is not a safe place to relay that. The body is truncated (512 chars) and logged at debug for the operator only; it never reaches a client-visible field. A merchant that wants pass-through is a per-resource opt-in, post-alpha.

Input validation

Validated at the boundary, before anything else happens:

Input Check
resource input JSON Schema from the resource definition, closed by default at every level: an object schema - root, nested under properties, nested under items, or nested under an additionalProperties subschema - that omits additionalProperties gets additionalProperties: false stamped on recursively at config load, not just at the root. That enumeration was written from the stamper rather than from the validator and drifted three times; it now matches what compileJsonSchema actually recurses into; an operator who sets it explicitly (including explicitly to true) is respected at whichever level they set it. A resource that declares no input: at all gets an empty closed schema, not an always-valid one - declaring nothing means accepting nothing. Unknown properties, including prototype-named keys (__proto__, constructor, …), are matched by own-property lookup only.
path parameters URL-encoded on substitution
body size capped at 256 KB, one number for both surfaces, enforced in two different places: Fastify's bodyLimit runs inside a body parser on the HTTP routes; /mcp deliberately installs a no-op parser so the MCP transport can read the raw stream, so the mount enforces its own byte count instead. A cap that only protects one of two entry points, or two caps that can silently drift apart, is how /mcp ended up with no cap at all in the first place.
content type JSON enforced on the invoke routes. Not on /mcp, where a wildcard no-op parser hands the raw stream to the MCP SDK and the SDK does its own enforcement.
payment proof decoded and schema-validated by the payment provider; a malformed proof is a rejection, never a crash
configuration Zod, strict, before startup

The reserved _payment field is stripped from tool input before schema validation, so it can never collide with a resource's own properties.

Payment security

Covered in detail in payment-flow.md. The invariants:

  1. Paid resources fail closed - thirteen distinct failure conditions, each with a test, none of which delivers the resource.
  2. verify has no fund-moving side effects; only settle does.
  3. Replay is defended twice: on-chain via EIP-3009 authorizationState, and in the gateway via a replayKey reserved under a UNIQUE constraint before settlement. The key is derived from the authorisation, not the request, so a replay against a different request still collides.
  4. The gateway holds no buyer or merchant key. The signed authorisation names the merchant as recipient, so a broadcaster cannot redirect funds.
  5. The effective settlement destination is visible in /.well-known/agent-commerce and in doctor, so misconfiguration is noticeable rather than silent.

Authentication and exposure

The surface splits by audience, and the split is enforced, not advisory:

Route Audience Protection
POST /api/resources/:id/invoke agents payment, not authentication
/mcp agents payment, not authentication
GET /api/resources, /health, /.well-known/agent-commerce anyone none - public by design
GET /ready operators none, but detail is a fixed vocabulary, never raw errors
GET /api/receipts, /api/events, /api/events/stream operators server.adminToken, compared in constant time
GET /.well-known/acp.json anyone none - public by design, and carries no configuration
/acp/checkout_sessions… agents protocols.acp.auth.token, compared in constant time

The operator routes carry the merchant's commerce ledger. With no server.adminToken configured they return 404, not open data - a missing control must not read as an absent restriction.

Browser access uses server.allowedOrigins, an explicit allowlist defaulting to empty. Origin reflection was removed: reflecting the caller's own Origin makes every route cross-origin readable from any page the operator happens to visit, which is the same thing as having no policy. Agent traffic gets no CORS headers at all, because it is not browser traffic.

Origin and Host are validated in one place, the gateway's onRequest hook, so the MCP mount is covered by the same rule as the HTTP routes - including against DNS rebinding at a hostname resolving to 127.0.0.1.

Every published port in docker-compose.yml binds 127.0.0.1. Port 8545 in particular is an Anvil node with unlocked accounts and the full anvil_* admin namespace; on a shared network that would be unauthenticated control of the dev chain.

ACP

ACP is the one agent-facing surface that is authenticated: every checkout route requires Authorization: Bearer <token>, compared in constant time against protocols.acp.auth.token. Both sides are hashed before comparison, so a wrong token costs the same whatever its length. Authentication runs before the request body is read, so an unauthenticated caller cannot make the adapter buffer or parse anything.

Discovery at /.well-known/acp.json stays public and carries no configuration: no bearer token, no backend URL, no mapped resource ids, no idempotency database path. Only the digest of the token is ever persisted - the idempotency store scopes keys by SHA-256("acp-auth:" + token), never by the token itself.

Signature and Timestamp verification are not implemented, are not advertised, and a Signature header is never accepted in place of the bearer token. Deploy ACP behind TLS: a bearer token on a plaintext connection is a shared secret in the clear.

payment_data on a completion is buyer payment material belonging to the merchant's own flow. It passes through to the merchant backend as ordinary business input and is never logged, never stored in a receipt, and never converted into an Agent Commerce payment proof. Delegated payment and delegated authentication are not implemented, so this feature adds no handling of raw card credentials anywhere in the gateway.

What the gateway relays, and what it does not

The gateway does not forward the merchant backend's own error body to callers. A backend in verbose or development error mode routinely emits stack traces, internal hostnames and SQL fragments; relaying them would make the gateway a pass-through for someone else's internals to whoever called a free resource. The backend's status code is returned - that is ours to state and useful - and the body is logged server-side at debug level only.

The same rule governs health and readiness detail: a fixed vocabulary on the wire, raw messages to the log; the rule generalises to any value crossing a trust boundary.

Denial of service

Not a focus of this release, but more is in place than this section used to list. What exists:

  • request body-size cap, enforced inside the body parsers
  • a 1 MB cap on the merchant backend's response - AbortSignal.timeout bounds a backend call by time, not by bytes
  • explicit backend timeouts on every outbound call
  • a bounded list limit on every receipts/events query, applied in the store
  • a cap on concurrent SSE subscribers
  • on /mcp: a counting semaphore bounding concurrent tool calls (8) plus a bounded queue (64) - over that, GATEWAY_BUSY rather than unbounded growth
  • readiness memoisation and single-flight, so /ready polling cannot amplify into one upstream RPC call per request
  • X-Request-Id accepted only as [A-Za-z0-9._:-]{1,64}, so a caller cannot write an unbounded string into every audit row
  • an 8192-byte cap on the Agent-Authorization header (MAX_AUTHORIZATION_HEADER_BYTES), checked on the raw header before it is base64url-decoded or parsed as JSON. Over the limit is AUTHORIZATION_INVALID, and nothing from the caller's value is echoed back. The cap is on the encoded header rather than on the decoded proof because base64url expands by 4/3, so a decode-first check would have to allocate the oversized string first. A real Direct Checkout Mandate presentation is well inside it; MCP and A2A carry the same proof in the request body instead and are bounded by the body-size cap

What does not exist: rate limiting, per-agent quotas, adaptive backpressure. Put the gateway behind your own edge if you expose it publicly.

Dependencies

@x402/core, @x402/evm, @modelcontextprotocol/sdk, viem, fastify, pino and better-sqlite3 are third-party code, pinned exactly. npm audit runs in the release workflow; high and critical findings are assessed and documented before a release rather than auto-blocking on irrelevant transitive advisories.

What a consumer actually installs, audited against the published tarball:

Install npm audit
the package alone 0 vulnerabilities
plus @modelcontextprotocol/sdk, @x402/core, @x402/evm, viem 0 vulnerabilities
plus @coinbase/x402 (only for auth.type: cdp) 2 - 1 high, 1 moderate

The whole delta is CDP: @coinbase/x402@coinbase/cdp-sdkaxios, which carries a set of high-severity advisories, plus a Solana client tree this project has no use for. It is an optional peer, imported dynamically only when that auth type is configured, so nobody else pays for it - and auth.type: bearer covers any facilitator with a static token and installs nothing. This is stated rather than buried because the affected path is the one handling real money.

Development keys

The repository contains Anvil's well-known development accounts, used only on the local demo chain and labelled LOCAL DEVELOPMENT ONLY - DO NOT FUND. They are public knowledge and anyone can spend from them. Never send real assets to those addresses, and never reuse them anywhere else.

Two independent checks refuse them where it would matter: a dev key may not sign against an RPC that does not look local or private, and a dev address may not be the settlement destination on any non-local deployment. The second is the consequential one - a wrong key merely fails to sign, a wrong payTo succeeds and gives the money away.

Mainnet

eip155:8453 is refused unless the configuration says so in full: an explicit allowMainnet, a remote facilitator reached over HTTPS, a second explicit acknowledgement (allowUnauthenticatedFacilitator) if that facilitator takes no credential, a non-development payTo, and the canonical USDC for the chain including the EIP-712 domain name it reports. All of it is checked at config load, so the gateway does not start otherwise and agent-commerce validate reports it without starting anything.

A facilitator cannot redirect your money - an EIP-3009 authorisation names its recipient, its amount and its chain, so it can broadcast exactly that transfer or nothing. What it can do is see every authorisation you handle, and stop answering. That is why an unauthenticated one is a separate, explicit acknowledgement rather than a warning.

The in-process facilitator is never allowed on a mainnet. To be precise about why: it is not a custody problem - the facilitator signer never holds buyer or merchant funds, it pays gas and broadcasts transferWithAuthorization, and the money moves buyer to merchant directly on-chain. It is a funded key inside the resource server, so compromising that process means draining the gas wallet and broadcasting arbitrary transactions from it. With a remote facilitator the gateway holds no signing key at all.

Adversarial scenarios, and where each is tested

Every row below has an executed test, not a claim. Nothing here is asserted by reading a log line: settlement outcomes are read back off the chain, and rejection outcomes assert that balances did not move.

Scenario Outcome Where
header tampering (PAYMENT-SIGNATURE mangled, wrong header, absent) 402, no delivery tests/unit/gateway, tests/e2e/payment
payload tampering (any field of the authorisation) PAYMENT_INVALID tests/e2e/payment, tests/unit/payments-x402
network substitution wrong_network before settlement tests/e2e/payment
asset substitution (a real but different token) wrong_asset before settlement tests/e2e/payment
payTo substitution wrong_recipient before settlement tests/e2e/payment, testnet suite
amount manipulation (below the price) wrong_amount before settlement tests/e2e/payment
replay, sequentially refused, no second transfer tests/e2e/payment, mainnet suite
duplicate concurrent request settles once, other gets PAYMENT_REPLAYED tests/integration/adversarial-payment.test.ts
replay after a gateway restart still refused - the reservation is in SQLite same
expired authorisation (validBefore in the past) refused before settlement tests/e2e/payment
not-yet-valid authorisation (validAfter in the future) refused before settlement tests/e2e/payment
a {param} in the host position of backend.url refused at config load tests/unit/config/schema.test.ts
an unknown key nested under an additionalProperties schema rejected by the closed schema same
a hostile or unbounded facilitator rejection string clamped before it reaches buyer, event or ledger tests/unit/payments-x402
facilitator timeout PAYMENT_PROVIDER_UNAVAILABLE, settlement treated as uncertain tests/unit/payments-x402
facilitator 401 / 5xx PAYMENT_PROVIDER_UNAVAILABLE, never charged to the buyer tests/integration/adversarial-payment.test.ts
malformed facilitator response refused; never read as a verdict same
backend timeout BACKEND_TIMEOUT tests/unit/core/execution
backend 500 after payment receipt records paid-and-undelivered; payer told it settled tests/unit/gateway, tests/unit/core/execution
receipt-store failure STORAGE_ERROR, never mislabelled PAYMENT_REPLAYED tests/unit/storage-receipts
a local reader racing the ledger's creation database and sidecars are 0600 from the moment SQLite opens them tests/unit/storage-receipts/permissions.test.ts
RPC unreachable during verify PAYMENT_PROVIDER_UNAVAILABLE, not "bad signature" tests/unit/payments-x402
an external $ref in an imported document refused; zero outbound requests tests/unit/openapi/load.test.ts
an imported path value naming another host percent-encoded into one segment of the configured origin tests/integration/openapi-import.test.ts
an imported query group colliding with a pinned backend query INPUT_INVALID before payment; nothing settled same
an imported operation with an unsupported required parameter never becomes a resource at all same
an ACP request with no, wrong or non-bearer authorisation 401 before the body is read; zero merchant calls tests/conformance/acp/protocol.test.ts
an ACP request naming an unsupported API version 400 naming supported_versions; never mapped to the pinned one same
an ACP POST with no or an over-long Idempotency-Key 400 before the body is read same
an ACP key replayed while the first request is in flight 409; the merchant is called exactly once tests/conformance/acp/idempotency.test.ts
an ACP key reused with a different body 422; the merchant is called exactly once same
an ACP completion retried after a 5xx not cached; the clean retry runs same
a merchant answering an ACP route with a non-ACP document refused as processing_error; its body never forwarded tests/conformance/acp/errors.test.ts
a merchant leaking a connection string or stack in an error body never relayed; the ACP error carries type, code and message only same
an ACP Request-Id carrying a header-injection payload dropped, never echoed tests/unit/protocols-acp/adapter.test.ts
an ACP checkout resource configured as paid refused at config load; payment-required at runtime is a 500 tests/unit/config/schema.test.ts
an AP2 mandate with a tampered signature AUTHORIZATION_INVALID; nothing settles tests/integration/ap2-x402-conformance.test.ts
an expired AP2 mandate AUTHORIZATION_INVALID; nothing settles same
a mandate from an issuer that is not configured refused at the trust allowlist, before any signature check same, tests/unit/authorization-ap2
a mandate claiming a trusted kid but signed with another key refused at the signature; kid selects the key, never labels it same
a mandate naming a kid the issuer does not have refused; no "try every key" fallback same
a mandate whose checkout_hash does not match its checkout JWT checkout_binding_failed; nothing settles same
a mandate approved for another resource, input, amount, currency, payment method, network or asset purchase_mismatch, one coarse reason; nothing settles same
a mandate silent about the chain the requirement names refused - fail closed both ways same
a mandate presented twice AUTHORIZATION_REPLAYED; the second purchase moves no funds same, tests/e2e/authorization
the same mandate re-presented with a fresh, valid payment proof still refused; balances unchanged on a real chain tests/e2e/authorization
a mandate replayed under selective disclosure (one mandate, many presentation strings) refused - the replay key is the issuer-signed token, not the presentation tests/unit/authorization-ap2
the AP2 replay store unreachable AUTHORIZATION_PROVIDER_UNAVAILABLE, retryable, never the buyer's fault tests/integration/ap2-runtime.test.ts
a payment rejected after a mandate verified the reservation is released; a corrected proof reuses the mandate tests/integration/ap2-x402-conformance.test.ts
a settlement broadcast but never confirmed the mandate is not handed back; marked uncertain for an operator same
a free resource configured to require a mandate refused at config load, and again on the execution path tests/unit/config/ap2.test.ts, tests/unit/core/execution
an oversized Agent-Authorization header AUTHORIZATION_INVALID before any decode; nothing echoed back tests/integration/authorization-carrier.test.ts

Two of those exist because writing them found a bug. The SDK's exact/EVM scheme reports an unreachable node as invalid_exact_evm_signature, and its HTTP facilitator client throws a bare Error for a 401 or 5xx - both would have recorded a failure of ours as the payer's fault, and burned an authorisation nothing had checked. The provider now treats any throw out of a facilitator call as "no verdict obtained", because a verdict arrives as a returned value.

OpenAPI import

The importer reads a local, operator-supplied document and writes a file for review. It is not on the request path, and after import nothing reads the document again.

  • No network, no filesystem walk. There is no remote source option, and every $ref is checked before the validator sees the document: an external reference (https://…, ./types.yaml, file:///…) is refused by name. A document that names one cannot cause a single outbound request. Asserted with a stubbed fetch in tests/unit/openapi/load.test.ts and again end to end in tests/integration/openapi-import.test.ts.
  • Bounded work. A source over 10 MiB is refused before parsing. Internal references are resolved lazily with cycle detection and a depth bound, so a recursive schema is a diagnostic rather than an out-of-memory kill.
  • No credential is ever imported. An operation declaring OpenAPI security produces a warning and a review comment pointing at backend.headers: { Authorization: Bearer ${BACKEND_TOKEN} }; the scheme name, header name and any example value stay out of the generated file. A security scheme never becomes agent-supplied input, and Accept, Content-Type and Authorization header parameters are ignored per the specification.
  • The document is data. Descriptions, examples and vendor x- extensions are serialised into YAML or dropped. Nothing in a document is executed, and no x- extension can alter pricing, payments, the backend URL or protocol exposure - the generator reads only the fields it knows.
  • Nothing is overwritten. An existing output file stops the run unless --force is passed, a failed run writes nothing at all, and the write is a temp sibling plus rename so a crash cannot leave a partial file.
  • Commerce policy is never inferred. Without --free / --expose the generated fragment has no pricing or expose and will not load, so no operation becomes purchasable or agent-visible without a human deciding so.
  • Imported resources are not privileged. They are ordinary CommerceResource entries: same config validation, same execution pipeline, same pre-payment request-shape checks. tests/integration/openapi-import.test.ts drives one over HTTP, MCP and A2A and asserts all three produce the identical merchant request.

Threats we are not addressing

Buyer identity and screening · fraud and disputes · refunds and chargebacks · multi-tenancy and RBAC · host compromise · supply-chain attestation · side-channel and timing analysis · protocol-level censorship or MEV around settlement · availability guarantees · a malicious facilitator withholding settlement (it cannot redirect funds, but it can decline to broadcast, and fail-closed means the resource is simply not delivered).