openpay-x402-sdk 0.10.2 → 0.10.3

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/CHANGELOG.md CHANGED
@@ -1,6 +1,24 @@
1
1
  # Changelog
2
2
 
3
- ## 0.10.2 — unreleased
3
+ ## 0.10.3 — 2026-10-10
4
+
5
+ - Widen `isPrivatePaymentHost` / `parseSafePaymentUrl` to the same IANA
6
+ special-purpose table the OpenPay server uses: NAT64 (`64:ff9b::/96`,
7
+ `64:ff9b:1::/48`), 6to4 (`2002::/16`), Teredo (`2001::/32`), the deprecated
8
+ 6to4 relay (`192.88.99.0/24`), IPv4-translated (`::ffff:0:0:0/96`),
9
+ documentation, benchmarking, IETF-assignment, discard-only, segment-routing,
10
+ site-local and reserved ranges are now rejected before DNS, before a custom
11
+ transport runs and at connect time. Mapped IPv4 is judged against the full
12
+ IPv4 table. A `::` that stands for no group is treated as malformed (rejected).
13
+ Public addresses next to each range stay allowed.
14
+ - `resolveLicense` accepts `productUrl: null` (the seller released their last
15
+ @handle). The descriptor keeps the license identity, so gates keep working;
16
+ only the store page link is absent. Any non-null `productUrl` is validated as
17
+ before. `LicenseDescriptor.productUrl` is now `string | null`. 0.10.2 and
18
+ earlier reject such a descriptor with `invalid_response`.
19
+ - No dependency changes.
20
+
21
+ ## 0.10.2 — 2026-09-24
4
22
 
5
23
  - Fix `createDualGate` USDC authorization reuse before settlement. Reuse JPYC's
6
24
  local claim manager for v1/v2 payments, keyed by pinned chain, asset, payer and
package/README.md CHANGED
@@ -611,7 +611,12 @@ splits, amount inconsistencies,
611
611
  resource URL mismatches, and catalog bait-and-switches before requesting a
612
612
  signature. `MAX_TIMEOUT_SECONDS` is the equivalent setting for the exported
613
613
  environment config readers. Target host/catalog admission and private-address
614
- checks run before buyer target requests. Those requests require HTTPS, do not
614
+ checks run before buyer target requests. The private-address check uses the
615
+ same IANA special-purpose table as the OpenPay server: loopback, private,
616
+ CGNAT, link-local, documentation, benchmarking, multicast and reserved IPv4;
617
+ IPv4-mapped/translated, NAT64, 6to4, Teredo, ULA, site-local and multicast
618
+ IPv6; plus `localhost`, `.localhost`, `.local` and `.internal` names. Those
619
+ requests require HTTPS, do not
615
620
  follow redirects, and have a 15-second timeout. The default Node transport also
616
621
  validates DNS before and during connection to block rebinding. A custom
617
622
  `fetchImpl` still gets the pre-connection resolution check — a hostname that
package/index.d.ts CHANGED
@@ -382,7 +382,8 @@ export interface LicenseDescriptor {
382
382
  remaining: number | null;
383
383
  saleActive: boolean;
384
384
  registered: boolean;
385
- productUrl: string;
385
+ /** Store page link; null when the seller no longer has a public @handle (the license identity stays valid). */
386
+ productUrl: string | null;
386
387
  /** Append the wallet address query parameter to check rights. */
387
388
  verifyUrl: string;
388
389
  sellerRole: 'operator' | 'third_party';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "openpay-x402-sdk",
3
- "version": "0.10.2",
3
+ "version": "0.10.3",
4
4
  "description": "Guarded Node.js buyer SDK for OpenPay x402 JPYC resources",
5
5
  "type": "module",
6
6
  "main": "./src/index.mjs",
@@ -27,7 +27,8 @@
27
27
  "node": ">=20"
28
28
  },
29
29
  "scripts": {
30
- "test": "node --test tests/*.test.mjs"
30
+ "test": "node --test tests/*.test.mjs",
31
+ "prepublishOnly": "npm test"
31
32
  },
