@craigruks/x402-server-guard 0.1.0 → 0.1.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +37 -9
- package/SECURITY.md +3 -1
- package/dist/cache.d.ts +7 -3
- package/dist/cache.d.ts.map +1 -1
- package/dist/cache.js +4 -2
- package/dist/cache.js.map +1 -1
- package/dist/guard.js +1 -1
- package/dist/guard.js.map +1 -1
- package/dist/index.js +1 -1
- package/dist/nonce-store.js +3 -3
- package/dist/nonce-store.js.map +1 -1
- package/dist/store-types.d.ts +1 -1
- package/package.json +15 -15
- package/src/cache.ts +7 -3
- package/src/guard.ts +1 -1
- package/src/index.ts +1 -1
- package/src/nonce-store.ts +3 -3
- package/src/store-types.ts +1 -1
package/README.md
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
# x402-server-guard
|
|
2
2
|
|
|
3
|
-
**Server-side hardening middleware for [x402](https://github.com/
|
|
3
|
+
**Server-side hardening middleware for [x402](https://github.com/x402-foundation/x402)
|
|
4
4
|
payment endpoints.**
|
|
5
5
|
|
|
6
|
+
[](https://www.npmjs.com/package/@craigruks/x402-server-guard)
|
|
6
7
|
[](https://github.com/craigruks/x402-server-guard/actions/workflows/ci.yml)
|
|
7
8
|
[](./LICENSE)
|
|
8
9
|
|
|
@@ -76,7 +77,7 @@ return grant(resource);
|
|
|
76
77
|
> isolate holds its own map, so replay and race protection do not hold across
|
|
77
78
|
> instances. For those deploys, pass a store backed by an atomic compare-and-set: a
|
|
78
79
|
> Durable Object, Redis `SET NX`, or a database unique constraint (not a plain
|
|
79
|
-
> get-then-put store like Workers KV, which reopens the race). Any backend that
|
|
80
|
+
> get-then-put store like Workers KV, which reopens the race condition). Any backend that
|
|
80
81
|
> implements the `NonceStore` contract works. One is built start to finish in the box, a
|
|
81
82
|
> Cloudflare Durable Object adapter:
|
|
82
83
|
> `import { createDurableObjectNonceStore } from "@craigruks/x402-server-guard/cloudflare"`
|
|
@@ -137,6 +138,21 @@ All four enumerated attack classes are covered; see the table below.
|
|
|
137
138
|
drops into Hono, Express, Next, or Fastify. A Hono binding is shown in the
|
|
138
139
|
examples; an `@x402/core`-hook convenience wrapper is a thin layer over `protect`.
|
|
139
140
|
|
|
141
|
+
## Taking the dependency (or not)
|
|
142
|
+
|
|
143
|
+
If you run a payment endpoint, you are right to want as few dependencies as possible.
|
|
144
|
+
This library has none at runtime, and the core is small enough to read in one sitting. If
|
|
145
|
+
you would rather not add a dependency at all, lift the primitives you use straight into
|
|
146
|
+
your own code: the reservation, the finality hold, and the cache directives are each a
|
|
147
|
+
small, self-contained file, and a coding agent can pull over the parts you need in a few
|
|
148
|
+
minutes. That is a supported way to use this.
|
|
149
|
+
|
|
150
|
+
If you do take the dependency, we have tried to make depending on us safe. Releases
|
|
151
|
+
publish through GitHub Actions using npm trusted publishing (OIDC), so no long-lived npm
|
|
152
|
+
token exists for an attacker to steal; tokens are disallowed on the package, publishing
|
|
153
|
+
requires two-factor auth, and CI-published releases carry build provenance that ties the
|
|
154
|
+
package to the exact commit and workflow that built it.
|
|
155
|
+
|
|
140
156
|
## Mitigations
|
|
141
157
|
|
|
142
158
|
Each ships with a paired test proving the attack against a vanilla server and
|
|
@@ -147,13 +163,17 @@ and honesty (is this a strawman, does the reference actually have these gaps, wh
|
|
|
147
163
|
this does not do) are answered in
|
|
148
164
|
[`docs/objection-handling.md`](./docs/objection-handling.md).
|
|
149
165
|
|
|
150
|
-
| Attack class | Status |
|
|
151
|
-
| --- | --- |
|
|
152
|
-
| Duplicate-settlement race | done |
|
|
153
|
-
| Payment replay | done (same nonce reservation) |
|
|
154
|
-
| Cross-resource substitution | done (same nonce reservation, distinct reason) |
|
|
155
|
-
| Grant-before-finality (k-confirmations) | done |
|
|
156
|
-
| Cache leakage of paid content | done |
|
|
166
|
+
| Attack class | Status | Upstream `@x402/core` 2.29.0 |
|
|
167
|
+
| --- | --- | --- |
|
|
168
|
+
| Duplicate-settlement race | done | partly: closed by the opt-in `upfront` flow ([#3053](https://github.com/x402-foundation/x402/pull/3053)), open in the default flow |
|
|
169
|
+
| Payment replay | done (same nonce reservation) | partly, as for the race |
|
|
170
|
+
| Cross-resource substitution | done (same nonce reservation, distinct reason) | partly: `upfront` denies the reuse, nothing binds or flags the resource |
|
|
171
|
+
| Grant-before-finality (k-confirmations) | done | no: success is one inclusion; a hook can now fail it closed ([#3994](https://github.com/x402-foundation/x402/pull/3994)) |
|
|
172
|
+
| Cache leakage of paid content | done | partly: `private` on the paid 200 since 2.21.0 ([#2990](https://github.com/x402-foundation/x402/pull/2990)), no `no-store`, no `Vary` |
|
|
173
|
+
|
|
174
|
+
The upstream column is a survey, not a dependency: the guard does not call
|
|
175
|
+
`@x402/core`. Evidence for each entry is in
|
|
176
|
+
[`docs/hardening.md`](./docs/hardening.md#what-upstream-now-covers).
|
|
157
177
|
|
|
158
178
|
## Development
|
|
159
179
|
|
|
@@ -173,6 +193,14 @@ Dev tooling lives in `package.json` scripts (`npm run …`); the [`justfile`](./
|
|
|
173
193
|
is the discoverable index for repo operations that aren't npm: supply-chain
|
|
174
194
|
checks, CI, release prep. Run `just` to see them all.
|
|
175
195
|
|
|
196
|
+
## Acknowledgments
|
|
197
|
+
|
|
198
|
+
Shengchen Ling, an author of "Free-Riding the Agentic Web: A Systematic Security
|
|
199
|
+
Analysis of x402 Payments" ([arXiv:2605.30998](https://arxiv.org/abs/2605.30998)),
|
|
200
|
+
reviewed how this library maps to the paper's flaw classes and invariants and
|
|
201
|
+
confirmed it aligns with the paper's intended interpretation. This is not an
|
|
202
|
+
audit or endorsement of the implementation.
|
|
203
|
+
|
|
176
204
|
## License
|
|
177
205
|
|
|
178
206
|
[MIT](./LICENSE) © Craig Ruks
|
package/SECURITY.md
CHANGED
|
@@ -47,7 +47,9 @@ published under a separate tag.
|
|
|
47
47
|
|
|
48
48
|
Please report suspected vulnerabilities **privately** via GitHub Security
|
|
49
49
|
Advisories ("Report a vulnerability" on the repository's Security tab), not
|
|
50
|
-
through public issues.
|
|
50
|
+
through public issues. This covers any case where the guard fails to stop an
|
|
51
|
+
attack it claims to mitigate. A public issue that describes a working attack
|
|
52
|
+
discloses it before a fix exists.
|
|
51
53
|
|
|
52
54
|
Include a description, affected version, and a reproduction if possible. Expect an
|
|
53
55
|
acknowledgement within 72 hours. Please allow a reasonable window for a fix before
|
package/dist/cache.d.ts
CHANGED
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Cache directives for paid responses.
|
|
3
3
|
*
|
|
4
|
-
* A shared cache (CDN or reverse proxy)
|
|
4
|
+
* A shared cache (CDN or reverse proxy) is keyed on the URL and knows nothing about
|
|
5
5
|
* payment, so a cacheable paid 200 can be stored and served to the next caller, paid
|
|
6
|
-
* or not. The reference x402 adapters
|
|
6
|
+
* or not. The reference x402 adapters send `private` on the paid 200 as of
|
|
7
|
+
* `@x402/core` 2.21.0 (unchanged through 2.29.0), and set no directive at all before
|
|
8
|
+
* it; neither sets `Vary`.
|
|
7
9
|
*
|
|
8
10
|
* The mitigation is a response directive, not a decision about a nonce, so it lives
|
|
9
11
|
* here as a small pure helper the server or adapter applies to every paid response.
|
|
@@ -20,7 +22,9 @@ export interface CacheDirectives {
|
|
|
20
22
|
export interface CacheDirectivesOptions {
|
|
21
23
|
/**
|
|
22
24
|
* The request header the payment travels in, added to `Vary` so a cache that
|
|
23
|
-
* stores despite `no-store` still keys by payer. Defaults to `X-PAYMENT
|
|
25
|
+
* stores despite `no-store` still keys by payer. Defaults to `X-PAYMENT`, the x402
|
|
26
|
+
* v1 header; an x402 v2 client sends `PAYMENT-SIGNATURE`, so pass that behind a v2
|
|
27
|
+
* server.
|
|
24
28
|
*/
|
|
25
29
|
readonly paymentHeader?: string;
|
|
26
30
|
}
|
package/dist/cache.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"cache.d.ts","sourceRoot":"","sources":["../src/cache.ts"],"names":[],"mappings":"AAAA
|
|
1
|
+
{"version":3,"file":"cache.d.ts","sourceRoot":"","sources":["../src/cache.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;GAaG;AAEH,wFAAwF;AACxF,MAAM,WAAW,eAAe;IAC9B,6BAA6B;IAC7B,QAAQ,CAAC,YAAY,EAAE,MAAM,CAAC;IAC9B,uEAAuE;IACvE,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;CACvB;AAED,MAAM,WAAW,sBAAsB;IACrC;;;;;OAKG;IACH,QAAQ,CAAC,aAAa,CAAC,EAAE,MAAM,CAAC;CACjC;AAED,+DAA+D;AAC/D,wBAAgB,2BAA2B,CAAC,OAAO,GAAE,sBAA2B,GAAG,eAAe,CAGjG;AAED;;;;;GAKG;AACH,wBAAgB,uBAAuB,CAAC,YAAY,EAAE,MAAM,GAAG,SAAS,GAAG,OAAO,CAMjF"}
|
package/dist/cache.js
CHANGED
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Cache directives for paid responses.
|
|
3
3
|
*
|
|
4
|
-
* A shared cache (CDN or reverse proxy)
|
|
4
|
+
* A shared cache (CDN or reverse proxy) is keyed on the URL and knows nothing about
|
|
5
5
|
* payment, so a cacheable paid 200 can be stored and served to the next caller, paid
|
|
6
|
-
* or not. The reference x402 adapters
|
|
6
|
+
* or not. The reference x402 adapters send `private` on the paid 200 as of
|
|
7
|
+
* `@x402/core` 2.21.0 (unchanged through 2.29.0), and set no directive at all before
|
|
8
|
+
* it; neither sets `Vary`.
|
|
7
9
|
*
|
|
8
10
|
* The mitigation is a response directive, not a decision about a nonce, so it lives
|
|
9
11
|
* here as a small pure helper the server or adapter applies to every paid response.
|
package/dist/cache.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"cache.js","sourceRoot":"","sources":["../src/cache.ts"],"names":[],"mappings":"AAAA
|
|
1
|
+
{"version":3,"file":"cache.js","sourceRoot":"","sources":["../src/cache.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;GAaG;AAoBH,+DAA+D;AAC/D,MAAM,UAAU,2BAA2B,CAAC,OAAO,GAA2B,EAAE;IAC9E,MAAM,aAAa,GAAG,OAAO,CAAC,aAAa,IAAI,WAAW,CAAC;IAC3D,OAAO,EAAE,YAAY,EAAE,mBAAmB,EAAE,IAAI,EAAE,aAAa,EAAE,CAAC;AACpE,CAAC;AAED;;;;;GAKG;AACH,MAAM,UAAU,uBAAuB,CAAC,YAAgC;IACtE,IAAI,YAAY,KAAK,SAAS,IAAI,YAAY,KAAK,EAAE,EAAE,CAAC;QACtD,OAAO,IAAI,CAAC;IACd,CAAC;IACD,MAAM,UAAU,GAAG,YAAY,CAAC,WAAW,EAAE,CAAC;IAC9C,OAAO,CAAC,UAAU,CAAC,QAAQ,CAAC,UAAU,CAAC,IAAI,CAAC,UAAU,CAAC,QAAQ,CAAC,SAAS,CAAC,CAAC;AAC7E,CAAC"}
|
package/dist/guard.js
CHANGED
|
@@ -18,7 +18,7 @@ export function createGuard(options = {}) {
|
|
|
18
18
|
const canonicalizeResource = options.canonicalizeResource ?? canonicalResource;
|
|
19
19
|
return {
|
|
20
20
|
async reserve(params) {
|
|
21
|
-
// Fold both keys to canonical form up front; everything below
|
|
21
|
+
// Fold both keys to canonical form up front; everything below is keyed on the
|
|
22
22
|
// canonical values (see canonical.ts for why two encodings must not split).
|
|
23
23
|
const nonce = canonicalizeNonce(params.nonce);
|
|
24
24
|
const resource = canonicalizeResource(params.resource);
|
package/dist/guard.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"guard.js","sourceRoot":"","sources":["../src/guard.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AACH,OAAO,EAAE,cAAc,EAAE,iBAAiB,EAAE,MAAM,gBAAgB,CAAC;AACnE,OAAO,EAAmB,UAAU,EAAE,MAAM,YAAY,CAAC;AACzD,OAAO,EACL,sBAAsB,GAKvB,MAAM,kBAAkB,CAAC;AAC1B,OAAO,EAAE,GAAG,EAAe,aAAa,EAAE,MAAM,aAAa,CAAC;AA+C9D,6EAA6E;AAC7E,MAAM,UAAU,WAAW,CAAC,OAAO,GAAiB,EAAE;IACpD,MAAM,KAAK,GAAG,OAAO,CAAC,KAAK,IAAI,sBAAsB,EAAE,CAAC;IACxD,MAAM,iBAAiB,GAAG,OAAO,CAAC,iBAAiB,IAAI,cAAc,CAAC;IACtE,MAAM,oBAAoB,GAAG,OAAO,CAAC,oBAAoB,IAAI,iBAAiB,CAAC;IAC/E,OAAO;QACL,KAAK,CAAC,OAAO,CAAC,MAAqB;YACjC,
|
|
1
|
+
{"version":3,"file":"guard.js","sourceRoot":"","sources":["../src/guard.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AACH,OAAO,EAAE,cAAc,EAAE,iBAAiB,EAAE,MAAM,gBAAgB,CAAC;AACnE,OAAO,EAAmB,UAAU,EAAE,MAAM,YAAY,CAAC;AACzD,OAAO,EACL,sBAAsB,GAKvB,MAAM,kBAAkB,CAAC;AAC1B,OAAO,EAAE,GAAG,EAAe,aAAa,EAAE,MAAM,aAAa,CAAC;AA+C9D,6EAA6E;AAC7E,MAAM,UAAU,WAAW,CAAC,OAAO,GAAiB,EAAE;IACpD,MAAM,KAAK,GAAG,OAAO,CAAC,KAAK,IAAI,sBAAsB,EAAE,CAAC;IACxD,MAAM,iBAAiB,GAAG,OAAO,CAAC,iBAAiB,IAAI,cAAc,CAAC;IACtE,MAAM,oBAAoB,GAAG,OAAO,CAAC,oBAAoB,IAAI,iBAAiB,CAAC;IAC/E,OAAO;QACL,KAAK,CAAC,OAAO,CAAC,MAAqB;YACjC,8EAA8E;YAC9E,4EAA4E;YAC5E,MAAM,KAAK,GAAG,iBAAiB,CAAC,MAAM,CAAC,KAAK,CAAC,CAAC;YAC9C,MAAM,QAAQ,GAAG,oBAAoB,CAAC,MAAM,CAAC,QAAQ,CAAC,CAAC;YACvD,MAAM,MAAM,GAAG,MAAM,SAAS,CAAC,GAAG,EAAE,CAClC,KAAK,CAAC,OAAO,CAAC,EAAE,KAAK,EAAE,QAAQ,EAAE,SAAS,EAAE,MAAM,CAAC,SAAS,EAAE,CAAC,CAChE,CAAC;YACF,IAAI,CAAC,MAAM,CAAC,EAAE,EAAE,CAAC;gBACf,8EAA8E;gBAC9E,0EAA0E;gBAC1E,8CAA8C;gBAC9C,MAAM,KAAK,GAAG,MAAM,CAAC,KAAK,CAAC;gBAC3B,MAAM,MAAM,GACV,KAAK,CAAC,IAAI,KAAK,mBAAmB,IAAI,KAAK,CAAC,IAAI,KAAK,mBAAmB;oBACtE,CAAC,CAAC,KAAK;oBACP,CAAC,CAAC,UAAU,CAAC,mBAAmB,EAAE,yBAAyB,EAAE,KAAK,CAAC,CAAC;gBACxE,OAAO,EAAE,QAAQ,EAAE,KAAK,EAAE,MAAM,EAAE,CAAC;YACrC,CAAC;YACD,QAAQ,MAAM,CAAC,KAAK,CAAC,MAAM,EAAE,CAAC;gBAC5B,KAAK,UAAU,EAAE,CAAC;oBAChB,MAAM,EAAE,KAAK,EAAE,GAAG,MAAM,CAAC,KAAK,CAAC;oBAC/B,OAAO,EAAE,QAAQ,EAAE,IAAI,EAAE,OAAO,EAAE,GAAG,EAAE,CAAC,kBAAkB,CAAC,KAAK,EAAE,KAAK,EAAE,KAAK,CAAC,EAAE,CAAC;gBACpF,CAAC;gBACD,KAAK,kBAAkB;oBACrB,gEAAgE;oBAChE,wEAAwE;oBACxE,iEAAiE;oBACjE,IAAI,MAAM,CAAC,KAAK,CAAC,aAAa,KAAK,QAAQ,EAAE,CAAC;wBAC5C,OAAO;4BACL,QAAQ,EAAE,KAAK;4BACf,MAAM,EAAE,UAAU,CAChB,yBAAyB,EACzB,gDAAgD,CACjD;yBACF,CAAC;oBACJ,CAAC;oBACD,OAAO;wBACL,QAAQ,EAAE,KAAK;wBACf,MAAM,EAAE,UAAU,CAAC,wBAAwB,EAAE,mCAAmC,CAAC;qBAClF,CAAC;gBACJ,KAAK,SAAS;oBACZ,OAAO;wBACL,QAAQ,EAAE,KAAK;wBACf,MAAM,EAAE,UAAU,CAAC,eAAe,EAAE,mCAAmC,CAAC;qBACzE,CAAC;gBACJ;oBACE,0EAA0E;oBAC1E,4CAA4C;oBAC5C,OAAO;wBACL,QAAQ,EAAE,KAAK;wBACf,MAAM,EAAE,UAAU,CAAC,mBAAmB,EAAE,wCAAwC,CAAC;qBAClF,CAAC;YACN,CAAC;QACH,CAAC;KACF,CAAC;AACJ,CAAC;AAED;;;;;GAKG;AACH,KAAK,UAAU,SAAS,CACtB,EAA+B;IAE/B,MAAM,OAAO,GAAG,MAAM,aAAa,CAAC,EAAE,CAAC,CAAC;IACxC,IAAI,CAAC,OAAO,CAAC,EAAE,EAAE,CAAC;QAChB,OAAO,GAAG,CAAC,UAAU,CAAC,mBAAmB,EAAE,yBAAyB,EAAE,OAAO,CAAC,KAAK,CAAC,CAAC,CAAC;IACxF,CAAC;IACD,OAAO,OAAO,CAAC,KAAK,CAAC;AACvB,CAAC;AAED,6FAA6F;AAC7F,SAAS,kBAAkB,CACzB,KAAiB,EACjB,KAAa,EACb,KAAa;IAEb,OAAO,SAAS,CAAC,GAAG,EAAE,CAAC,KAAK,CAAC,OAAO,CAAC,KAAK,EAAE,KAAK,CAAC,CAAC,CAAC;AACtD,CAAC"}
|
package/dist/index.js
CHANGED
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
* race and payment replay is the first mitigation to land.
|
|
13
13
|
*/
|
|
14
14
|
/** Current package version (semver). */
|
|
15
|
-
export const VERSION = "0.1.
|
|
15
|
+
export const VERSION = "0.1.2";
|
|
16
16
|
export { isStorableBySharedCache, paidResponseCacheDirectives, } from "./cache.js";
|
|
17
17
|
export { canonicalNonce, canonicalResource } from "./canonical.js";
|
|
18
18
|
export { guardError } from "./error.js";
|
package/dist/nonce-store.js
CHANGED
|
@@ -5,10 +5,10 @@
|
|
|
5
5
|
* are never split by an `await`. The in-memory store below is atomic by construction
|
|
6
6
|
* (a synchronous body runs to completion); a distributed store must use a genuine
|
|
7
7
|
* atomic compare-and-set (a Durable Object, Redis `SET .. NX`, a unique constraint).
|
|
8
|
-
* Get-then-put stores (Workers KV, S3) reopen the race and are not safe here.
|
|
8
|
+
* Get-then-put stores (Workers KV, S3) reopen the race condition and are not safe here.
|
|
9
9
|
*
|
|
10
10
|
* `reserve` and `release` return a `Result`, so a store failure is a value the guard
|
|
11
|
-
* turns into a fail-closed deny. Replay
|
|
11
|
+
* turns into a fail-closed deny. Replay is keyed on the nonce, never the signature, which
|
|
12
12
|
* is what makes it immune to signature malleability. Full rationale: docs/hardening.md.
|
|
13
13
|
* The contract itself lives in `store-types.ts` (Node-free, so non-Node adapters can
|
|
14
14
|
* import it); this module owns the Node in-memory implementation.
|
|
@@ -62,7 +62,7 @@ export class MemoryNonceStore {
|
|
|
62
62
|
return Promise.resolve(ok({ status: "already-reserved", boundResource: existing.resource }));
|
|
63
63
|
}
|
|
64
64
|
// Reject a fresh reservation once full rather than grow unbounded or evict a live
|
|
65
|
-
// entry (which reopens the race). Overwriting a dead entry is exempt (no growth).
|
|
65
|
+
// entry (which reopens the race condition). Overwriting a dead entry is exempt (no growth).
|
|
66
66
|
if (existing === undefined && this.reserved.size >= this.maxEntries) {
|
|
67
67
|
return Promise.resolve(err(guardError("store-at-capacity", "nonce store at capacity")));
|
|
68
68
|
}
|
package/dist/nonce-store.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"nonce-store.js","sourceRoot":"","sources":["../src/nonce-store.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;GAcG;AACH,OAAO,EAAE,UAAU,EAAE,MAAM,YAAY,CAAC;AACxC,OAAO,EAAE,GAAG,EAAE,EAAE,EAAe,MAAM,aAAa,CAAC;AAiCnD,oFAAoF;AACpF,MAAM,sBAAsB,GAAG,EAAE,CAAC;AAElC;;;;GAIG;AACH,MAAM,mBAAmB,GAAG,SAAS,CAAC;AAEtC,MAAM,UAAU,GAAG,GAAW,EAAE,CAAC,IAAI,CAAC,KAAK,CAAC,IAAI,CAAC,GAAG,EAAE,GAAG,IAAI,CAAC,CAAC;AAE/D;;;;;;;;;;;GAWG;AACH,MAAM,OAAO,gBAAgB;IACV,QAAQ,GAAG,IAAI,GAAG,EAAiB,CAAC;IAC7C,SAAS,GAAG,MAAM,CAAC,iBAAiB,CAAC;IAC5B,GAAG,CAAe;IAClB,UAAU,CAAS;IAEpC,YAAY,OAAO,GAA4B,EAAE;QAC/C,IAAI,CAAC,GAAG,GAAG,OAAO,CAAC,GAAG,IAAI,UAAU,CAAC;QACrC,IAAI,CAAC,UAAU,GAAG,OAAO,CAAC,UAAU,IAAI,mBAAmB,CAAC;IAC9D,CAAC;IAED,iDAAiD;IACjD,IAAI,IAAI;QACN,OAAO,IAAI,CAAC,QAAQ,CAAC,IAAI,CAAC;IAC5B,CAAC;IAED,OAAO,CAAC,EACN,KAAK,EACL,QAAQ,EACR,SAAS,GACK;QACd,MAAM,GAAG,GAAG,IAAI,CAAC,GAAG,EAAE,CAAC;QACvB,IAAI,CAAC,UAAU,CAAC,GAAG,CAAC,CAAC;QAErB,6EAA6E;QAC7E,oDAAoD;QACpD,IAAI,SAAS,IAAI,GAAG,EAAE,CAAC;YACrB,OAAO,OAAO,CAAC,OAAO,CAAC,EAAE,CAAC,EAAE,MAAM,EAAE,SAAS,EAAE,CAAC,CAAC,CAAC;QACpD,CAAC;QAED,MAAM,QAAQ,GAAG,IAAI,CAAC,QAAQ,CAAC,GAAG,CAAC,KAAK,CAAC,CAAC;QAC1C,IAAI,QAAQ,KAAK,SAAS,IAAI,QAAQ,CAAC,SAAS,GAAG,GAAG,EAAE,CAAC;YACvD,OAAO,OAAO,CAAC,OAAO,CAAC,EAAE,CAAC,EAAE,MAAM,EAAE,kBAAkB,EAAE,aAAa,EAAE,QAAQ,CAAC,QAAQ,EAAE,CAAC,CAAC,CAAC;QAC/F,CAAC;QACD,kFAAkF;QAClF,
|
|
1
|
+
{"version":3,"file":"nonce-store.js","sourceRoot":"","sources":["../src/nonce-store.ts"],"names":[],"mappings":"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"}
|
package/dist/store-types.d.ts
CHANGED
|
@@ -34,7 +34,7 @@ export type ReserveError = StoreError | GuardError<"store-at-capacity">;
|
|
|
34
34
|
export interface ReserveParams {
|
|
35
35
|
/**
|
|
36
36
|
* The payment's nonce; the x402 exact scheme uses a random 32-byte value. The guard
|
|
37
|
-
*
|
|
37
|
+
* is keyed on this nonce alone (folded to a canonical form by default, see canonical.ts).
|
|
38
38
|
* It does not scope by chain or asset, so a store shared across several chains or
|
|
39
39
|
* assets should namespace the nonce per (chain, asset) itself to keep two distinct
|
|
40
40
|
* payments from colliding; a direct store caller must also pre-fold it.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@craigruks/x402-server-guard",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.2",
|
|
4
4
|
"description": "Server-side hardening middleware for x402 payment endpoints. Mitigations for settlement races, replay, cross-resource substitution, and cache leakage. Not a security guarantee.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"x402",
|
|
@@ -19,7 +19,7 @@
|
|
|
19
19
|
"type": "git",
|
|
20
20
|
"url": "git+https://github.com/craigruks/x402-server-guard.git"
|
|
21
21
|
},
|
|
22
|
-
"homepage": "https://github.
|
|
22
|
+
"homepage": "https://craigruks.github.io/x402-server-guard/",
|
|
23
23
|
"bugs": {
|
|
24
24
|
"url": "https://github.com/craigruks/x402-server-guard/issues"
|
|
25
25
|
},
|
|
@@ -70,21 +70,21 @@
|
|
|
70
70
|
},
|
|
71
71
|
"devDependencies": {
|
|
72
72
|
"@arethetypeswrong/cli": "0.18.5",
|
|
73
|
-
"@biomejs/biome": "2.
|
|
74
|
-
"@cloudflare/workers-types": "5.
|
|
75
|
-
"@types/node": "26.
|
|
76
|
-
"@vitest/coverage-v8": "
|
|
77
|
-
"@x402/core": "2.
|
|
78
|
-
"esbuild": "0.28.
|
|
79
|
-
"hono": "4.
|
|
80
|
-
"miniflare": "4.
|
|
81
|
-
"publint": "0.3.
|
|
82
|
-
"tsx": "4.23.
|
|
73
|
+
"@biomejs/biome": "2.6.4",
|
|
74
|
+
"@cloudflare/workers-types": "5.20261104.1",
|
|
75
|
+
"@types/node": "26.6.6",
|
|
76
|
+
"@vitest/coverage-v8": "5.1.0",
|
|
77
|
+
"@x402/core": "2.29.0",
|
|
78
|
+
"esbuild": "0.28.2",
|
|
79
|
+
"hono": "4.13.16",
|
|
80
|
+
"miniflare": "4.20260730.0",
|
|
81
|
+
"publint": "0.3.25",
|
|
82
|
+
"tsx": "4.23.15",
|
|
83
83
|
"typescript": "7.0.2",
|
|
84
|
-
"vitest": "
|
|
84
|
+
"vitest": "5.1.0"
|
|
85
85
|
},
|
|
86
86
|
"allowScripts": {
|
|
87
|
-
"esbuild@0.28.
|
|
88
|
-
"workerd@1.
|
|
87
|
+
"esbuild@0.28.2": true,
|
|
88
|
+
"workerd@1.20260730.1": true
|
|
89
89
|
}
|
|
90
90
|
}
|
package/src/cache.ts
CHANGED
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Cache directives for paid responses.
|
|
3
3
|
*
|
|
4
|
-
* A shared cache (CDN or reverse proxy)
|
|
4
|
+
* A shared cache (CDN or reverse proxy) is keyed on the URL and knows nothing about
|
|
5
5
|
* payment, so a cacheable paid 200 can be stored and served to the next caller, paid
|
|
6
|
-
* or not. The reference x402 adapters
|
|
6
|
+
* or not. The reference x402 adapters send `private` on the paid 200 as of
|
|
7
|
+
* `@x402/core` 2.21.0 (unchanged through 2.29.0), and set no directive at all before
|
|
8
|
+
* it; neither sets `Vary`.
|
|
7
9
|
*
|
|
8
10
|
* The mitigation is a response directive, not a decision about a nonce, so it lives
|
|
9
11
|
* here as a small pure helper the server or adapter applies to every paid response.
|
|
@@ -22,7 +24,9 @@ export interface CacheDirectives {
|
|
|
22
24
|
export interface CacheDirectivesOptions {
|
|
23
25
|
/**
|
|
24
26
|
* The request header the payment travels in, added to `Vary` so a cache that
|
|
25
|
-
* stores despite `no-store` still keys by payer. Defaults to `X-PAYMENT
|
|
27
|
+
* stores despite `no-store` still keys by payer. Defaults to `X-PAYMENT`, the x402
|
|
28
|
+
* v1 header; an x402 v2 client sends `PAYMENT-SIGNATURE`, so pass that behind a v2
|
|
29
|
+
* server.
|
|
26
30
|
*/
|
|
27
31
|
readonly paymentHeader?: string;
|
|
28
32
|
}
|
package/src/guard.ts
CHANGED
|
@@ -70,7 +70,7 @@ export function createGuard(options: GuardOptions = {}): Guard {
|
|
|
70
70
|
const canonicalizeResource = options.canonicalizeResource ?? canonicalResource;
|
|
71
71
|
return {
|
|
72
72
|
async reserve(params: ReserveParams): Promise<Reservation> {
|
|
73
|
-
// Fold both keys to canonical form up front; everything below
|
|
73
|
+
// Fold both keys to canonical form up front; everything below is keyed on the
|
|
74
74
|
// canonical values (see canonical.ts for why two encodings must not split).
|
|
75
75
|
const nonce = canonicalizeNonce(params.nonce);
|
|
76
76
|
const resource = canonicalizeResource(params.resource);
|
package/src/index.ts
CHANGED
package/src/nonce-store.ts
CHANGED
|
@@ -5,10 +5,10 @@
|
|
|
5
5
|
* are never split by an `await`. The in-memory store below is atomic by construction
|
|
6
6
|
* (a synchronous body runs to completion); a distributed store must use a genuine
|
|
7
7
|
* atomic compare-and-set (a Durable Object, Redis `SET .. NX`, a unique constraint).
|
|
8
|
-
* Get-then-put stores (Workers KV, S3) reopen the race and are not safe here.
|
|
8
|
+
* Get-then-put stores (Workers KV, S3) reopen the race condition and are not safe here.
|
|
9
9
|
*
|
|
10
10
|
* `reserve` and `release` return a `Result`, so a store failure is a value the guard
|
|
11
|
-
* turns into a fail-closed deny. Replay
|
|
11
|
+
* turns into a fail-closed deny. Replay is keyed on the nonce, never the signature, which
|
|
12
12
|
* is what makes it immune to signature malleability. Full rationale: docs/hardening.md.
|
|
13
13
|
* The contract itself lives in `store-types.ts` (Node-free, so non-Node adapters can
|
|
14
14
|
* import it); this module owns the Node in-memory implementation.
|
|
@@ -106,7 +106,7 @@ export class MemoryNonceStore implements NonceStore {
|
|
|
106
106
|
return Promise.resolve(ok({ status: "already-reserved", boundResource: existing.resource }));
|
|
107
107
|
}
|
|
108
108
|
// Reject a fresh reservation once full rather than grow unbounded or evict a live
|
|
109
|
-
// entry (which reopens the race). Overwriting a dead entry is exempt (no growth).
|
|
109
|
+
// entry (which reopens the race condition). Overwriting a dead entry is exempt (no growth).
|
|
110
110
|
if (existing === undefined && this.reserved.size >= this.maxEntries) {
|
|
111
111
|
return Promise.resolve(err(guardError("store-at-capacity", "nonce store at capacity")));
|
|
112
112
|
}
|
package/src/store-types.ts
CHANGED
|
@@ -30,7 +30,7 @@ export type ReserveError = StoreError | GuardError<"store-at-capacity">;
|
|
|
30
30
|
export interface ReserveParams {
|
|
31
31
|
/**
|
|
32
32
|
* The payment's nonce; the x402 exact scheme uses a random 32-byte value. The guard
|
|
33
|
-
*
|
|
33
|
+
* is keyed on this nonce alone (folded to a canonical form by default, see canonical.ts).
|
|
34
34
|
* It does not scope by chain or asset, so a store shared across several chains or
|
|
35
35
|
* assets should namespace the nonce per (chain, asset) itself to keep two distinct
|
|
36
36
|
* payments from colliding; a direct store caller must also pre-fold it.
|