From e2798ece7a8609b2142031c7e7e617c674bf9c0c Mon Sep 17 00:00:00 2001 From: Eras256 Date: Thu, 24 Sep 2026 13:54:17 -0600 Subject: [PATCH 1/2] feat(sdk): optional replay guard + rate limit for x402Serve() 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 --- .github/workflows/sdk-test.yml | 34 ++++ examples/deploy-x402-vercel/README.md | 32 +++ .../lib/x402-guard-memory-store.ts | 43 ++++ .../deploy-x402-vercel/lib/x402-handler.ts | 31 ++- examples/deploy-x402-vercel/package-lock.json | 12 +- examples/deploy-x402-vercel/package.json | 2 +- packages/sdk/CHANGELOG.md | 11 ++ packages/sdk/src/index.ts | 39 ++++ packages/sdk/src/x402-guard-upstash.ts | 50 +++++ packages/sdk/src/x402-guard.test.ts | 158 +++++++++++++++ packages/sdk/src/x402-guard.ts | 183 ++++++++++++++++++ packages/sdk/src/x402serve-smoke.test.ts | 89 +++++++++ 12 files changed, 676 insertions(+), 8 deletions(-) create mode 100644 .github/workflows/sdk-test.yml create mode 100644 examples/deploy-x402-vercel/lib/x402-guard-memory-store.ts create mode 100644 packages/sdk/CHANGELOG.md create mode 100644 packages/sdk/src/x402-guard-upstash.ts create mode 100644 packages/sdk/src/x402-guard.test.ts create mode 100644 packages/sdk/src/x402-guard.ts diff --git a/.github/workflows/sdk-test.yml b/.github/workflows/sdk-test.yml new file mode 100644 index 0000000..4013739 --- /dev/null +++ b/.github/workflows/sdk-test.yml @@ -0,0 +1,34 @@ +# There was no CI running packages/sdk's own tests before this - the repo's +# only prior workflow (test-verify-audit-cid.yml) is scoped exclusively to +# actions/verify-audit-cid/**. Added alongside x402Serve()'s guard feature +# (issue #91) so "tests passing in CI" is a real, checkable fact for this +# package going forward, not just a local run. +name: SDK Tests +on: + pull_request: + paths: + - packages/sdk/** + - .github/workflows/sdk-test.yml + push: + branches: + - main + paths: + - packages/sdk/** + - .github/workflows/sdk-test.yml +jobs: + test: + runs-on: ubuntu-latest + defaults: + run: + working-directory: packages/sdk + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 20 + # --legacy-peer-deps: @stellar/mpp@0.7.1 peer-requires + # @stellar/stellar-sdk@^15.1.0 while this package pins ^14.5.0 - a + # pre-existing mismatch, unrelated to this workflow, not fixed here. + - run: npm install --no-audit --no-fund --legacy-peer-deps + - run: npm run build + - run: npm test diff --git a/examples/deploy-x402-vercel/README.md b/examples/deploy-x402-vercel/README.md index 9af8bbd..b2d8fdd 100644 --- a/examples/deploy-x402-vercel/README.md +++ b/examples/deploy-x402-vercel/README.md @@ -21,6 +21,38 @@ A minimal, zero-config template for deploying pay-per-request APIs protected by --- +## Replay/rate-limit protection (on by default in this template) + +`x402Serve()` on its own verifies and settles a payment; it does not stop the same payment proof from being replayed, and it does not rate-limit callers - see [nirium-protocol/nirium#91](https://github.com/nirium-protocol/nirium/issues/91). This template turns both on via `x402Serve()`'s `guard` config, using `lib/x402-guard-memory-store.ts` (a plain in-memory store): + +```ts +const serveMiddleware = x402Serve({ + payTo, network, + routes: { [resource]: { price: priceUsdc, description } }, + guard: { + store: defaultGuardStore, // in-memory here, swap for Upstash below + rateLimit: { max: 30, windowMs: 60_000 }, // 30 req/min per IP + }, +}); +``` + +A reused `PAYMENT-SIGNATURE` header against the same route gets `409 payment_replayed`; too many requests from one IP get `429 rate_limited`; if the guard's store is down, requests with a payment proof get `503` rather than being let through unprotected. + +**The in-memory store is a demo convenience, not production-durable**: Vercel serverless functions aren't guaranteed to reuse the same process between requests, so a replay hitting a different instance than the original won't be caught. For real production use, swap it for Upstash (the store `nirium` ships a reference adapter for): + +```ts +import { createUpstashX402GuardStore } from "nirium"; + +const store = createUpstashX402GuardStore({ + url: process.env.UPSTASH_REDIS_REST_URL!, + token: process.env.UPSTASH_REDIS_REST_TOKEN!, +}); +``` + +Pass `guard: false` in a route's config to disable it entirely, or set `GUARD_STORE=none` to disable the template's default in-memory store globally. + +--- + ## Testing Your Live Deployment ### 1. Request without payment (402 Payment Required) diff --git a/examples/deploy-x402-vercel/lib/x402-guard-memory-store.ts b/examples/deploy-x402-vercel/lib/x402-guard-memory-store.ts new file mode 100644 index 0000000..3fc6698 --- /dev/null +++ b/examples/deploy-x402-vercel/lib/x402-guard-memory-store.ts @@ -0,0 +1,43 @@ +import type { X402GuardStore } from "nirium"; + +/** + * In-memory reference X402GuardStore - for this demo template only. + * + * Vercel serverless functions are NOT guaranteed to reuse the same process + * between invocations (cold starts happen, and traffic can fan out across + * multiple concurrent instances), so this store's protection is + * best-effort here: a replay hitting a different instance than the + * original request won't be caught. It exists to show the shape of + * `config.guard` turned ON without requiring you to set up Upstash just to + * try the template. + * + * For real production use, swap this for `createUpstashX402GuardStore()` + * (exported by the `nirium` package) so every instance shares the same + * durable store - see the README section on this. + */ +export function createMemoryX402GuardStore(): X402GuardStore { + const claims = new Map(); + const hits = new Map(); + + return { + async claimOnce(key, ttlMs) { + const now = Date.now(); + const expiresAt = claims.get(key); + if (expiresAt !== undefined && expiresAt > now) return false; + claims.set(key, now + ttlMs); + return true; + }, + async release(key) { + claims.delete(key); + }, + async slidingWindowHit(key, windowMs, member) { + const now = Date.now(); + const windowStart = now - windowMs; + const existing = (hits.get(key) ?? []).filter((ts) => ts > windowStart); + existing.push(now); + hits.set(key, existing); + void member; // unused here - Upstash's ZADD needs a unique member, this doesn't + return existing.length; + }, + }; +} diff --git a/examples/deploy-x402-vercel/lib/x402-handler.ts b/examples/deploy-x402-vercel/lib/x402-handler.ts index d876ccb..2147eab 100644 --- a/examples/deploy-x402-vercel/lib/x402-handler.ts +++ b/examples/deploy-x402-vercel/lib/x402-handler.ts @@ -1,4 +1,12 @@ -import { x402Serve } from "nirium"; +import { x402Serve, type X402GuardStore } from "nirium"; +import { createMemoryX402GuardStore } from "./x402-guard-memory-store.ts"; + +// One shared store across warm invocations of this module - see the +// caveat in x402-guard-memory-store.ts about cold starts / multiple +// instances. This is what turns x402Serve()'s optional guard ON by +// default in this template; set GUARD_STORE=none to turn it off. +const defaultGuardStore: X402GuardStore | null = + process.env.GUARD_STORE === "none" ? null : createMemoryX402GuardStore(); export type X402RouteConfig = { payTo?: string; @@ -8,6 +16,12 @@ export type X402RouteConfig = { description?: string; facilitatorUrl?: string; facilitatorApiKey?: string; + /** + * Replay/rate-limit protection for x402Serve() - on by default in this + * template (in-memory store, see the caveat above). Pass `false` to + * disable for a single route, or swap `store`/`rateLimit` for your own. + */ + guard?: false | { store?: X402GuardStore; rateLimit?: { max: number; windowMs: number } }; }; /** @@ -44,6 +58,16 @@ export function withX402Protection( req.headers.get("PAYMENT-SIGNATURE") || req.headers.get("x-payment-signature"); + // Guard is ON by default in this template (config.guard !== false) - + // per-request X-PAYMENT reuse gets 409, and a default rate limit of + // 30 requests/minute per IP applies unless you override it. + const guardConfig = config.guard === false + ? undefined + : { + store: config.guard?.store ?? defaultGuardStore ?? undefined, + rateLimit: config.guard?.rateLimit ?? { max: 30, windowMs: 60_000 }, + }; + const serveMiddleware = x402Serve({ payTo, network, @@ -55,6 +79,7 @@ export function withX402Protection( }, ...(facilitatorUrl ? { facilitatorUrl } : {}), ...(facilitatorApiKey ? { facilitatorApiKey } : {}), + ...(guardConfig?.store ? { guard: guardConfig as { store: X402GuardStore; rateLimit?: { max: number; windowMs: number } } } : {}), }); return new Promise((resolve) => { @@ -104,6 +129,10 @@ export function withX402Protection( url: req.url, path: resource, headers: Object.fromEntries(req.headers.entries()), + // Vercel's Request has no `.ip`; the real client IP (behind Vercel's + // proxy) is this header. Falls back to "unknown" (one shared rate + // limit bucket) if absent, same as a direct/local request. + ip: req.headers.get("x-forwarded-for")?.split(",")[0]?.trim(), }; if (paymentHeader) { diff --git a/examples/deploy-x402-vercel/package-lock.json b/examples/deploy-x402-vercel/package-lock.json index 1fa5420..f72446f 100644 --- a/examples/deploy-x402-vercel/package-lock.json +++ b/examples/deploy-x402-vercel/package-lock.json @@ -9,10 +9,9 @@ "version": "0.1.0", "dependencies": { "next": "^15.1.0", - "nirium": "^0.10.1", + "nirium": "^0.14.1", "react": "^19.0.0", - "react-dom": "^19.0.0", - "viem": "^2.55.19" + "react-dom": "^19.0.0" }, "devDependencies": { "@types/node": "^22.10.0", @@ -1772,9 +1771,9 @@ } }, "node_modules/nirium": { - "version": "0.10.2", - "resolved": "https://registry.npmjs.org/nirium/-/nirium-0.10.2.tgz", - "integrity": "sha512-52Y7h9c/V0FiTzCMXLx2jiO7H+QJYBOpaZON1I4GSOs5Dn9Vjsb09i4T0H5GZXY8mFS9VbvMaseHvwN8Tz5y0A==", + "version": "0.14.1", + "resolved": "https://registry.npmjs.org/nirium/-/nirium-0.14.1.tgz", + "integrity": "sha512-nVeW60vnIJ2Yb8BuOs2SRMUmRXPFbF7pat1nVX7K2HlDniNA7UdXwOFbrHDxeaPbZUfrbWNKp9CWAQijFR1JYQ==", "license": "Apache-2.0", "dependencies": { "@stellar/mpp": "^0.7.1", @@ -1782,6 +1781,7 @@ "@x402/fetch": "^2.17.0", "@x402/stellar": "^2.17.0", "mppx": "^0.6.31", + "viem": "^2.55.19", "ws": "^8.18.0" } }, diff --git a/examples/deploy-x402-vercel/package.json b/examples/deploy-x402-vercel/package.json index ef80b85..7567fda 100644 --- a/examples/deploy-x402-vercel/package.json +++ b/examples/deploy-x402-vercel/package.json @@ -12,7 +12,7 @@ }, "dependencies": { "next": "^15.1.0", - "nirium": "^0.10.1", + "nirium": "^0.14.1", "react": "^19.0.0", "react-dom": "^19.0.0" }, diff --git a/packages/sdk/CHANGELOG.md b/packages/sdk/CHANGELOG.md new file mode 100644 index 0000000..b350d88 --- /dev/null +++ b/packages/sdk/CHANGELOG.md @@ -0,0 +1,11 @@ +# Changelog + +All notable changes to the `nirium` package are documented here. + +## Unreleased + +### Added + +- `x402Serve()`: optional `guard` config for replay protection and rate limiting, off by default. Providing `guard.store` turns on single-use protection against a replayed payment proof (`X-PAYMENT`/`PAYMENT-SIGNATURE`); adding `guard.rateLimit` also turns on a sliding-window rate limit per caller IP. Fails closed (503) for replay protection and open for rate limiting if the store is unavailable, matching the design already proven in a real production `x402Serve()` deployment. New exports: `X402GuardStore`, `X402GuardConfig`, `createUpstashX402GuardStore()` (a reference Upstash-backed store). See [#91](https://github.com/nirium-protocol/nirium/issues/91). + + The store interface and this feature's fail-closed/fail-open split are generalized from - and credited to - a real production integrator's own implementation: **Edgadafi/remesa-liquidez** (commit `1e0cbd5902cb224d3c6a2320cc009cf4df84f513`, `backend/src/middleware/paymentGuard.ts` and `backend/src/middleware/rateLimit.ts`). The code in this package is our own, not copied from theirs. diff --git a/packages/sdk/src/index.ts b/packages/sdk/src/index.ts index ad4e0cc..a5a0f00 100644 --- a/packages/sdk/src/index.ts +++ b/packages/sdk/src/index.ts @@ -13,6 +13,7 @@ import { createEd25519Signer } from '@x402/stellar'; // @ts-ignore import { ExactStellarScheme } from '@x402/stellar/exact/client'; import * as MppxModule from 'mppx'; +import { checkReplay, checkRateLimit, type X402GuardConfig } from './x402-guard'; export interface AgentConfig { apiKey: string; @@ -1300,6 +1301,13 @@ export interface X402ServeConfig { facilitatorAuthHeader?: string; appName?: string; appLogo?: string; + /** + * Optional replay/rate-limit protection - off by default. Providing + * `guard.store` turns it on; without it, x402Serve() behaves exactly + * as before. See x402-guard.ts and + * https://github.com/nirium-protocol/nirium/issues/91. + */ + guard?: X402GuardConfig; } const X402_FACILITATORS = { @@ -1482,8 +1490,36 @@ export function x402Serve(config: X402ServeConfig): any { lastFlush = Date.now(); }; + const sendDenied = (res: any, d: { status: number; body: Record; headers?: Record }) => { + if (d.headers) for (const [k, v] of Object.entries(d.headers)) res.setHeader(k, v); + res.status(d.status).json(d.body); + }; + return async function niriumX402(req: any, res: any, next: any) { maybeFlushHeartbeat(); + + if (config.guard) { + const guardReq = { method: req.method, url: req.originalUrl ?? req.url, headers: req.headers, ip: req.ip }; + + const rl = await checkRateLimit(guardReq, config.guard); + if (!rl.allowed) { + sendDenied(res, rl.denied!); + return; + } + + const replay = await checkReplay(guardReq, config.guard); + if (!replay.allowed) { + sendDenied(res, replay.denied!); + return; + } + if (replay.release) { + const release = replay.release; + res.on('finish', () => { + if (res.statusCode < 200 || res.statusCode >= 300) release(); + }); + } + } + try { if (!cached) { await preflight(); @@ -1502,6 +1538,9 @@ export function x402Serve(config: X402ServeConfig): any { // (unlike a bundler) needs the real extension to find the compiled file. export { x402Metrics } from './metrics'; export type { X402MetricsResult, MetricsSnapshot } from './metrics'; +export type { X402GuardStore, X402GuardConfig, GuardRequest, GuardDenied } from './x402-guard'; +export { createUpstashX402GuardStore } from './x402-guard-upstash'; +export type { UpstashX402GuardStoreOptions } from './x402-guard-upstash'; export default Agent; export { diff --git a/packages/sdk/src/x402-guard-upstash.ts b/packages/sdk/src/x402-guard-upstash.ts new file mode 100644 index 0000000..2692c08 --- /dev/null +++ b/packages/sdk/src/x402-guard-upstash.ts @@ -0,0 +1,50 @@ +/** + * Reference X402GuardStore backed by Upstash's REST API — plain `fetch`, + * no extra dependency. Any other Redis-compatible store can implement the + * three-method X402GuardStore interface instead; this one is provided + * because it's the store the production reference implementation this + * feature is modeled on actually uses. See x402-guard.ts. + */ + +import type { X402GuardStore } from './x402-guard'; + +export interface UpstashX402GuardStoreOptions { + /** e.g. https://usw1-xxxx.upstash.io */ + url: string; + token: string; +} + +export function createUpstashX402GuardStore(opts: UpstashX402GuardStoreOptions): X402GuardStore { + const base = opts.url.replace(/\/$/, ''); + + const call = async (cmd: (string | number)[]): Promise => { + const r = await fetch(base, { + method: 'POST', + headers: { Authorization: `Bearer ${opts.token}`, 'Content-Type': 'application/json' }, + body: JSON.stringify(cmd), + signal: AbortSignal.timeout(5000), + }); + if (!r.ok) throw new Error(`Upstash returned HTTP ${r.status}`); + const j = await r.json(); + if (j.error) throw new Error(`Upstash error: ${j.error}`); + return j.result; + }; + + return { + async claimOnce(key, ttlMs) { + const result = await call(['SET', key, '1', 'PX', ttlMs, 'NX']); + return result === 'OK'; + }, + async release(key) { + await call(['DEL', key]); + }, + async slidingWindowHit(key, windowMs, member) { + const now = Date.now(); + await call(['ZADD', key, now, member]); + await call(['ZREMRANGEBYSCORE', key, 0, now - windowMs]); + await call(['PEXPIRE', key, windowMs]); + const count = await call(['ZCARD', key]); + return Number(count); + }, + }; +} diff --git a/packages/sdk/src/x402-guard.test.ts b/packages/sdk/src/x402-guard.test.ts new file mode 100644 index 0000000..9aaaa02 --- /dev/null +++ b/packages/sdk/src/x402-guard.test.ts @@ -0,0 +1,158 @@ +/** + * Unit tests for the replay guard / rate limit checks that back + * X402ServeConfig['guard']. Pure functions against a fake in-memory store, + * no network or Express involved — the integration-level tests (guard + * wired through x402Serve() itself) live in x402serve-smoke.test.ts. + * + * These reproduce the exact scenarios a real production x402Serve() user + * (Edgadafi/remesa-liquidez) built their own version of this against, per + * https://github.com/nirium-protocol/nirium/issues/91. + */ + +import { checkReplay, checkRateLimit, type X402GuardStore, type X402GuardConfig, type GuardRequest } from './x402-guard'; + +function fakeStore(): X402GuardStore & { claims: Map } { + const claims = new Map(); + return { + claims, + async claimOnce(key, ttlMs) { + const now = Date.now(); + const expiresAt = claims.get(key); + if (expiresAt !== undefined && expiresAt > now) return false; + claims.set(key, now + ttlMs); + return true; + }, + async release(key) { + claims.delete(key); + }, + async slidingWindowHit() { + throw new Error('slidingWindowHit not used by replay tests'); + }, + }; +} + +function req(overrides: Partial = {}): GuardRequest { + return { + method: 'GET', + url: '/signals', + headers: { 'payment-signature': 'proof-abc' }, + ip: '203.0.113.1', + ...overrides, + }; +} + +describe('checkReplay', () => { + it('allows a request with no payment proof yet (lets x402Serve issue its 402)', async () => { + const store = fakeStore(); + const config: X402GuardConfig = { store }; + const result = await checkReplay(req({ headers: {} }), config); + expect(result.allowed).toBe(true); + }); + + it('allows the first use of a payment proof', async () => { + const store = fakeStore(); + const config: X402GuardConfig = { store }; + const result = await checkReplay(req(), config); + expect(result.allowed).toBe(true); + expect(store.claims.size).toBe(1); + }); + + it('rejects a reused payment proof against the same route with 409', async () => { + const store = fakeStore(); + const config: X402GuardConfig = { store }; + const first = await checkReplay(req(), config); + expect(first.allowed).toBe(true); + + const second = await checkReplay(req(), config); + expect(second.allowed).toBe(false); + expect(second.denied?.status).toBe(409); + expect(second.denied?.body.error).toBe('payment_replayed'); + }); + + it('allows the same proof against a DIFFERENT route (binds proof + method + route)', async () => { + const store = fakeStore(); + const config: X402GuardConfig = { store }; + await checkReplay(req({ url: '/signals' }), config); + const other = await checkReplay(req({ url: '/market' }), config); + expect(other.allowed).toBe(true); + }); + + it('reads the x402 v2 header "payment-signature" and the v1 fallback "x-payment"', async () => { + const store = fakeStore(); + const config: X402GuardConfig = { store }; + const v2 = await checkReplay(req({ headers: { 'payment-signature': 'same-proof' } }), config); + expect(v2.allowed).toBe(true); + const v1Reuse = await checkReplay(req({ headers: { 'x-payment': 'same-proof' } }), config); + // Same underlying proof string, same method+url -> same digest -> replay. + expect(v1Reuse.allowed).toBe(false); + }); + + it('releases the claim when the caller reports the request did not succeed, allowing a legitimate retry', async () => { + const store = fakeStore(); + const config: X402GuardConfig = { store }; + const first = await checkReplay(req(), config); + expect(first.allowed).toBe(true); + await first.release!(); // simulates: response ended non-2xx (e.g. facilitator down) + + const retry = await checkReplay(req(), config); + expect(retry.allowed).toBe(true); + }); + + it('fails CLOSED (503) when the store is unavailable, never silently letting the payment through unprotected', async () => { + const brokenStore: X402GuardStore = { + async claimOnce() { throw new Error('store down'); }, + async release() {}, + async slidingWindowHit() { throw new Error('store down'); }, + }; + const config: X402GuardConfig = { store: brokenStore }; + const result = await checkReplay(req(), config); + expect(result.allowed).toBe(false); + expect(result.denied?.status).toBe(503); + expect(result.denied?.body.error).toBe('replay_protection_unavailable'); + }); +}); + +describe('checkRateLimit', () => { + it('allows when no rateLimit config is set, even with a store present', async () => { + const store = fakeStore(); + const config: X402GuardConfig = { store }; + const result = await checkRateLimit(req(), config); + expect(result.allowed).toBe(true); + }); + + it('allows requests under the limit', async () => { + let hits = 0; + const store: X402GuardStore = { + async claimOnce() { return true; }, + async release() {}, + async slidingWindowHit() { hits += 1; return hits; }, + }; + const config: X402GuardConfig = { store, rateLimit: { max: 5, windowMs: 60_000 } }; + const result = await checkRateLimit(req(), config); + expect(result.allowed).toBe(true); + }); + + it('rejects with 429 once the sliding window count exceeds max', async () => { + const store: X402GuardStore = { + async claimOnce() { return true; }, + async release() {}, + async slidingWindowHit() { return 6; }, + }; + const config: X402GuardConfig = { store, rateLimit: { max: 5, windowMs: 60_000 } }; + const result = await checkRateLimit(req(), config); + expect(result.allowed).toBe(false); + expect(result.denied?.status).toBe(429); + expect(result.denied?.headers?.['Retry-After']).toBe('60'); + }); + + it('fails OPEN when the store errors, so a store outage never becomes a self-inflicted denial of service', async () => { + const store: X402GuardStore = { + async claimOnce() { return true; }, + async release() {}, + async slidingWindowHit() { throw new Error('store down'); }, + }; + const config: X402GuardConfig = { store, rateLimit: { max: 5, windowMs: 60_000 } }; + const result = await checkRateLimit(req(), config); + expect(result.allowed).toBe(true); + }); +}); diff --git a/packages/sdk/src/x402-guard.ts b/packages/sdk/src/x402-guard.ts new file mode 100644 index 0000000..8c2a1bf --- /dev/null +++ b/packages/sdk/src/x402-guard.ts @@ -0,0 +1,183 @@ +/** + * Optional replay/rate-limit protection for x402Serve() — off by default. + * + * x402Serve() verifies and settles a payment; on its own it does not stop + * the same signed payment proof from being replayed against a route (a + * concurrent-request "free shopping" window), and it does not rate-limit + * callers. See https://github.com/nirium-protocol/nirium/issues/91. + * + * The design here (single-use claim keyed on the payment proof + method + + * route, released only if the request did NOT end in 2xx so a legitimate + * retry after a facilitator hiccup still works; fail-closed for the replay + * guard and fail-open for rate limiting, both by the same reasoning) is + * generalized from a real production x402Serve() integration — + * Edgadafi/remesa-liquidez (commit 1e0cbd5902cb224d3c6a2320cc009cf4df84f513, + * `backend/src/middleware/paymentGuard.ts` and `backend/src/middleware/ + * rateLimit.ts`) — credited here and in CHANGELOG.md. The store interface + * and this module's code are our own; nothing here is copied from theirs. + */ + +import { createHash } from 'node:crypto'; + +/** + * A pluggable KV backend. Implement this against Upstash, another + * Redis-compatible store, or anything else with the same semantics — see + * `createUpstashX402GuardStore()` for a ready-made Upstash implementation. + */ +export interface X402GuardStore { + /** + * Atomically claims `key` for `ttlMs`. Returns `true` if THIS call made + * the claim, `false` if `key` was already claimed (and is still within + * its TTL) by an earlier call. + */ + claimOnce(key: string, ttlMs: number): Promise; + /** + * Releases a claim early. Used after a request that did not succeed, so + * a legitimate retry with the same payment proof is not blocked. + */ + release(key: string): Promise; + /** + * Registers one hit for `key` (a caller-unique `member` id, so repeat + * calls within the same millisecond don't collide) and returns how many + * hits are still inside the trailing `windowMs` sliding window. + */ + slidingWindowHit(key: string, windowMs: number, member: string): Promise; +} + +export interface X402GuardConfig { + /** + * Backing store. Providing this is what turns the guard on — with no + * `guard` config at all, x402Serve() behaves exactly as before (no + * replay protection, no rate limiting, zero overhead). + */ + store: X402GuardStore; + /** How long a used payment proof stays claimed. Default 15 minutes. */ + replayTtlMs?: number; + /** + * Sliding-window rate limit per caller IP. Omit to skip rate limiting + * even with `store` configured — replay protection and rate limiting + * are independent knobs. + */ + rateLimit?: { max: number; windowMs: number }; +} + +export interface GuardRequest { + method: string; + url: string; + headers: Record; + ip?: string; +} + +export interface GuardDenied { + status: number; + body: Record; + headers?: Record; +} + +export interface ReplayCheck { + allowed: boolean; + denied?: GuardDenied; + /** Call after the response is known — releases the claim on non-2xx. */ + release?: () => Promise; +} + +export interface RateLimitCheck { + allowed: boolean; + denied?: GuardDenied; +} + +const DEFAULT_REPLAY_TTL_MS = 15 * 60 * 1000; + +// @x402/express reads the payment proof from `payment-signature` (x402 +// v2), falling back to `x-payment` (v1) — verified directly against the +// published @x402/express package (dist/cjs/index.js), not assumed from +// either header name alone. The guard has to hash the SAME header the real +// middleware will read, or it protects nothing. +function readPaymentHeader(headers: GuardRequest['headers']): string | undefined { + const v2 = headers['payment-signature']; + const v1 = headers['x-payment']; + const pick = (v: typeof v2) => (Array.isArray(v) ? v[0] : v); + return pick(v2) ?? pick(v1); +} + +/** Single-use guard against replaying a payment proof. */ +export async function checkReplay(req: GuardRequest, config: X402GuardConfig): Promise { + const raw = readPaymentHeader(req.headers); + if (!raw) return { allowed: true }; // no proof yet — let x402Serve issue its 402 + + // The key binds the proof to method + route, same binding the exact + // scheme itself already puts on resource.url — a proof burned against + // route A doesn't block its own legitimate use against route B. + const digest = createHash('sha256').update(raw).update('\n').update(`${req.method} ${req.url}`).digest('hex'); + const key = `nirium:x402guard:replay:${digest}`; + + let claimed: boolean; + try { + claimed = await config.store.claimOnce(key, config.replayTtlMs ?? DEFAULT_REPLAY_TTL_MS); + } catch { + // Fail-closed: once a store is configured, its failure must never + // silently become "no protection" — a payment gate that only + // protects when convenient isn't a payment gate. + return { + allowed: false, + denied: { + status: 503, + body: { + error: 'replay_protection_unavailable', + message: 'The payment replay guard\'s store is unavailable right now. Retry later.', + }, + }, + }; + } + + if (!claimed) { + return { + allowed: false, + denied: { + status: 409, + body: { + error: 'payment_replayed', + message: 'This payment proof was already used. Each request needs a freshly signed payment.', + }, + }, + }; + } + + return { allowed: true, release: () => config.store.release(key).catch(() => { /* best-effort */ }) }; +} + +/** Sliding-window rate limit per caller IP. */ +export async function checkRateLimit(req: GuardRequest, config: X402GuardConfig): Promise { + if (!config.rateLimit) return { allowed: true }; + + const ip = req.ip || 'unknown'; + const key = `nirium:x402guard:rl:${ip}`; + const member = `${Date.now()}:${Math.random().toString(36).slice(2, 10)}`; + + let count: number; + try { + count = await config.store.slidingWindowHit(key, config.rateLimit.windowMs, member); + } catch { + // Fail-open: a store outage must not turn into a self-inflicted + // denial of service for every caller — rate limiting protects + // availability, and denying all traffic because the counter is + // unreachable would be the opposite of that. + return { allowed: true }; + } + + if (count > config.rateLimit.max) { + return { + allowed: false, + denied: { + status: 429, + headers: { 'Retry-After': String(Math.ceil(config.rateLimit.windowMs / 1000)) }, + body: { + error: 'rate_limited', + message: `Max ${config.rateLimit.max} requests per ${Math.ceil(config.rateLimit.windowMs / 1000)}s.`, + }, + }, + }; + } + + return { allowed: true }; +} diff --git a/packages/sdk/src/x402serve-smoke.test.ts b/packages/sdk/src/x402serve-smoke.test.ts index 0b8ad11..5fcda74 100644 --- a/packages/sdk/src/x402serve-smoke.test.ts +++ b/packages/sdk/src/x402serve-smoke.test.ts @@ -88,3 +88,92 @@ describe('x402Metrics wrapping x402Serve', () => { ).toThrow('payTo'); }); }); + +describe('x402Serve guard integration (config.guard)', () => { + // These only exercise the DENIED paths (409/503/429), which return before + // x402Serve ever reaches preflight()/build() — so none of this needs the + // @x402/express/@x402/core mocks above. The allowed-path/store behavior + // itself is covered directly in x402-guard.test.ts. + + function fakeStore(overrides: Partial<{ + claimOnce: (key: string, ttlMs: number) => Promise; + release: (key: string) => Promise; + slidingWindowHit: (key: string, windowMs: number, member: string) => Promise; + }> = {}) { + return { + claimOnce: overrides.claimOnce ?? (async () => true), + release: overrides.release ?? (async () => {}), + slidingWindowHit: overrides.slidingWindowHit ?? (async () => 1), + }; + } + + function mockReqRes(headers: Record = {}) { + const req: any = { method: 'GET', url: '/signals', originalUrl: '/signals', headers, ip: '203.0.113.1' }; + const res: any = { + statusCode: 200, + status(code: number) { this.statusCode = code; return this; }, + json(body: any) { this.body = body; return this; }, + setHeader(k: string, v: string) { this.headers = { ...(this.headers ?? {}), [k]: v }; }, + on() { /* no-op: not reached on the denied paths under test */ }, + }; + return { req, res }; + } + + it('rejects a reused X-PAYMENT the same way Edgadafi/remesa-liquidez does: 409, payment_replayed', async () => { + const store = fakeStore({ + claimOnce: (() => { + let used = false; + return async () => { if (used) return false; used = true; return true; }; + })(), + }); + const handler = x402Serve({ ...VALID_CONFIG, guard: { store } }); + + const first = mockReqRes({ 'payment-signature': 'proof-1' }); + await handler(first.req, first.res, () => {}); + // First call proceeds past the guard (into preflight, which will fail + // in this test env with no real facilitator — that's fine, it's not + // what this test is checking). + + const second = mockReqRes({ 'payment-signature': 'proof-1' }); + await handler(second.req, second.res, () => {}); + expect(second.res.statusCode).toBe(409); + expect(second.res.body.error).toBe('payment_replayed'); + }); + + it('responds 503 instead of accepting a payment unprotected when the guard store is down', async () => { + const store = fakeStore({ claimOnce: async () => { throw new Error('store down'); } }); + const handler = x402Serve({ ...VALID_CONFIG, guard: { store } }); + + const { req, res } = mockReqRes({ 'payment-signature': 'proof-2' }); + await handler(req, res, () => {}); + expect(res.statusCode).toBe(503); + expect(res.body.error).toBe('replay_protection_unavailable'); + }); + + it('rate-limits with 429 before ever looking at the payment proof', async () => { + const store = fakeStore({ slidingWindowHit: async () => 999 }); + const handler = x402Serve({ + ...VALID_CONFIG, + guard: { store, rateLimit: { max: 5, windowMs: 60_000 } }, + }); + + const { req, res } = mockReqRes({ 'payment-signature': 'proof-3' }); + await handler(req, res, () => {}); + expect(res.statusCode).toBe(429); + expect(res.body.error).toBe('rate_limited'); + expect(res.headers['Retry-After']).toBe('60'); + }); + + it('with no `guard` config at all, behaves exactly as before (no 409/503/429 from the guard layer)', async () => { + const handler = x402Serve(VALID_CONFIG); // no `guard` key + const first = mockReqRes({ 'payment-signature': 'proof-4' }); + await handler(first.req, first.res, () => {}); + const second = mockReqRes({ 'payment-signature': 'proof-4' }); // same "proof" reused + await handler(second.req, second.res, () => {}); + // Neither call was rejected by a guard that doesn't exist - whatever + // status they got came from downstream (preflight failing in this test + // env), never 409/429, and never our guard's own error bodies. + expect(second.res.body?.error).not.toBe('payment_replayed'); + expect(second.res.statusCode).not.toBe(409); + }); +}); From 66671b19a3d6b2b99c05306817cd381aafb5e765 Mon Sep 17 00:00:00 2001 From: Eras256 Date: Thu, 24 Sep 2026 13:55:36 -0600 Subject: [PATCH 2/2] fix(ci): bump sdk-test.yml to Node 22 - the package's test script uses --experimental-strip-types, which needs Node >=22 Co-Authored-By: Claude Sonnet 5 --- .github/workflows/sdk-test.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/sdk-test.yml b/.github/workflows/sdk-test.yml index 4013739..03e8454 100644 --- a/.github/workflows/sdk-test.yml +++ b/.github/workflows/sdk-test.yml @@ -25,7 +25,7 @@ jobs: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: - node-version: 20 + node-version: 22 # --legacy-peer-deps: @stellar/mpp@0.7.1 peer-requires # @stellar/stellar-sdk@^15.1.0 while this package pins ^14.5.0 - a # pre-existing mismatch, unrelated to this workflow, not fixed here.