x402-surface-check 0.2.25 → 0.2.27
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 -1
- package/bin/x402-surface-check.mjs +137 -10
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -32,9 +32,12 @@ 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
|
|
40
|
+
- Credential-like query params and URL userinfo inside public registry/discovery endpoint URLs, reported with redacted values so provider API keys and tokens are not repeated in scan output
|
|
38
41
|
- Grouped finding summaries for repeated route-wide issues, so large manifests keep the patch order readable
|
|
39
42
|
- Contextual reference guides for CORS, cache policy, Worker gates, resource echo, validation/auth ordering, and the May 2026 x402 attack-control map
|
|
40
43
|
- Over-broad public method surfaces
|
|
@@ -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,70 @@ 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
|
+
|
|
814
|
+
const secretQueryParamPattern =
|
|
815
|
+
/^(?:access[_-]?token|api[_-]?key|auth|authorization|bearer|client[_-]?secret|code|key|password|private[_-]?key|secret|session|sig|signature|token|jwt)$/i
|
|
816
|
+
|
|
817
|
+
function redactedCredentialUrl(value) {
|
|
818
|
+
if (!/^https?:\/\//i.test(String(value ?? ''))) return null
|
|
819
|
+
try {
|
|
820
|
+
const url = new URL(value)
|
|
821
|
+
let changed = false
|
|
822
|
+
|
|
823
|
+
if (url.username || url.password) {
|
|
824
|
+
if (url.username) url.username = 'REDACTED'
|
|
825
|
+
if (url.password) url.password = 'REDACTED'
|
|
826
|
+
changed = true
|
|
827
|
+
}
|
|
828
|
+
|
|
829
|
+
for (const [name] of Array.from(url.searchParams.entries())) {
|
|
830
|
+
if (secretQueryParamPattern.test(name)) {
|
|
831
|
+
url.searchParams.set(name, 'REDACTED')
|
|
832
|
+
changed = true
|
|
833
|
+
}
|
|
834
|
+
}
|
|
835
|
+
|
|
836
|
+
return changed ? url.toString() : null
|
|
837
|
+
}
|
|
838
|
+
catch {
|
|
839
|
+
return null
|
|
840
|
+
}
|
|
841
|
+
}
|
|
842
|
+
|
|
843
|
+
function publicUrlCredentialFindings(value, path = 'document', depth = 0) {
|
|
844
|
+
if (depth > 8 || value === null || value === undefined) return []
|
|
845
|
+
if (typeof value === 'string') {
|
|
846
|
+
const redacted = redactedCredentialUrl(value)
|
|
847
|
+
return redacted
|
|
848
|
+
? [`P2 - Public document exposes credential-like URL material at ${path}: ${redacted}. Move provider tokens, signatures, sessions, or API keys out of registry-visible endpoint URLs.`]
|
|
849
|
+
: []
|
|
850
|
+
}
|
|
851
|
+
if (Array.isArray(value)) {
|
|
852
|
+
return value.flatMap((item, index) => publicUrlCredentialFindings(item, `${path}[${index}]`, depth + 1))
|
|
853
|
+
}
|
|
854
|
+
if (typeof value === 'object') {
|
|
855
|
+
return Object.entries(value).flatMap(([key, item]) => {
|
|
856
|
+
const safeKey = /^[a-zA-Z_$][\w$-]*$/.test(key) ? `.${key}` : `[${JSON.stringify(key)}]`
|
|
857
|
+
return publicUrlCredentialFindings(item, `${path}${safeKey}`, depth + 1)
|
|
858
|
+
})
|
|
859
|
+
}
|
|
860
|
+
return []
|
|
861
|
+
}
|
|
862
|
+
|
|
768
863
|
function cachePolicy(headers = {}) {
|
|
769
864
|
return headers['cache-control'] ?? headers.cacheControl ?? ''
|
|
770
865
|
}
|
|
@@ -817,6 +912,9 @@ function findingList(documentResult, challengeResults, preflightResults, entries
|
|
|
817
912
|
if (!documentResult.body.json) {
|
|
818
913
|
findings.push(`P1 - Document did not return parseable JSON; content begins: ${documentResult.body.text.slice(0, 80).replace(/\s+/g, ' ')}.`)
|
|
819
914
|
}
|
|
915
|
+
else {
|
|
916
|
+
findings.push(...publicUrlCredentialFindings(document))
|
|
917
|
+
}
|
|
820
918
|
|
|
821
919
|
if (entries.length === 0) {
|
|
822
920
|
findings.push('P1 - Document does not expose any manifest, OpenAPI, item, category, or resource endpoints for no-payment probes.')
|
|
@@ -864,6 +962,9 @@ function findingList(documentResult, challengeResults, preflightResults, entries
|
|
|
864
962
|
if (summary.resourceUrl.startsWith('http://') || summary.extraResource.startsWith('http://')) {
|
|
865
963
|
findings.push(`P1 - ${result.name} challenge uses a non-HTTPS resource URL: ${summary.resourceUrl || summary.extraResource}.`)
|
|
866
964
|
}
|
|
965
|
+
if (looksLikeLocalResourceUrl(summary.resourceUrl) || looksLikeLocalResourceUrl(summary.extraResource)) {
|
|
966
|
+
findings.push(`P1 - ${result.name} challenge binds payment to a localhost/private-development resource URL: ${summary.resourceUrl || summary.extraResource}.`)
|
|
967
|
+
}
|
|
867
968
|
if (!summary.amount || !summary.payTo || !summary.asset) {
|
|
868
969
|
findings.push(`P1 - ${result.name} challenge is missing amount/maxAmountRequired, payTo, or asset metadata.`)
|
|
869
970
|
}
|
|
@@ -873,7 +974,12 @@ function findingList(documentResult, challengeResults, preflightResults, entries
|
|
|
873
974
|
findings.push(`P1 - ${result.name} documented price ${moneyFromDecimal(summary.expectedPriceUsd)} does not match live 402 challenge price ${moneyFromDecimal(summary.priceUsd)}.`)
|
|
874
975
|
}
|
|
875
976
|
}
|
|
876
|
-
|
|
977
|
+
const accepts = challengeAccepts(result)
|
|
978
|
+
const topResource = challengeResourceValue(result.body.json)
|
|
979
|
+
const acceptResources = accepts.map(acceptResourceValue)
|
|
980
|
+
const populatedAcceptResources = acceptResources.filter(Boolean)
|
|
981
|
+
|
|
982
|
+
for (const accept of accepts) {
|
|
877
983
|
if (looksLikePlaceholderPayTo(accept.payTo)) {
|
|
878
984
|
findings.push(`P1 - ${result.name} challenge advertises placeholder-looking payTo ${accept.payTo}; production listings should not ask agents to pay placeholder recipients.`)
|
|
879
985
|
}
|
|
@@ -881,11 +987,20 @@ function findingList(documentResult, challengeResults, preflightResults, entries
|
|
|
881
987
|
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
988
|
}
|
|
883
989
|
}
|
|
884
|
-
if (!
|
|
885
|
-
findings.push(`P2 - ${result.name} challenge does not
|
|
990
|
+
if (!topResource && populatedAcceptResources.length === 0) {
|
|
991
|
+
findings.push(`P2 - ${result.name} challenge does not expose a signed/intended resource URL at the top level or in any accept leg.`)
|
|
992
|
+
}
|
|
993
|
+
else if (accepts.length > 0 && populatedAcceptResources.length < accepts.length) {
|
|
994
|
+
findings.push(`P2 - ${result.name} challenge does not repeat the resource URL in every accept leg for spend-map and replay binding.`)
|
|
995
|
+
}
|
|
996
|
+
if (topResource && populatedAcceptResources.some(resource => resource !== topResource)) {
|
|
997
|
+
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.`)
|
|
998
|
+
}
|
|
999
|
+
if (accepts.length > 0 && !accepts.some(accept => hasFreshnessMetadata(result.body.json, accept))) {
|
|
1000
|
+
findings.push(`P2 - ${result.name} challenge does not expose timeout/expiry metadata; bounded freshness helps reduce replay windows for payment capabilities.`)
|
|
886
1001
|
}
|
|
887
1002
|
if (looksExplicitlyCacheable(result.headers)) {
|
|
888
|
-
findings.push(`
|
|
1003
|
+
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
1004
|
}
|
|
890
1005
|
else if (options.strictCache && !cachePolicy(result.headers)) {
|
|
891
1006
|
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 +1048,8 @@ function groupedFindingLabel(finding) {
|
|
|
933
1048
|
if (/CORS preflight does not allow X-PAYMENT/.test(finding)) {
|
|
934
1049
|
return 'P1 - CORS preflight does not allow X-PAYMENT.'
|
|
935
1050
|
}
|
|
936
|
-
if (/challenge does not repeat the resource URL/.test(finding)) {
|
|
937
|
-
return 'P2 -
|
|
1051
|
+
if (/challenge does not expose a signed\/intended resource URL|challenge does not repeat the resource URL|challenge resource URL differs/.test(finding)) {
|
|
1052
|
+
return 'P2 - Challenges have incomplete or inconsistent resource binding.'
|
|
938
1053
|
}
|
|
939
1054
|
if (/returned validation HTTP \d+ before a payment challenge/.test(finding)) {
|
|
940
1055
|
return 'P1 - Routes return validation before a payment challenge.'
|
|
@@ -948,9 +1063,15 @@ function groupedFindingLabel(finding) {
|
|
|
948
1063
|
if (/challenge advertises placeholder-looking payTo/.test(finding)) {
|
|
949
1064
|
return 'P1 - Challenges advertise placeholder-looking payTo recipients.'
|
|
950
1065
|
}
|
|
1066
|
+
if (/challenge does not expose timeout\/expiry metadata/.test(finding)) {
|
|
1067
|
+
return 'P2 - Challenges do not expose timeout or expiry metadata for replay-window control.'
|
|
1068
|
+
}
|
|
951
1069
|
if (/payment challenge response did not expose Cache-Control/.test(finding)) {
|
|
952
1070
|
return 'P3 - Payment challenge responses do not expose Cache-Control in strict cache mode.'
|
|
953
1071
|
}
|
|
1072
|
+
if (/payment challenge response is explicitly cacheable/.test(finding)) {
|
|
1073
|
+
return 'P1 - Payment challenge responses are explicitly cacheable.'
|
|
1074
|
+
}
|
|
954
1075
|
if (/content while payment headers advertise enforcement/.test(finding)) {
|
|
955
1076
|
return 'P2 - Payment headers advertise enforcement on a 200 response.'
|
|
956
1077
|
}
|
|
@@ -982,11 +1103,17 @@ function referenceGuides(findings) {
|
|
|
982
1103
|
add('Cloudflare x402 Worker Starter', 'https://tateprograms.com/cloudflare-x402-worker.html')
|
|
983
1104
|
add('x402 Attack Map 2026', 'https://tateprograms.com/x402-attack-map-2026.html')
|
|
984
1105
|
}
|
|
985
|
-
if (/validation HTTP \d+ before a payment challenge|auth HTTP \d+ before a payment challenge|replay|idempotency/i.test(text)) {
|
|
1106
|
+
if (/validation HTTP \d+ before a payment challenge|auth HTTP \d+ before a payment challenge|replay|idempotency|timeout\/expiry|freshness/i.test(text)) {
|
|
986
1107
|
add('x402 Launch Checklist', 'https://tateprograms.com/x402-launch-checklist.html')
|
|
1108
|
+
add('x402 Attack Map 2026', 'https://tateprograms.com/x402-attack-map-2026.html')
|
|
987
1109
|
}
|
|
988
|
-
if (/resource URL|resource echo|accepts\[0\]\.extra\.resource/i.test(text)) {
|
|
1110
|
+
if (/resource URL|resource echo|resource binding|accept leg|accepts\[0\]\.extra\.resource/i.test(text)) {
|
|
989
1111
|
add('x402 Surface Check notes', 'https://tateprograms.com/x402-surface-check.html')
|
|
1112
|
+
add('x402 Attack Map 2026', 'https://tateprograms.com/x402-attack-map-2026.html')
|
|
1113
|
+
}
|
|
1114
|
+
if (/credential-like URL material|provider tokens|API keys|registry-visible endpoint URLs/i.test(text)) {
|
|
1115
|
+
add('x402 Metadata Filter', 'https://tateprograms.com/x402-metadata-filter.html')
|
|
1116
|
+
add('Agent Commerce Gate', 'https://tateprograms.com/agent-commerce-gate.html')
|
|
990
1117
|
}
|
|
991
1118
|
return guides.map(guide => `- ${guide.label}: ${guide.url}`)
|
|
992
1119
|
}
|