feat(sdk): optional replay guard + rate limit for x402Serve() - #93
Merged
Merged
Conversation
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>
This was referenced Sep 24, 2026
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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). Noguardat all -> zero change to existing behavior, zero overhead.guard.store(anX402GuardStore-claimOnce/release/slidingWindowHit) turns on single-use protection for theX-PAYMENT/PAYMENT-SIGNATUREproof. Fails closed (503) if the store errors - once you've opted in, a broken store must never silently become "no protection."guard.rateLimit(needsstoretoo) 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 referenceX402GuardStorebacked by Upstash's REST API (plainfetch, 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, commit1e0cbd5902cb224d3c6a2320cc009cf4df84f513(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/expresspackage rather than assumed: the guard readspayment-signature(x402 v2) orx-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 justx-paymentalone.Tests
packages/sdk/src/x402-guard.test.ts(pure unit tests, fake in-memory store) and a newdescribeblock inx402serve-smoke.test.ts(through the realx402Serve()-returned middleware), reproducing exactly the scenarios from the acceptance criteria:409 payment_replayed503 replay_protection_unavailable, never silent pass-through429 rate_limitedwithRetry-Afterguardconfig at all -> unchanged behaviorAll 56 jest tests + the 2 native
node --testWebSocket tests pass locally (npm testinpackages/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 toactions/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-sdkpin 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-vercelNow 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
niriumdependency was pinned to^0.10.1and had been silently resolving to a real npm version 5 minors behind current (0.10.2vs today's0.14.1) - same class of drift this project's ownAGENTS.mdalready warns about for the CLI scaffold. Bumped to^0.14.1,package-lock.jsonregenerated against the real registry.Known, expected gap - not resolved by this PR:
examples/deploy-x402-vercelimportsX402GuardStorefrom"nirium", which only exists after this PR's SDK changes are published to npm. Its ownnpm install && npm run typecheckwill fail against the currently-publishednirium@0.14.1until that publish happens. Confirmed by typechecking locally against afile:link to this PR's actualpackages/sdksource (passes clean) vs. against the real registry version (fails with exactly the expected twoTS2614errors). This PR does not publish to npm - that stays a separate, deliberate, interactive step.Co-Authored-By: Claude Sonnet 5 noreply@anthropic.com