Skip to content

fix(security): verify payments locally and stop caching paid responses - #11

Merged
Ashutosh0x merged 1 commit into
mainfrom
security/local-verify-and-no-store
Sep 25, 2026
Merged

Ashutosh0x merged 1 commit into
mainfrom
security/local-verify-and-no-store

Conversation

@Ashutosh0x

Copy link
Copy Markdown
Contributor

Closes #4. Partially addresses #6.

#6 — gate() no longer delegates the whole decision

gate() forwarded the payload to facilitator.settle() and released on
success, checking nothing itself. Since cfg.facilitator is a documented
option, "the hosted one" and "any third party's" are the same code path, so any
weakness there was a full bypass with no second line.

verifyLocally() now checks the payload against the requirements this server
built — payTo, amount (>=, so overpaying is fine), scheme, network,
x402Version, the validity window, and basic shape.

Two things worth calling out:

  • It runs before settle. A mismatched payment is never submitted, rather
    than settled on-chain for a resource that is then refused.
  • The tests use a facilitator stub that approves everything, so each refusal
    is demonstrated with the facilitator actively saying yes. A test with an
    honest facilitator would pass whether or not these checks existed.

Address comparison is chain-aware: EVM addresses are hex and case-insensitive
(EIP-55 checksumming means the same address legitimately arrives in different
cases), while Solana addresses are base58 where case is significant and
lowercasing would make two different addresses compare equal.

What this does not close

These are field checks — not a signature check, and not a resource binding. The
exact scheme's authorization carries no resource, so a payment minted for
resource A still satisfies every field of resource B
on the same server at the
same price. Cross-resource substitution needs a binding the payer signs over
(F1 in @furlpay/x402-guard). #6 also asks for middleware-level
replay/idempotency, which needs a nonce store this package does not have.

Both gaps are documented in the README rather than left implied by a passing
check, so I have left #6 open.

#4 — paid responses are no longer cacheable

Both adapters now stamp Cache-Control: no-store, private and
Vary: X-PAYMENT, on the 402 challenge as well as the 200 — a cached challenge
is a cached quote. withX402 sets rather than appends, because a handler that
chose its own cache policy chose it without knowing the response was paid for.

Test fixtures

Three pre-existing fixtures were { x402Version: 1 } — no scheme, no network,
no authorization. They exercised the settle path only because gate() forwarded
whatever arrived; no real facilitator could have settled them either. They are
now realistic payments.

settlement-boundary.test.js needed the same change, and its own anti-vacuity
guard is what caught it
— the strength sweep stopped reaching a release at all
and the test said so rather than passing empty.

Verified

npm test   45 tests, 45 pass, 0 fail   (28 pre-existing + 17 new)

Zero new dependencies.

Closes #6 (Attack II, partial) and #4 (Attack III).

