Skip to content

feat(sdk): optional replay guard + rate limit for x402Serve() - #93

Merged
Eras256 merged 2 commits into
mainfrom
feat/x402serve-guard
Sep 24, 2026
Merged

Eras256 merged 2 commits into
mainfrom
feat/x402serve-guard

Conversation

@Eras256

@Eras256 Eras256 commented Sep 24, 2026

Copy link
Copy Markdown
Collaborator

Closes the code half of #91: x402Serve() verifies and settles a payment, but on its own does nothing to stop the same signed proof from being replayed or to rate-limit callers. This adds both as an opt-in config, off by default.

What's new

  • X402ServeConfig.guard (optional). No guard at all -> zero change to existing behavior, zero overhead.
  • guard.store (an X402GuardStore - claimOnce/release/slidingWindowHit) turns on single-use protection for the X-PAYMENT/PAYMENT-SIGNATURE proof. Fails closed (503) if the store errors - once you've opted in, a broken store must never silently become "no protection."
  • guard.rateLimit (needs store too) turns on a sliding-window rate limit per caller IP. Fails open if the store errors - a store outage shouldn't become a self-inflicted denial of service.
  • createUpstashX402GuardStore(): a reference X402GuardStore backed by Upstash's REST API (plain fetch, no new dependency). Anyone can implement the same 3-method interface against their own KV/Redis.

Where this design comes from

The store interface, the "release only on non-2xx" behavior, and the fail-closed/fail-open split are generalized from - and credited to, in the code comments and CHANGELOG.md - a real production x402Serve() integrator who built their own version of exactly this: Edgadafi/remesa-liquidez, commit 1e0cbd5902cb224d3c6a2320cc009cf4df84f513 (backend/src/middleware/paymentGuard.ts, backend/src/middleware/rateLimit.ts). The code here is our own, not copied.

One correction to their approach, verified directly against the real published @x402/express package rather than assumed: the guard reads payment-signature (x402 v2) or x-payment (v1 fallback) - the exact same header precedence @x402/express's own middleware uses (dist/cjs/index.js, adapter.getHeader("payment-signature") || adapter.getHeader("x-payment")), not just x-payment alone.

Tests

packages/sdk/src/x402-guard.test.ts (pure unit tests, fake in-memory store) and a new describe block in x402serve-smoke.test.ts (through the real x402Serve()-returned middleware), reproducing exactly the scenarios from the acceptance criteria:

  • a reused payment proof against the same route -> 409 payment_replayed
  • releasing the claim after a non-2xx response allows a legitimate retry with the same proof
  • a proof reused against a different route is allowed (binds proof + method + route)
  • store failure -> 503 replay_protection_unavailable, never silent pass-through
  • rate limit over the window -> 429 rate_limited with Retry-After
  • store failure on rate limit -> allowed (fail-open)
  • no guard config at all -> unchanged behavior

All 56 jest tests + the 2 native node --test WebSocket tests pass locally (npm test in packages/sdk).

CI

This repo had no workflow running packages/sdk's own tests before this (the only existing one, test-verify-audit-cid.yml, is scoped to actions/verify-audit-cid/** and never touches this package). Added .github/workflows/sdk-test.yml (installs with --legacy-peer-deps - @stellar/mpp@0.7.1's peer range vs this package's @stellar/stellar-sdk pin is a pre-existing mismatch, not fixed here) so this and future PRs get a real CI check instead of relying on a local run being reported honestly.

examples/deploy-x402-vercel

