x402-surface-check 0.2.13 → 0.2.15
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 +4 -2
- package/bin/x402-surface-check.mjs +45 -15
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -14,8 +14,9 @@ npx --yes x402-surface-check --endpoint --method POST https://x402.rpc.ankr.com/
|
|
|
14
14
|
|
|
15
15
|
## What It Checks
|
|
16
16
|
|
|
17
|
-
- Manifest endpoint discovery from `items[]`, `endpoints[]`, `x402Endpoints`, category arrays, resource strings, and OpenAPI paths
|
|
17
|
+
- Manifest endpoint discovery from `items[]`, `endpoints[]`, `resources[]`, `x402Endpoints`, category arrays, resource strings, and OpenAPI paths
|
|
18
18
|
- Linked discovery documents via `discovery_url`, `discoveryUrl`, `resources_url`, `resourcesUrl`, or manifest-level OpenAPI links
|
|
19
|
+
- OpenAPI `servers[]` base-path preservation, so `/paths` are probed through the documented gateway rather than the domain root
|
|
19
20
|
- OpenAPI query/path examples and JSON request-body examples for safer no-payment probes
|
|
20
21
|
- No-payment HTTP 402 challenge shape
|
|
21
22
|
- x402 v1 and v2 price fields, including `accepts[]` and `schemes[]` challenge arrays
|
|
@@ -26,7 +27,7 @@ npx --yes x402-surface-check --endpoint --method POST https://x402.rpc.ankr.com/
|
|
|
26
27
|
- Placeholder recipients such as zero addresses and Solana system-program values
|
|
27
28
|
- Testnet or staging rails such as Base Sepolia and Solana devnet
|
|
28
29
|
- HTTPS resource URLs and stable resource metadata
|
|
29
|
-
- Browser CORS allowance for `X-PAYMENT
|
|
30
|
+
- Browser CORS allowance for the requesting origin and `X-PAYMENT`, including the actual 402 challenge response
|
|
30
31
|
- Over-broad public method surfaces
|
|
31
32
|
- Auth, validation, and free/trial responses that appear before a payment challenge, without piling on missing-field findings when no challenge was actually returned
|
|
32
33
|
- Operational health/status endpoints, without treating expected free health checks as paid-route failures
|
|
@@ -44,6 +45,7 @@ Recent public no-payment checks have found and verified real launch fixes:
|
|
|
44
45
|
- anchor-x402: multi-rail x402 challenges verified, with browser preflight blockers isolated before merge. https://github.com/solana-foundation/pay-skills/pull/47#issuecomment-4455678163
|
|
45
46
|
- Agent Trust Bench: linked discovery URL and browser-compatibility notes verified clean for adversarial agent-payment resources. https://github.com/solana-foundation/pay-skills/pull/23#issuecomment-4455722170
|
|
46
47
|
- Solrouter: private LLM inference route verified with HTTPS resource-binding and price-alignment notes. https://github.com/solana-foundation/pay-skills/pull/39#issuecomment-4455800060
|
|
48
|
+
- Tetrac: Solana market-data payment gates verified, with browser payment-header preflight blocker isolated. https://github.com/solana-foundation/pay-skills/pull/32#issuecomment-4455923744
|
|
47
49
|
|
|
48
50
|
Field notes and browser version: https://tateprograms.com/x402-surface-check.html
|
|
49
51
|
|
|
@@ -132,6 +132,12 @@ function endpointUrl(rawPath, baseUrl, sourceUrl) {
|
|
|
132
132
|
return new URL(value, base).toString()
|
|
133
133
|
}
|
|
134
134
|
|
|
135
|
+
function openApiServerBaseUrl(document, sourceUrl) {
|
|
136
|
+
const rawUrl = document.servers?.find(server => typeof server?.url === 'string')?.url
|
|
137
|
+
if (!rawUrl) return documentBaseUrl(document, sourceUrl)
|
|
138
|
+
return endpointUrl(rawUrl, documentBaseUrl(document, sourceUrl), sourceUrl)
|
|
139
|
+
}
|
|
140
|
+
|
|
135
141
|
function linkedDiscoveryUrl(document, sourceUrl) {
|
|
136
142
|
const rawUrl = document?.discovery_url
|
|
137
143
|
?? document?.discoveryUrl
|
|
@@ -217,7 +223,9 @@ function openApiProbeUrl(path, operation, baseUrl) {
|
|
|
217
223
|
}
|
|
218
224
|
}
|
|
219
225
|
|
|
220
|
-
const url =
|
|
226
|
+
const url = /^https?:\/\//i.test(String(resolvedPath))
|
|
227
|
+
? new URL(resolvedPath)
|
|
228
|
+
: new URL(String(resolvedPath).replace(/^\/+/, ''), `${baseUrl.replace(/\/?$/, '/')}`)
|
|
221
229
|
for (const [name, value] of searchParams.entries()) {
|
|
222
230
|
url.searchParams.set(name, value)
|
|
223
231
|
}
|
|
@@ -273,8 +281,7 @@ function endpointEntries(document, sourceUrl, limit) {
|
|
|
273
281
|
}
|
|
274
282
|
|
|
275
283
|
if (document.openapi && document.paths && typeof document.paths === 'object') {
|
|
276
|
-
const baseUrl = document
|
|
277
|
-
?? sourceUrl
|
|
284
|
+
const baseUrl = openApiServerBaseUrl(document, sourceUrl)
|
|
278
285
|
|
|
279
286
|
for (const [path, operations] of Object.entries(document.paths)) {
|
|
280
287
|
if (!operations || typeof operations !== 'object') continue
|
|
@@ -294,17 +301,32 @@ function endpointEntries(document, sourceUrl, limit) {
|
|
|
294
301
|
}
|
|
295
302
|
|
|
296
303
|
for (const resource of document.resources ?? []) {
|
|
297
|
-
if (typeof resource
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
+
if (typeof resource === 'string') {
|
|
305
|
+
const match = resource.match(/^(GET|POST|PUT|PATCH|DELETE)\s+(\S+)/i)
|
|
306
|
+
if (!match) continue
|
|
307
|
+
const [, method, rawPath] = match
|
|
308
|
+
const url = rawPath.startsWith('http')
|
|
309
|
+
? rawPath
|
|
310
|
+
: new URL(rawPath, document.baseUrl ?? sourceUrl).toString()
|
|
311
|
+
entries.push({
|
|
312
|
+
name: rawPath.split('/').filter(Boolean).at(-1) ?? rawPath,
|
|
313
|
+
url,
|
|
314
|
+
method: method.toUpperCase(),
|
|
315
|
+
})
|
|
316
|
+
continue
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
if (!resource || typeof resource !== 'object') continue
|
|
320
|
+
const rawPath = resource.url ?? resource.endpoint ?? resource.resource ?? resource.path
|
|
321
|
+
if (!rawPath) continue
|
|
304
322
|
entries.push({
|
|
305
|
-
name:
|
|
306
|
-
|
|
307
|
-
|
|
323
|
+
name: resource.id
|
|
324
|
+
?? resource.name
|
|
325
|
+
?? resource.title
|
|
326
|
+
?? String(resource.path ?? rawPath).split('/').filter(Boolean).at(-1)
|
|
327
|
+
?? String(rawPath),
|
|
328
|
+
url: endpointUrl(rawPath, baseUrl, sourceUrl),
|
|
329
|
+
method: String(resource.method ?? 'GET').toUpperCase(),
|
|
308
330
|
})
|
|
309
331
|
}
|
|
310
332
|
|
|
@@ -413,7 +435,7 @@ async function fetchDocument(url) {
|
|
|
413
435
|
}
|
|
414
436
|
}
|
|
415
437
|
|
|
416
|
-
async function probeEndpoint(entry) {
|
|
438
|
+
async function probeEndpoint(entry, origin) {
|
|
417
439
|
const method = entry.method ?? 'POST'
|
|
418
440
|
const response = await fetch(entry.url, {
|
|
419
441
|
method,
|
|
@@ -421,6 +443,7 @@ async function probeEndpoint(entry) {
|
|
|
421
443
|
'user-agent': `x402-surface-check/${packageJson.version}`,
|
|
422
444
|
accept: 'application/json',
|
|
423
445
|
'content-type': 'application/json',
|
|
446
|
+
...(origin ? { origin } : {}),
|
|
424
447
|
},
|
|
425
448
|
body: method === 'GET' || method === 'HEAD'
|
|
426
449
|
? undefined
|
|
@@ -641,6 +664,9 @@ function findingList(documentResult, challengeResults, preflightResults, entries
|
|
|
641
664
|
continue
|
|
642
665
|
}
|
|
643
666
|
|
|
667
|
+
if (!result.headers?.['access-control-allow-origin']) {
|
|
668
|
+
findings.push(`P1 - ${result.name} 402 challenge response does not allow the requesting origin; browser agents cannot read the payment requirements even if preflight succeeds.`)
|
|
669
|
+
}
|
|
644
670
|
if (summary.resourceUrl.startsWith('http://') || summary.extraResource.startsWith('http://')) {
|
|
645
671
|
findings.push(`P1 - ${result.name} challenge uses a non-HTTPS resource URL: ${summary.resourceUrl || summary.extraResource}.`)
|
|
646
672
|
}
|
|
@@ -669,6 +695,10 @@ function findingList(documentResult, challengeResults, preflightResults, entries
|
|
|
669
695
|
for (const result of preflightResults) {
|
|
670
696
|
const challengeResult = challengesByEntry.get(entryKey(result))
|
|
671
697
|
if (!challengeResult || !hasPaymentChallenge(challengeResult)) continue
|
|
698
|
+
const allowedOrigin = result.headers['access-control-allow-origin'] ?? ''
|
|
699
|
+
if (!allowedOrigin) {
|
|
700
|
+
findings.push(`P1 - ${result.name} CORS preflight does not allow the requesting origin; observed allow-origin: none.`)
|
|
701
|
+
}
|
|
672
702
|
const allowed = result.headers['access-control-allow-headers'] ?? ''
|
|
673
703
|
if (allowed !== '*' && !/x-payment/i.test(allowed)) {
|
|
674
704
|
const observed = result.status >= 400
|
|
@@ -777,7 +807,7 @@ async function runCheck(options) {
|
|
|
777
807
|
const preflights = []
|
|
778
808
|
|
|
779
809
|
for (const entry of entries) {
|
|
780
|
-
challenges.push(await probeEndpoint(entry))
|
|
810
|
+
challenges.push(await probeEndpoint(entry, origin))
|
|
781
811
|
preflights.push(await probePreflight(entry, origin))
|
|
782
812
|
}
|
|
783
813
|
|