32
33
  "dependencies": {
33
34
  "viem": "^2.45.0"
package/src/license.mjs CHANGED
@@ -146,10 +146,11 @@ export async function resolveLicense({ product, origin = DEFAULT_LICENSE_ORIGIN,
146
146
  (body.remaining !== null && (!Number.isSafeInteger(body.remaining) || body.remaining < 0 || body.remaining > body.supply)) ||
147
147
  !['operator', 'third_party'].includes(body.sellerRole)) throw new Error('Invalid descriptor fields');
148
148
  httpsUrl(body.termsUrl);
149
- const productUrl = httpsUrl(body.productUrl);
149
+ // productUrl is null when the seller no longer has a public @handle; the license identity stays valid.
150
+ const productUrl = body.productUrl === null ? null : httpsUrl(body.productUrl);
150
151
  const verifyUrl = httpsUrl(body.verifyUrl);
151
- if (productUrl.origin !== DEFAULT_LICENSE_ORIGIN || !/^\/@[^/]+$/.test(productUrl.pathname) || productUrl.hash ||
152
- productUrl.search !== `?product=${product}` || verifyUrl.origin !== DEFAULT_LICENSE_ORIGIN ||
152
+ if ((productUrl && (productUrl.origin !== DEFAULT_LICENSE_ORIGIN || !/^\/@[^/]+$/.test(productUrl.pathname) || productUrl.hash ||
153
+ productUrl.search !== `?product=${product}`)) || verifyUrl.origin !== DEFAULT_LICENSE_ORIGIN ||
153
154
  verifyUrl.pathname !== '/api/license/verify' || verifyUrl.search !== `?product=${product}` || verifyUrl.hash) {
154
155
  throw new Error('Invalid descriptor links');
155
156
  }
package/src/network.mjs CHANGED
@@ -6,13 +6,69 @@ function normalizeHost(hostname) {
6
6
  return hostname.replace(/^\[|\]$/g, '').replace(/\.+$/, '').toLowerCase();
7
7
  }
8
8
 
9
- function isPrivateIpv4(a, b) {
10
- if (a === 0 || a === 10 || a === 127) return true;
11
- if (a === 100 && b >= 64 && b <= 127) return true;
12
- if (a === 169 && b === 254) return true;
13
- if (a === 172 && b >= 16 && b <= 31) return true;
14
- if (a === 192 && b === 168) return true;
15
- return a >= 224;
9
+ // SSRF boundary shared with the OpenPay server (lib/net/privateHost.ts): private,
10
+ // non-unicast, documentation and transition ranges from the IANA special-purpose
11
+ // registries (checked 2026-09-23). Translation/tunnel ranges (NAT64, 6to4, Teredo)
12
+ // are rejected as a whole even where IANA lists globally reachable exceptions, so a
13
+ // DNS answer like 64:ff9b::a9fe:a9fe (NAT64 to 169.254.169.254) or 2002:c0a8:101::
14
+ // (6to4 to 192.168.1.1) never carries a signed X-PAYMENT into the buyer's own network.
15
+ // Keep both tables identical; tests/packages/x402-sdk-network.test.ts compares them.
16
+ const IPV4_CIDRS = [
17
+ ['0.0.0.0', 8], // this network
18
+ ['10.0.0.0', 8], // private
19
+ ['100.64.0.0', 10], // CGNAT
20
+ ['127.0.0.0', 8], // loopback
21
+ ['169.254.0.0', 16], // link-local
22
+ ['172.16.0.0', 12], // private
23
+ ['192.0.0.0', 24], // IETF protocol assignments
24
+ ['192.0.2.0', 24], // documentation
25
+ ['192.88.99.0', 24], // deprecated 6to4 relay anycast
26
+ ['192.168.0.0', 16], // private
27
+ ['198.18.0.0', 15], // benchmarking
28
+ ['198.51.100.0', 24], // documentation
29
+ ['203.0.113.0', 24], // documentation
30
+ ['224.0.0.0', 4], // multicast
31
+ ['240.0.0.0', 4], // reserved (includes limited broadcast)
32
+ ];
33
+
34
+ const IPV6_CIDRS = [
35
+ ['::', 128], // unspecified
36
+ ['::1', 128], // loopback
37
+ ['::ffff:0:0:0', 96], // IPv4-translated (RFC 6145)
38
+ ['64:ff9b::', 96], // NAT64 well-known prefix
39
+ ['64:ff9b:1::', 48], // local-use NAT64
40
+ ['100::', 64], // discard-only
41
+ ['100:0:0:1::', 64], // dummy prefix
42
+ ['2001::', 32], // Teredo
43
+ ['2001:2::', 48], // benchmarking
44
+ ['2001:db8::', 32], // documentation
45
+ ['2002::', 16], // 6to4
46
+ ['3fff::', 20], // documentation
47
+ ['5f00::', 16], // segment routing SIDs
48
+ ['fc00::', 7], // ULA
49
+ ['fe80::', 10], // link-local
50
+ ['fec0::', 10], // deprecated site-local
51
+ ['ff00::', 8], // multicast
52
+ ];
53
+
54
+ const IPV4_PREFIXES = IPV4_CIDRS.map(([base, bits]) => ({
55
+ groups: base.split('.').map(Number), bits,
56
+ }));
57
+ const IPV6_PREFIXES = IPV6_CIDRS.map(([base, bits]) => ({
58
+ groups: expandIpv6(base), bits,
59
+ }));
60
+
61
+ // Compare only the bits inside the prefix, including a CIDR that ends mid-group.
62
+ function matchesPrefix(address, prefix, groupBits) {
63
+ for (let i = 0; i * groupBits < prefix.bits; i += 1) {
64
+ const shift = Math.max(0, (i + 1) * groupBits - prefix.bits);
65
+ if (address[i] >>> shift !== prefix.groups[i] >>> shift) return false;
66
+ }
67
+ return true;
68
+ }
69
+
70
+ function isPrivateIpv4(octets) {
71
+ return IPV4_PREFIXES.some((prefix) => matchesPrefix(octets, prefix, 8));
16
72
  }
17
73
 
18
74
  function expandIpv6(hostname) {
@@ -35,7 +91,8 @@ function expandIpv6(hostname) {
35
91
  groups = head;
36
92
  } else {
37
93
  const fill = 8 - head.length - tail.length;
38
- if (fill < 0) return null;
94
+ // `::` must stand for at least one zero group (same rule as the server).
95
+ if (fill < 1) return null;
39
96
  groups = [...head, ...Array(fill).fill('0'), ...tail];
40
97
  }
41
98
  if (groups.length !== 8) return null;
@@ -59,25 +116,19 @@ export function isPrivatePaymentHost(hostname) {
59
116
  if (host.includes(':')) {
60
117
  const groups = expandIpv6(host);
61
118
  if (groups === null) return true;
62
- if (groups.every((group) => group === 0)) return true;
63
- if (
64
- groups.slice(0, 7).every((group) => group === 0) &&
65
- groups[7] === 1
66
- ) {
67
- return true;
68
- }
119
+ if (IPV6_PREFIXES.some((prefix) => matchesPrefix(groups, prefix, 16))) return true;
120
+ // IPv4-mapped (::ffff:a.b.c.d) and IPv4-compatible (::a.b.c.d) literals are the
121
+ // socket's IPv4 form; judge them by the full IPv4 table.
69
122
  if (
70
123
  groups.slice(0, 5).every((group) => group === 0) &&
71
124
  (groups[5] === 0xffff || groups[5] === 0)
72
125
  ) {
73
- return isPrivateIpv4(
74
- (groups[6] >> 8) & 0xff,
75
- groups[6] & 0xff,
76
- );
126
+ return isPrivateIpv4([
127
+ (groups[6] >> 8) & 0xff, groups[6] & 0xff,
128
+ (groups[7] >> 8) & 0xff, groups[7] & 0xff,
129
+ ]);
77
130
  }
78
- if (groups[0] >= 0xfc00 && groups[0] <= 0xfdff) return true;
79
- if (groups[0] >= 0xfe80 && groups[0] <= 0xfebf) return true;
80
- return groups[0] >= 0xff00;
131
+ return false;
81
132
  }
82
133
 
83
134
  const ipv4 = host.match(
@@ -86,7 +137,7 @@ export function isPrivatePaymentHost(hostname) {
86
137
  if (!ipv4) return false;
87
138
  const octets = ipv4.slice(1).map(Number);
88
139
  if (octets.some((octet) => octet > 255)) return true;
89
- return isPrivateIpv4(octets[0], octets[1]);
140
+ return isPrivateIpv4(octets);
90
141
  }
91
142
 
92
143
  export function parseSafePaymentUrl(raw) {