Now ships with the guard on by default (in-memory store for the demo, with the caveat that Vercel serverless instances aren't guaranteed to share process state, and the Upstash swap documented in the README) instead of only showing the disabled default.

While touching this example: its nirium dependency was pinned to ^0.10.1 and had been silently resolving to a real npm version 5 minors behind current (0.10.2 vs today's 0.14.1) - same class of drift this project's own AGENTS.md already warns about for the CLI scaffold. Bumped to ^0.14.1, package-lock.json regenerated against the real registry.

Known, expected gap - not resolved by this PR: examples/deploy-x402-vercel imports X402GuardStore from "nirium", which only exists after this PR's SDK changes are published to npm. Its own npm install && npm run typecheck will fail against the currently-published nirium@0.14.1 until that publish happens. Confirmed by typechecking locally against a file: link to this PR's actual packages/sdk source (passes clean) vs. against the real registry version (fails with exactly the expected two TS2614 errors). This PR does not publish to npm - that stays a separate, deliberate, interactive step.

Co-Authored-By: Claude Sonnet 5 noreply@anthropic.com

Eras256 and others added 2 commits September 24, 2026 13:54
Adds `guard` to X402ServeConfig - off by default, so nothing changes
for existing consumers. Providing `guard.store` turns on single-use
protection against a replayed X-PAYMENT/PAYMENT-SIGNATURE proof; adding
`guard.rateLimit` also turns on a sliding-window rate limit per caller
IP. Replay protection fails closed (503) if the store errors; rate
limiting fails open, both for the reasons documented inline.

New: packages/sdk/src/x402-guard.ts (the checks + pluggable
X402GuardStore interface), x402-guard-upstash.ts (a reference Upstash
adapter), x402-guard.test.ts (unit tests) and an integration describe
block in x402serve-smoke.test.ts (the denied paths through the real
x402Serve() middleware).

examples/deploy-x402-vercel now ships with the guard ON by default
(in-memory store, with the Upstash swap documented) instead of only
showing the disabled default. Also bumps that example's stale `nirium`
pin from ^0.10.1 to ^0.14.1 - it had been stuck on a real npm version
5 minors behind current, a pre-existing drift bug found while touching
this file.

Adds .github/workflows/sdk-test.yml: packages/sdk had no CI before
this running its own test suite.

The store interface and the fail-closed/fail-open design are
generalized from - and credited to - a real production x402Serve()
integrator's own implementation: Edgadafi/remesa-liquidez (commit
1e0cbd5902cb224d3c6a2320cc009cf4df84f513). See CHANGELOG.md and #91.

Note: examples/deploy-x402-vercel's own `npm install`/typecheck will
fail against the currently-published nirium@0.14.1 (no X402GuardStore
export yet) until this SDK change is itself published - a known,
expected gap, not something this PR resolves.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…s --experimental-strip-types, which needs Node >=22

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@Eras256
Eras256 merged commit 313ce41 into main Sep 24, 2026
1 check passed
@Eras256
Eras256 deleted the feat/x402serve-guard branch September 24, 2026 19:56
Edgadafi added a commit to Edgadafi/remesa-liquidez that referenced this pull request Sep 25, 2026
…e) (#12)

Problema de seguridad detectado en nirium PR#93:
@x402/express 2.22.0 lee payment-signature (x402v2) primero, cayendo
a x-payment (x402v1). Las guardas paymentHeaderLimits() y
paymentReplayGuard() solo leían x-payment, permitiendo bypass del
límite de tamaño y de la protección anti-replay enviando el proof
en payment-signature.

Cambios:
- paymentGuard.ts: nueva función getEffectivePaymentHeader() con
  precedencia payment-signature → x-payment (misma que @x402/express)
- paymentHeaderLimits(): valida ambos headers, rechaza conflictos y
  duplicados (arrays), aplica límite 8KB al header efectivo
- paymentReplayGuard(): deriva clave de replay del header efectivo,
  cierra bypass cross-header
- app.ts: CORS permite Payment-Signature además de X-PAYMENT
- paymentGuard.test.ts: suite completa cubriendo replay via v2,
  mixing headers, oversized v2, conflictos, precedencia
- package.json: script test, deps @types/supertest + supertest

Tests: 17 passed (replay v1/v2, mixing, oversized, conflictos,
precedencia, binding ruta+método)
Typecheck: pasa sin errores

Crédito: hallazgo de nirium-protocol/nirium#93

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Edgadafi <Edgadafi@users.noreply.github.com>
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