Skip to content

fix(x402): validate server payment requirements before signing in pay and eip3009AuthenticatedFetch#11

Open
Nexory wants to merge 1 commit into
ProjectOpenSea:mainfrom
Nexory:fix/x402-validate-payment-requirements
Open

fix(x402): validate server payment requirements before signing in pay and eip3009AuthenticatedFetch#11
Nexory wants to merge 1 commit into
ProjectOpenSea:mainfrom
Nexory:fix/x402-validate-payment-requirements

Conversation

@Nexory

@Nexory Nexory commented Jun 10, 2026

Copy link
Copy Markdown

What

Two client-side x402 paths sign an EIP-3009 transfer authorization using the amount, recipient, and asset from the server's 402 response without validating them. paidFetch already guards this; these two did not. This wires the same guard into both.

Why

The values in an x402 accepts[] entry are server-controlled. paidFetch runs validatePaymentRequirements before signing (rejects a burn-address payTo, defaults asset to the network USDC, optional maxAmount / allowedRecipients), and its JSDoc notes that "a compromised server can request payment to an attacker-controlled address or for an inflated amount." Two other paths skip that:

  • pay (runPaymentOnly) passes the server's requirements straight to signX402Payment (src/cli/commands/pay.ts). A hostile endpoint that tool-sdk pay <url> is pointed at can have the caller sign a transfer of an arbitrary asset and amount to an arbitrary address.
  • eip3009AuthenticatedFetch is documented as signing a zero-value proof of wallet control (src/lib/client/eip3009-auth.ts), but it forwards the server's maxAmountRequired to signX402Payment, so a value-bearing 402 got signed. The sibling smoke command validates asset and amount before calling signX402Payment; this path did not.

Change

  • Export validatePaymentRequirements from x402-payment.ts and call it in runPaymentOnly, with a default 1 USDC cap overridable via a new --max-amount flag (mirrors smoke).
  • In extractPaymentRequirements, only accept a requirement when it is zero-value (maxAmountRequired is "0" or absent); a value-bearing 402 is returned as-is instead of being signed.

Existing behavior is unchanged for honest servers: a USDC requirement within the cap still signs and replays, and a zero-value 402 still produces the auth proof.

Tests

Adds one regression test per path. Against the current code both fail:

eip3009AuthenticatedFetch signs and replays the value-bearing challenge:

AssertionError: expected "spy" to be called 1 times, but got 2 times

pay signs without validation:

AssertionError: promise resolved "Command{...}" instead of rejecting

With the fix both pass and the full suite stays green:

Test Files  39 passed (39)
      Tests  634 passed (634)

The amount, recipient, and asset in an x402 402 response are
server-controlled. `paidFetch` validates them before signing
(validatePaymentRequirements: payTo is not a burn address, asset defaults
to the network USDC, optional maxAmount/allowedRecipients), but two other
client paths did not:

- `pay` (runPaymentOnly) passed the server's requirements straight to
  signX402Payment with no checks, so a hostile endpoint could have the
  caller sign a transfer of an arbitrary asset/amount to an arbitrary
  address. It now runs validatePaymentRequirements with a default 1 USDC
  cap, overridable via --max-amount (mirroring `smoke`).

- eip3009AuthenticatedFetch is documented as signing a zero-value proof of
  wallet control, but it forwarded the server's maxAmountRequired to
  signX402Payment, so a value-bearing requirement was signed. It now signs
  only when the requirement is zero-value; a value-bearing 402 is returned
  as-is.

validatePaymentRequirements is exported so `pay` can reuse it. Adds
regression tests for both paths.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant