x402-surface-check 0.2.24 → 0.2.26
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 +5 -2
- package/bin/x402-surface-check.mjs +147 -10
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -17,7 +17,8 @@ npx --yes x402-surface-check --strict-cache https://api.example.com/openapi.json
|
|
|
17
17
|
|
|
18
18
|
## What It Checks
|
|
19
19
|
|
|
20
|
-
- Manifest endpoint discovery from `items[]`, `endpoints[]`, `resources[]`, `x402Endpoints`, category arrays, raw resource URL strings, method-prefixed resource strings, and OpenAPI paths
|
|
20
|
+
- Manifest endpoint discovery from `items[]`, `endpoints[]`, object-valued `endpoints`, `resources[]`, `x402Endpoints`, category arrays, raw resource URL strings, method-prefixed resource strings, and OpenAPI paths
|
|
21
|
+
- Object-valued manifest endpoint query examples, public catalog/discovery GETs, and payment-bearing two-phase operations without treating expected public catalog reads as failed payment gates
|
|
21
22
|
- Linked discovery documents via `discovery_url`, `discoveryUrl`, `resources_url`, `resourcesUrl`, or manifest-level OpenAPI links
|
|
22
23
|
- OpenAPI `servers[]` base-path preservation, so `/paths` are probed through the documented gateway rather than the domain root
|
|
23
24
|
- OpenAPI query/path examples, JSON request-body examples, nested request schemas, local `$ref` request schemas, and explicit direct-endpoint bodies for safer no-payment probes
|
|
@@ -31,8 +32,10 @@ npx --yes x402-surface-check --strict-cache https://api.example.com/openapi.json
|
|
|
31
32
|
- Placeholder recipients such as zero addresses and Solana system-program values
|
|
32
33
|
- Testnet or staging rails such as Base Sepolia and Solana devnet
|
|
33
34
|
- HTTPS resource URLs and stable resource metadata
|
|
35
|
+
- Resource binding across top-level `resource.url` and every accept leg, including localhost/private-development resource URLs that should not ship in production
|
|
36
|
+
- Timeout/expiry metadata on challenges, so payment capabilities have an explicit bounded freshness window
|
|
34
37
|
- Browser CORS allowance for the requesting origin and `X-PAYMENT`, including the actual 402 challenge response
|
|
35
|
-
- Cache-Control posture on no-payment challenge responses, with warnings for explicitly cacheable payment gates and optional strict-cache findings for missing policy headers
|
|
38
|
+
- Cache-Control posture on no-payment challenge responses, with P1 warnings for explicitly cacheable payment gates and optional strict-cache findings for missing policy headers
|
|
36
39
|
- Payment-enforcement headers on `200` responses, so public telemetry/free-trial endpoints do not accidentally advertise enforced x402 while returning content before a challenge
|
|
37
40
|
- Grouped finding summaries for repeated route-wide issues, so large manifests keep the patch order readable
|
|
38
41
|
- Contextual reference guides for CORS, cache policy, Worker gates, resource echo, validation/auth ordering, and the May 2026 x402 attack-control map
|
|
@@ -322,6 +322,47 @@ function openApiProbeUrl(path, operation, baseUrl, document) {
|
|
|
322
322
|
return url.toString()
|
|
323
323
|
}
|
|
324
324
|
|
|
325
|
+
function manifestEndpointPaymentSignal(endpoint) {
|
|
326
|
+
if (!endpoint || typeof endpoint !== 'object') return 0
|
|
327
|
+
if (Number(endpoint.phase1_response?.status) === 402) return 2
|
|
328
|
+
if (/payment-required|x-payment|402/i.test(String(endpoint.phase1_response?.header ?? ''))) return 2
|
|
329
|
+
if (/payment|required|402/i.test(String(endpoint.description ?? ''))) return 1
|
|
330
|
+
if (endpoint.accepts || endpoint.schemes || endpoint.payment || endpoint['x-payment-info']) return 1
|
|
331
|
+
return 0
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
function manifestEndpointBody(endpoint, document) {
|
|
335
|
+
const body = endpoint?.request_body ?? endpoint?.requestBody
|
|
336
|
+
if (!body || typeof body !== 'object') return undefined
|
|
337
|
+
if (body.example !== undefined) return body.example
|
|
338
|
+
if (body.safe_example !== undefined) return body.safe_example
|
|
339
|
+
if (body.safeExample !== undefined) return body.safeExample
|
|
340
|
+
return exampleValue(body, document)
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
function manifestEndpointUrl(rawPath, endpoint, baseUrl, sourceUrl) {
|
|
344
|
+
const url = new URL(endpointUrl(rawPath, baseUrl, sourceUrl))
|
|
345
|
+
const parameters = endpoint?.parameters
|
|
346
|
+
if (!parameters || typeof parameters !== 'object') return url.toString()
|
|
347
|
+
|
|
348
|
+
for (const [name, parameter] of Object.entries(parameters)) {
|
|
349
|
+
if (url.pathname.includes(`{${name}}`)) {
|
|
350
|
+
const pathValue = parameter?.example ?? parameter?.default
|
|
351
|
+
if (pathValue !== undefined && pathValue !== '') {
|
|
352
|
+
url.pathname = url.pathname.replaceAll(`{${name}}`, encodeURIComponent(String(pathValue)))
|
|
353
|
+
}
|
|
354
|
+
continue
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
const value = parameter?.example ?? parameter?.default
|
|
358
|
+
if (value !== undefined && value !== '') {
|
|
359
|
+
url.searchParams.set(name, String(value))
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
return url.toString()
|
|
364
|
+
}
|
|
365
|
+
|
|
325
366
|
function endpointEntries(document, sourceUrl, limit) {
|
|
326
367
|
const entries = []
|
|
327
368
|
const baseUrl = documentBaseUrl(document, sourceUrl)
|
|
@@ -356,6 +397,24 @@ function endpointEntries(document, sourceUrl, limit) {
|
|
|
356
397
|
})
|
|
357
398
|
}
|
|
358
399
|
}
|
|
400
|
+
else if (document.endpoints && typeof document.endpoints === 'object') {
|
|
401
|
+
for (const [key, endpoint] of Object.entries(document.endpoints)) {
|
|
402
|
+
if (!endpoint || typeof endpoint !== 'object') continue
|
|
403
|
+
const rawPath = endpoint.url ?? endpoint.endpoint ?? endpoint.path
|
|
404
|
+
if (!rawPath) continue
|
|
405
|
+
const method = String(endpoint.method ?? 'POST').toUpperCase()
|
|
406
|
+
const paymentSignal = manifestEndpointPaymentSignal(endpoint)
|
|
407
|
+
const hasPathParameters = /\{[^}]+\}/.test(String(rawPath))
|
|
408
|
+
if (paymentSignal === 0 && (method !== 'GET' || hasPathParameters)) continue
|
|
409
|
+
entries.push({
|
|
410
|
+
name: endpoint.id ?? endpoint.name ?? key,
|
|
411
|
+
url: manifestEndpointUrl(rawPath, endpoint, baseUrl, sourceUrl),
|
|
412
|
+
method,
|
|
413
|
+
requestBody: manifestEndpointBody(endpoint, document),
|
|
414
|
+
publicDiscovery: paymentSignal === 0,
|
|
415
|
+
})
|
|
416
|
+
}
|
|
417
|
+
}
|
|
359
418
|
|
|
360
419
|
if (Array.isArray(document.items)) {
|
|
361
420
|
for (const item of document.items) {
|
|
@@ -640,6 +699,37 @@ function acceptDecimals(accept) {
|
|
|
640
699
|
return Number.isFinite(numeric) ? numeric : 6
|
|
641
700
|
}
|
|
642
701
|
|
|
702
|
+
function acceptResourceValue(accept) {
|
|
703
|
+
return accept.resource
|
|
704
|
+
?? accept.extra?.resource
|
|
705
|
+
?? accept.resourceUrl
|
|
706
|
+
?? accept.extra?.resourceUrl
|
|
707
|
+
?? ''
|
|
708
|
+
}
|
|
709
|
+
|
|
710
|
+
function challengeResourceValue(challenge) {
|
|
711
|
+
return challenge?.resource?.url
|
|
712
|
+
?? challenge?.resourceUrl
|
|
713
|
+
?? ''
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
function hasFreshnessMetadata(challenge, accept) {
|
|
717
|
+
return [
|
|
718
|
+
challenge?.expires,
|
|
719
|
+
challenge?.expiresAt,
|
|
720
|
+
challenge?.validBefore,
|
|
721
|
+
challenge?.maxTimeoutSeconds,
|
|
722
|
+
accept?.maxTimeoutSeconds,
|
|
723
|
+
accept?.maxTimeout,
|
|
724
|
+
accept?.timeout,
|
|
725
|
+
accept?.expires,
|
|
726
|
+
accept?.expiresAt,
|
|
727
|
+
accept?.validBefore,
|
|
728
|
+
accept?.extra?.expires,
|
|
729
|
+
accept?.extra?.validBefore,
|
|
730
|
+
].some(value => value !== undefined && value !== null && value !== '')
|
|
731
|
+
}
|
|
732
|
+
|
|
643
733
|
function usesDecimalAmount(accept, result) {
|
|
644
734
|
const rawAmount = acceptAmountValue(accept)
|
|
645
735
|
if (rawAmount === undefined || rawAmount === null || rawAmount === '') return false
|
|
@@ -675,8 +765,8 @@ function challengeSummary(result) {
|
|
|
675
765
|
const firstAccept = challengeAccepts(result)[0] ?? {}
|
|
676
766
|
const hasChallenge = hasPaymentChallenge(result)
|
|
677
767
|
const amount = acceptAmountValue(firstAccept)
|
|
678
|
-
const resourceUrl = challenge
|
|
679
|
-
const extraResource = firstAccept
|
|
768
|
+
const resourceUrl = challengeResourceValue(challenge) || acceptResourceValue(firstAccept)
|
|
769
|
+
const extraResource = acceptResourceValue(firstAccept)
|
|
680
770
|
|
|
681
771
|
return {
|
|
682
772
|
status: result.status,
|
|
@@ -706,6 +796,21 @@ function looksLikePlaceholderPayTo(payTo) {
|
|
|
706
796
|
return false
|
|
707
797
|
}
|
|
708
798
|
|
|
799
|
+
function looksLikeLocalResourceUrl(value) {
|
|
800
|
+
if (!/^https?:\/\//i.test(String(value ?? ''))) return false
|
|
801
|
+
try {
|
|
802
|
+
const host = new URL(value).hostname.toLowerCase()
|
|
803
|
+
return host === 'localhost'
|
|
804
|
+
|| host === '0.0.0.0'
|
|
805
|
+
|| host === '127.0.0.1'
|
|
806
|
+
|| host === '::1'
|
|
807
|
+
|| host.endsWith('.local')
|
|
808
|
+
}
|
|
809
|
+
catch {
|
|
810
|
+
return false
|
|
811
|
+
}
|
|
812
|
+
}
|
|
813
|
+
|
|
709
814
|
function cachePolicy(headers = {}) {
|
|
710
815
|
return headers['cache-control'] ?? headers.cacheControl ?? ''
|
|
711
816
|
}
|
|
@@ -768,6 +873,13 @@ function findingList(documentResult, challengeResults, preflightResults, entries
|
|
|
768
873
|
if (summary.network) challengeNetworks.add(summary.network)
|
|
769
874
|
const hasChallenge = hasPaymentChallenge(result)
|
|
770
875
|
|
|
876
|
+
if (result.publicDiscovery && !hasChallenge) {
|
|
877
|
+
if (result.status < 200 || result.status >= 300) {
|
|
878
|
+
findings.push(`P2 - ${result.name} is documented as a public discovery route but returned HTTP ${result.status}; check the manifest example parameters or route availability.`)
|
|
879
|
+
}
|
|
880
|
+
continue
|
|
881
|
+
}
|
|
882
|
+
|
|
771
883
|
if (result.status !== 402) {
|
|
772
884
|
if (result.status >= 200 && result.status < 300) {
|
|
773
885
|
if (!looksLikeOperationalHealthEndpoint(result)) {
|
|
@@ -798,6 +910,9 @@ function findingList(documentResult, challengeResults, preflightResults, entries
|
|
|
798
910
|
if (summary.resourceUrl.startsWith('http://') || summary.extraResource.startsWith('http://')) {
|
|
799
911
|
findings.push(`P1 - ${result.name} challenge uses a non-HTTPS resource URL: ${summary.resourceUrl || summary.extraResource}.`)
|
|
800
912
|
}
|
|
913
|
+
if (looksLikeLocalResourceUrl(summary.resourceUrl) || looksLikeLocalResourceUrl(summary.extraResource)) {
|
|
914
|
+
findings.push(`P1 - ${result.name} challenge binds payment to a localhost/private-development resource URL: ${summary.resourceUrl || summary.extraResource}.`)
|
|
915
|
+
}
|
|
801
916
|
if (!summary.amount || !summary.payTo || !summary.asset) {
|
|
802
917
|
findings.push(`P1 - ${result.name} challenge is missing amount/maxAmountRequired, payTo, or asset metadata.`)
|
|
803
918
|
}
|
|
@@ -807,7 +922,12 @@ function findingList(documentResult, challengeResults, preflightResults, entries
|
|
|
807
922
|
findings.push(`P1 - ${result.name} documented price ${moneyFromDecimal(summary.expectedPriceUsd)} does not match live 402 challenge price ${moneyFromDecimal(summary.priceUsd)}.`)
|
|
808
923
|
}
|
|
809
924
|
}
|
|
810
|
-
|
|
925
|
+
const accepts = challengeAccepts(result)
|
|
926
|
+
const topResource = challengeResourceValue(result.body.json)
|
|
927
|
+
const acceptResources = accepts.map(acceptResourceValue)
|
|
928
|
+
const populatedAcceptResources = acceptResources.filter(Boolean)
|
|
929
|
+
|
|
930
|
+
for (const accept of accepts) {
|
|
811
931
|
if (looksLikePlaceholderPayTo(accept.payTo)) {
|
|
812
932
|
findings.push(`P1 - ${result.name} challenge advertises placeholder-looking payTo ${accept.payTo}; production listings should not ask agents to pay placeholder recipients.`)
|
|
813
933
|
}
|
|
@@ -815,11 +935,20 @@ function findingList(documentResult, challengeResults, preflightResults, entries
|
|
|
815
935
|
findings.push(`P2 - ${result.name} challenge advertises staging/test network ${accept.network}; document this as demo-only until live-value payment rails are active.`)
|
|
816
936
|
}
|
|
817
937
|
}
|
|
818
|
-
if (!
|
|
819
|
-
findings.push(`P2 - ${result.name} challenge does not
|
|
938
|
+
if (!topResource && populatedAcceptResources.length === 0) {
|
|
939
|
+
findings.push(`P2 - ${result.name} challenge does not expose a signed/intended resource URL at the top level or in any accept leg.`)
|
|
940
|
+
}
|
|
941
|
+
else if (accepts.length > 0 && populatedAcceptResources.length < accepts.length) {
|
|
942
|
+
findings.push(`P2 - ${result.name} challenge does not repeat the resource URL in every accept leg for spend-map and replay binding.`)
|
|
943
|
+
}
|
|
944
|
+
if (topResource && populatedAcceptResources.some(resource => resource !== topResource)) {
|
|
945
|
+
findings.push(`P2 - ${result.name} challenge resource URL differs between resource.url and one or more accept legs; agents may bind the payment to inconsistent resources.`)
|
|
946
|
+
}
|
|
947
|
+
if (accepts.length > 0 && !accepts.some(accept => hasFreshnessMetadata(result.body.json, accept))) {
|
|
948
|
+
findings.push(`P2 - ${result.name} challenge does not expose timeout/expiry metadata; bounded freshness helps reduce replay windows for payment capabilities.`)
|
|
820
949
|
}
|
|
821
950
|
if (looksExplicitlyCacheable(result.headers)) {
|
|
822
|
-
findings.push(`
|
|
951
|
+
findings.push(`P1 - ${result.name} payment challenge response is explicitly cacheable (${cachePolicy(result.headers)}); paid routes should use no-store/private cache policy or bypass shared caches.`)
|
|
823
952
|
}
|
|
824
953
|
else if (options.strictCache && !cachePolicy(result.headers)) {
|
|
825
954
|
findings.push(`P3 - ${result.name} payment challenge response did not expose Cache-Control; for payment-gated routes, document or send no-store/private cache policy and confirm paid 200 responses are never shared-cacheable.`)
|
|
@@ -867,8 +996,8 @@ function groupedFindingLabel(finding) {
|
|
|
867
996
|
if (/CORS preflight does not allow X-PAYMENT/.test(finding)) {
|
|
868
997
|
return 'P1 - CORS preflight does not allow X-PAYMENT.'
|
|
869
998
|
}
|
|
870
|
-
if (/challenge does not repeat the resource URL/.test(finding)) {
|
|
871
|
-
return 'P2 -
|
|
999
|
+
if (/challenge does not expose a signed\/intended resource URL|challenge does not repeat the resource URL|challenge resource URL differs/.test(finding)) {
|
|
1000
|
+
return 'P2 - Challenges have incomplete or inconsistent resource binding.'
|
|
872
1001
|
}
|
|
873
1002
|
if (/returned validation HTTP \d+ before a payment challenge/.test(finding)) {
|
|
874
1003
|
return 'P1 - Routes return validation before a payment challenge.'
|
|
@@ -882,9 +1011,15 @@ function groupedFindingLabel(finding) {
|
|
|
882
1011
|
if (/challenge advertises placeholder-looking payTo/.test(finding)) {
|
|
883
1012
|
return 'P1 - Challenges advertise placeholder-looking payTo recipients.'
|
|
884
1013
|
}
|
|
1014
|
+
if (/challenge does not expose timeout\/expiry metadata/.test(finding)) {
|
|
1015
|
+
return 'P2 - Challenges do not expose timeout or expiry metadata for replay-window control.'
|
|
1016
|
+
}
|
|
885
1017
|
if (/payment challenge response did not expose Cache-Control/.test(finding)) {
|
|
886
1018
|
return 'P3 - Payment challenge responses do not expose Cache-Control in strict cache mode.'
|
|
887
1019
|
}
|
|
1020
|
+
if (/payment challenge response is explicitly cacheable/.test(finding)) {
|
|
1021
|
+
return 'P1 - Payment challenge responses are explicitly cacheable.'
|
|
1022
|
+
}
|
|
888
1023
|
if (/content while payment headers advertise enforcement/.test(finding)) {
|
|
889
1024
|
return 'P2 - Payment headers advertise enforcement on a 200 response.'
|
|
890
1025
|
}
|
|
@@ -916,11 +1051,13 @@ function referenceGuides(findings) {
|
|
|
916
1051
|
add('Cloudflare x402 Worker Starter', 'https://tateprograms.com/cloudflare-x402-worker.html')
|
|
917
1052
|
add('x402 Attack Map 2026', 'https://tateprograms.com/x402-attack-map-2026.html')
|
|
918
1053
|
}
|
|
919
|
-
if (/validation HTTP \d+ before a payment challenge|auth HTTP \d+ before a payment challenge|replay|idempotency/i.test(text)) {
|
|
1054
|
+
if (/validation HTTP \d+ before a payment challenge|auth HTTP \d+ before a payment challenge|replay|idempotency|timeout\/expiry|freshness/i.test(text)) {
|
|
920
1055
|
add('x402 Launch Checklist', 'https://tateprograms.com/x402-launch-checklist.html')
|
|
1056
|
+
add('x402 Attack Map 2026', 'https://tateprograms.com/x402-attack-map-2026.html')
|
|
921
1057
|
}
|
|
922
|
-
if (/resource URL|resource echo|accepts\[0\]\.extra\.resource/i.test(text)) {
|
|
1058
|
+
if (/resource URL|resource echo|resource binding|accept leg|accepts\[0\]\.extra\.resource/i.test(text)) {
|
|
923
1059
|
add('x402 Surface Check notes', 'https://tateprograms.com/x402-surface-check.html')
|
|
1060
|
+
add('x402 Attack Map 2026', 'https://tateprograms.com/x402-attack-map-2026.html')
|
|
924
1061
|
}
|
|
925
1062
|
return guides.map(guide => `- ${guide.label}: ${guide.url}`)
|
|
926
1063
|
}
|