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 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?.resource?.url ?? firstAccept.resource ?? ''
738
- const extraResource = firstAccept.extra?.resource ?? firstAccept.resource ?? ''
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
- for (const accept of challengeAccepts(result)) {
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 (!summary.resourceUrl || !summary.extraResource) {
885
- findings.push(`P2 - ${result.name} challenge does not repeat the resource URL in both resource.url and accepts[0].extra.resource/resource.`)
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(`P2 - ${result.name} payment challenge response is explicitly cacheable (${cachePolicy(result.headers)}); paid routes should use no-store/private cache policy or bypass shared caches.`)
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 - Challenge accept legs do not repeat the resource URL for reconciliation.'
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
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "x402-surface-check",
3
- "version": "0.2.25",
3
+ "version": "0.2.26",
4
4
  "description": "No-payment x402 public-surface checker for manifests, OpenAPI specs, and HTTP 402 challenges.",
5
5
  "type": "module",
6
6
  "bin": {