x402-surface-check 0.2.33 → 0.2.34

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
@@ -38,7 +38,7 @@ npx --yes x402-surface-check --strict-proof https://api.example.com/openapi.json
38
38
  - HTTPS resource URLs and stable resource metadata
39
39
  - Resource binding across top-level `resource.url` and every accept leg, including localhost/private-development resource URLs that should not ship in production
40
40
  - Timeout/expiry metadata on challenges, so payment capabilities have an explicit bounded freshness window
41
- - Browser CORS allowance for the requesting origin and `X-PAYMENT`, including the actual 402 challenge response
41
+ - Browser CORS allowance for the requesting origin, `X-PAYMENT`, and exposed challenge/session headers on the actual 402 response
42
42
  - 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
43
43
  - Optional strict proof/idempotency posture: mutating paid routes that do not advertise payment-identifier idempotency, and payment challenges that do not advertise signed offer/receipt evidence
44
44
  - Payment-enforcement headers on `200` responses, so public telemetry/free-trial endpoints do not accidentally advertise enforced x402 while returning content before a challenge
@@ -742,8 +742,10 @@ async function probeEndpoint(entry, origin) {
742
742
  : JSON.stringify(entry.requestBody ?? {}),
743
743
  })
744
744
  const body = await readText(response)
745
+ const paymentRequiredHeader = response.headers.get('payment-required')
746
+ const xPaymentRequiredHeader = response.headers.get('x-payment-required')
745
747
  const headerChallenge = parseEncodedChallenge(
746
- response.headers.get('payment-required') ?? response.headers.get('x-payment-required'),
748
+ paymentRequiredHeader ?? xPaymentRequiredHeader,
747
749
  )
748
750
  const authenticateChallenge = parsePaymentAuthenticate(response.headers.get('www-authenticate'))
749
751
  ?? parseX402Authenticate(response.headers.get('www-authenticate'))
@@ -766,6 +768,14 @@ async function probeEndpoint(entry, origin) {
766
768
  status: response.status,
767
769
  headers: Object.fromEntries(response.headers.entries()),
768
770
  body,
771
+ bodyHadChallenge: bodyHasChallenge,
772
+ challengeHeaderName: paymentRequiredHeader
773
+ ? 'payment-required'
774
+ : xPaymentRequiredHeader
775
+ ? 'x-payment-required'
776
+ : authenticateChallenge
777
+ ? 'www-authenticate'
778
+ : '',
769
779
  }
770
780
  }
771
781
 
@@ -1043,6 +1053,23 @@ function looksExplicitlyCacheable(headers = {}) {
1043
1053
  return /\b(public|s-maxage|max-age\s*=)\b/i.test(policy)
1044
1054
  }
1045
1055
 
1056
+ function headerListAllows(headerValue = '', requiredName = '') {
1057
+ if (!requiredName) return true
1058
+ const normalizedRequired = requiredName.toLowerCase()
1059
+ return String(headerValue).split(',')
1060
+ .map(item => item.trim().toLowerCase())
1061
+ .some(item => item === '*' || item === normalizedRequired)
1062
+ }
1063
+
1064
+ function missingExposedPaymentHeaders(result) {
1065
+ const expose = result.headers?.['access-control-expose-headers'] ?? ''
1066
+ const critical = [
1067
+ result.challengeHeaderName,
1068
+ result.headers?.['x-session-id'] !== undefined ? 'x-session-id' : '',
1069
+ ].filter(Boolean)
1070
+ return critical.filter(name => !headerListAllows(expose, name))
1071
+ }
1072
+
1046
1073
  function entryKey(entry) {
1047
1074
  return `${entry.method ?? 'POST'} ${entry.url}`
1048
1075
  }
@@ -1121,6 +1148,19 @@ function findingList(documentResult, challengeResults, preflightResults, entries
1121
1148
  if (!result.headers?.['access-control-allow-origin']) {
1122
1149
  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.`)
1123
1150
  }
1151
+ else {
1152
+ const hiddenHeaders = missingExposedPaymentHeaders(result)
1153
+ if (
1154
+ result.challengeHeaderName
1155
+ && hiddenHeaders.includes(result.challengeHeaderName)
1156
+ && !result.bodyHadChallenge
1157
+ ) {
1158
+ findings.push(`P1 - ${result.name} carries payment requirements only in the ${result.challengeHeaderName} response header, but Access-Control-Expose-Headers does not expose it; browser agents cannot read the challenge after CORS succeeds.`)
1159
+ }
1160
+ if (hiddenHeaders.includes('x-session-id')) {
1161
+ findings.push(`P2 - ${result.name} returns x-session-id for the payment retry flow, but Access-Control-Expose-Headers does not expose it to browser agents.`)
1162
+ }
1163
+ }
1124
1164
  if (summary.resourceUrl.startsWith('http://') || summary.extraResource.startsWith('http://')) {
1125
1165
  findings.push(`P1 - ${result.name} challenge uses a non-HTTPS resource URL: ${summary.resourceUrl || summary.extraResource}.`)
1126
1166
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "x402-surface-check",
3
- "version": "0.2.33",
3
+ "version": "0.2.34",
4
4
  "description": "No-payment x402 public-surface checker for manifests, OpenAPI specs, and HTTP 402 challenges.",
5
5
  "type": "module",
6
6
  "bin": {