x402-surface-check 0.2.25 → 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 +3 -1
- package/bin/x402-surface-check.mjs +81 -10
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -32,8 +32,10 @@ npx --yes x402-surface-check --strict-cache https://api.example.com/openapi.json
|
|
|
32
32
|
- Placeholder recipients such as zero addresses and Solana system-program values
|
|
33
33
|
- Testnet or staging rails such as Base Sepolia and Solana devnet
|
|
34
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
|
|
35
37
|
- Browser CORS allowance for the requesting origin and `X-PAYMENT`, including the actual 402 challenge response
|
|
36
|
-
- 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
|
|
37
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
|
|
38
40
|
- Grouped finding summaries for repeated route-wide issues, so large manifests keep the patch order readable
|
|
39
41
|
- Contextual reference guides for CORS, cache policy, Worker gates, resource echo, validation/auth ordering, and the May 2026 x402 attack-control map
|
|
@@ -699,6 +699,37 @@ function acceptDecimals(accept) {
|
|
|
699
699
|
return Number.isFinite(numeric) ? numeric : 6
|
|
700
700
|
}
|
|
701
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
|
+
|
|
702
733
|
function usesDecimalAmount(accept, result) {
|
|
703
734
|
const rawAmount = acceptAmountValue(accept)
|
|
704
735
|
if (rawAmount === undefined || rawAmount === null || rawAmount === '') return false
|
|
@@ -734,8 +765,8 @@ function challengeSummary(result) {
|
|
|
734
765
|
const firstAccept = challengeAccepts(result)[0] ?? {}
|
|
735
766
|
const hasChallenge = hasPaymentChallenge(result)
|
|
736
767
|
const amount = acceptAmountValue(firstAccept)
|
|
737
|
-
const resourceUrl = challenge
|
|
738
|
-
const extraResource = firstAccept
|
|
768
|
+
const resourceUrl = challengeResourceValue(challenge) || acceptResourceValue(firstAccept)
|
|
769
|
+
const extraResource = acceptResourceValue(firstAccept)
|
|
739
770
|
|
|
740
771
|
return {
|
|
741
772
|
status: result.status,
|
|
@@ -765,6 +796,21 @@ function looksLikePlaceholderPayTo(payTo) {
|
|
|
765
796
|
return false
|
|
766
797
|
}
|
|
767
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
|
+
|
|
768
814
|
function cachePolicy(headers = {}) {
|
|
769
815
|
return headers['cache-control'] ?? headers.cacheControl ?? ''
|
|
770
816
|
}
|
|
@@ -864,6 +910,9 @@ function findingList(documentResult, challengeResults, preflightResults, entries
|
|
|
864
910
|
if (summary.resourceUrl.startsWith('http://') || summary.extraResource.startsWith('http://')) {
|
|
865
911
|
findings.push(`P1 - ${result.name} challenge uses a non-HTTPS resource URL: ${summary.resourceUrl || summary.extraResource}.`)
|
|
866
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
|
+
}
|
|
867
916
|
if (!summary.amount || !summary.payTo || !summary.asset) {
|
|
868
917
|
findings.push(`P1 - ${result.name} challenge is missing amount/maxAmountRequired, payTo, or asset metadata.`)
|
|
869
918
|
}
|
|
@@ -873,7 +922,12 @@ function findingList(documentResult, challengeResults, preflightResults, entries
|
|
|
873
922
|
findings.push(`P1 - ${result.name} documented price ${moneyFromDecimal(summary.expectedPriceUsd)} does not match live 402 challenge price ${moneyFromDecimal(summary.priceUsd)}.`)
|
|
874
923
|
}
|
|
875
924
|
}
|
|
876
|
-
|
|
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) {
|
|
877
931
|
if (looksLikePlaceholderPayTo(accept.payTo)) {
|
|
878
932
|
findings.push(`P1 - ${result.name} challenge advertises placeholder-looking payTo ${accept.payTo}; production listings should not ask agents to pay placeholder recipients.`)
|
|
879
933
|
}
|
|
@@ -881,11 +935,20 @@ function findingList(documentResult, challengeResults, preflightResults, entries
|
|
|
881
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.`)
|
|
882
936
|
}
|
|
883
937
|
}
|
|
884
|
-
if (!
|
|
885
|
-
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.`)
|
|
886
949
|
}
|
|
887
950
|
if (looksExplicitlyCacheable(result.headers)) {
|
|
888
|
-
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.`)
|
|
889
952
|
}
|
|
890
953
|
else if (options.strictCache && !cachePolicy(result.headers)) {
|
|
891
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.`)
|
|
@@ -933,8 +996,8 @@ function groupedFindingLabel(finding) {
|
|
|
933
996
|
if (/CORS preflight does not allow X-PAYMENT/.test(finding)) {
|
|
934
997
|
return 'P1 - CORS preflight does not allow X-PAYMENT.'
|
|
935
998
|
}
|
|
936
|
-
if (/challenge does not repeat the resource URL/.test(finding)) {
|
|
937
|
-
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.'
|
|
938
1001
|
}
|
|
939
1002
|
if (/returned validation HTTP \d+ before a payment challenge/.test(finding)) {
|
|
940
1003
|
return 'P1 - Routes return validation before a payment challenge.'
|
|
@@ -948,9 +1011,15 @@ function groupedFindingLabel(finding) {
|
|
|
948
1011
|
if (/challenge advertises placeholder-looking payTo/.test(finding)) {
|
|
949
1012
|
return 'P1 - Challenges advertise placeholder-looking payTo recipients.'
|
|
950
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
|
+
}
|
|
951
1017
|
if (/payment challenge response did not expose Cache-Control/.test(finding)) {
|
|
952
1018
|
return 'P3 - Payment challenge responses do not expose Cache-Control in strict cache mode.'
|
|
953
1019
|
}
|
|
1020
|
+
if (/payment challenge response is explicitly cacheable/.test(finding)) {
|
|
1021
|
+
return 'P1 - Payment challenge responses are explicitly cacheable.'
|
|
1022
|
+
}
|
|
954
1023
|
if (/content while payment headers advertise enforcement/.test(finding)) {
|
|
955
1024
|
return 'P2 - Payment headers advertise enforcement on a 200 response.'
|
|
956
1025
|
}
|
|
@@ -982,11 +1051,13 @@ function referenceGuides(findings) {
|
|
|
982
1051
|
add('Cloudflare x402 Worker Starter', 'https://tateprograms.com/cloudflare-x402-worker.html')
|
|
983
1052
|
add('x402 Attack Map 2026', 'https://tateprograms.com/x402-attack-map-2026.html')
|
|
984
1053
|
}
|
|
985
|
-
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)) {
|
|
986
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')
|
|
987
1057
|
}
|
|
988
|
-
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)) {
|
|
989
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')
|
|
990
1061
|
}
|
|
991
1062
|
return guides.map(guide => `- ${guide.label}: ${guide.url}`)
|
|
992
1063
|
}
|