LOCAL VERIFICATION (#6)

gate() forwarded the payload to facilitator.settle() and released the resource
on success, doing no checks of its own. That made the facilitator the entire
security boundary — and `cfg.facilitator` is a documented, first-class option, so
"the hosted one" and "any third party's" are the same code path. Any weakness
there was a full bypass with no second line.

verifyLocally() now checks the presented payload against the requirements this
server built, before the facilitator sees it:

  payTo         is the authorization actually addressed to us
  amount        value >= maxAmountRequired (overpaying is the payer's business)
  scheme        matches the challenge
  network       matches the challenge
  x402Version   checked first, because a payload from another version may use
                the same field names for different things
  validity      expired, or not-yet-valid beyond a 5s skew allowance
  shape         a signature and an authorization object are present

Running it BEFORE settle matters as much as the checks: a mismatched payment is
now never submitted at all, rather than settled on-chain for a resource that is
then refused. The tests use a facilitator stub that approves everything, so each
refusal is demonstrated with the facilitator actively saying yes — a test with an
honest facilitator would pass whether or not these checks existed.

Address comparison is chain-aware. EVM addresses are hex and case-insensitive,
so EIP-55 checksumming means the same address legitimately arrives in different
cases and a byte comparison would reject correct payments. Solana addresses are
base58, where case is significant and lowercasing would make two DIFFERENT
addresses compare equal. Blanket-lowercasing is wrong on one chain or the other,
so the shape decides.

`skipLocalVerification` exists for a scheme this package cannot interpret. It is
named for what it does rather than something reassuring, because setting it
restores the behaviour where a facilitator weakness is a full bypass.

NOT CLOSED BY THIS. These are field checks, not a signature check and not a
resource binding. The `exact` scheme's authorization carries no resource, so a
payment minted for resource A still satisfies every field of resource B on the
same server at the same price. Cross-resource substitution needs a binding the
payer signs over — F1 in @furlpay/x402-guard — and #6 also asks for middleware
replay/idempotency, which needs a nonce store this package does not have. Both
are called out in the README rather than implied by a passing check.

CACHE-CONTROL (#4)

Neither adapter set any caching policy, so a gated route behind a CDN or reverse
proxy could have its PAID response cached and replayed to later UNPAID clients —
one payment, unlimited grants, with the payment layer never involved.

Both adapters now stamp `Cache-Control: no-store, private` and
`Vary: X-PAYMENT`, on the 402 challenge as well as the 200. A cached challenge
is a cached quote, and every payer served it would be answering the same one.
withX402 SETS rather than appends: a handler that chose its own cache policy
chose it without knowing the response was paid for.

TEST FIXTURES

Three pre-existing fixtures were `{ x402Version: 1 }` — no scheme, no network,
no authorization. They exercised the settle path only because gate() checked
nothing and forwarded whatever arrived; no real facilitator could have settled
them either. They are now realistic payments. settlement-boundary.test.js needed
the same change, and its own anti-vacuity guard is what caught it: the strength
sweep stopped reaching a release at all, and the test said so rather than
passing empty.

VERIFIED

  npm test  45 tests, 45 pass, 0 fail  (28 pre-existing + 17 new)

Zero new dependencies.
@Ashutosh0x
Ashutosh0x merged commit 69a5554 into main Sep 25, 2026
1 of 4 checks passed
@Ashutosh0x
Ashutosh0x deleted the security/local-verify-and-no-store branch September 25, 2026 12:57
Ashutosh0x added a commit that referenced this pull request Sep 25, 2026
Completes #6. The field checks that landed in #11 covered payTo, amount,
scheme, network, version and validity; these are the two halves those checks
structurally cannot reach.

RESOURCE BINDING

Cross-resource substitution is invisible to field validation. A payment for /a
and a payment for /b, at the same price on the same server, differ in NO field
the authorization carries — the `exact` scheme does not name a resource. Every
check passes on both, because every field genuinely matches.

`bindingSecret` makes the server mint a quote when it issues the 402 and require
it back with the payment. The quote is HMAC'd over resource, price, recipient
and network, so a payer can echo one but cannot mint one for another route, and
a quote stops verifying the moment any of those terms change. It rides in
`PaymentRequirements.extra` outbound and `PaymentPayload.extra` inbound, both of
which the spec already carries — no wire-format change.

The signature is checked before any claim is read out of the body: a forged
quote's contents are attacker-controlled, and comparing them first would be
deciding on unauthenticated data. Comparison is constant-time and
length-guarded, because timingSafeEqual throws on a length mismatch and that
throw is itself a timing signal.

Opt-in, because requiring payers to echo a quote is a protocol change for them —
not because it is optional in any security sense.

SINGLE-USE CLAIMS

Replay resistance was entirely the facilitator's; the paper observed 248 grants
from one payment against a facilitator that did not linearize. `gate()` now
claims each payment before settling, so concurrent copies cannot both reach the
settler.

THE RELEASE RULE IS THE DESIGN:

  success            keep    the payment is spent
  definite failure   RELEASE no money moved; locking a payer out of their own
                             unspent authorization is a penalty the protocol
                             does not have
  not deep enough    RELEASE that 402 invites a retry, and holding the claim
                             would make "wait and try again" the one thing the
                             payer cannot do
  threw / timed out  KEEP    UNKNOWN IS NOT "DID NOT HAPPEN". The transaction
                             may have landed and the response been lost;
                             releasing re-opens replay for the payment most
                             likely to have actually settled

That last branch is the one that is tempting to get wrong — releasing on a
timeout feels tidy and is the dangerous option.

Claim keys are network-scoped (a nonce is only unique within its chain) and
quote-scoped when binding is on, so the key follows the SERVER-issued id rather
than a nonce the payer chooses and could vary at will.

The default store is in-memory and documented as single-instance only: its
atomicity is Node being single-threaded, which is a real guarantee in one
process and worth nothing across two workers. `claimStore` accepts a shared
implementation; `singleUse: false` opts out entirely.

VERIFIED

  npm test  66 tests, 66 pass, 0 fail  (45 before, 21 new)

Zero new dependencies — binding uses node:crypto.
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.

Paid responses are cacheable — set Cache-Control: no-store (Attack III)

1 participant