x402-surface-check 0.2.33 → 0.2.35
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 +2 -2
- package/bin/x402-surface-check.mjs +74 -7
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
No-payment CLI for checking x402 launch surfaces before a real agent spends.
|
|
4
4
|
|
|
5
|
-
It accepts an x402 manifest or OpenAPI URL, derives public endpoints, sends no-payment probes, checks browser preflight behavior, and returns a Markdown patch queue. It never sends
|
|
5
|
+
It accepts an x402 manifest or OpenAPI URL, derives public endpoints, sends no-payment probes, checks browser preflight behavior, and returns a Markdown patch queue. It never sends payment retry headers, never signs, and never attempts a paid call.
|
|
6
6
|
|
|
7
7
|
npm: https://www.npmjs.com/package/x402-surface-check
|
|
8
8
|
Attack-map field note: https://tateprograms.com/x402-attack-map-2026.html
|
|
@@ -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
|
|
41
|
+
- Browser CORS allowance for the requesting origin, common x402/MPP retry headers, 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
|
-
|
|
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
|
|
|
@@ -775,7 +785,7 @@ async function probePreflight(entry, origin) {
|
|
|
775
785
|
headers: {
|
|
776
786
|
origin,
|
|
777
787
|
'access-control-request-method': entry.method ?? 'POST',
|
|
778
|
-
'access-control-request-headers': 'content-type,x-payment',
|
|
788
|
+
'access-control-request-headers': 'content-type,x-payment,payment-signature,authorization',
|
|
779
789
|
},
|
|
780
790
|
})
|
|
781
791
|
|
|
@@ -1043,6 +1053,41 @@ 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
|
+
|
|
1073
|
+
function hasX402RetryChallenge(result) {
|
|
1074
|
+
if (['payment-required', 'x-payment-required'].includes(result.challengeHeaderName)) return true
|
|
1075
|
+
return challengeAccepts(result).some(accept => {
|
|
1076
|
+
const scheme = String(accept.scheme ?? '').toLowerCase()
|
|
1077
|
+
return scheme === 'exact' || scheme.includes('x402')
|
|
1078
|
+
})
|
|
1079
|
+
}
|
|
1080
|
+
|
|
1081
|
+
function hasMppRetryChallenge(result) {
|
|
1082
|
+
const authenticate = String(result.headers?.['www-authenticate'] ?? '')
|
|
1083
|
+
if (/^\s*payment\b/i.test(authenticate)) return true
|
|
1084
|
+
return challengeAccepts(result).some(accept => String(accept.scheme ?? '').toLowerCase() === 'mpp')
|
|
1085
|
+
}
|
|
1086
|
+
|
|
1087
|
+
function allowsAnyHeader(headerValue = '', names = []) {
|
|
1088
|
+
return names.some(name => headerListAllows(headerValue, name))
|
|
1089
|
+
}
|
|
1090
|
+
|
|
1046
1091
|
function entryKey(entry) {
|
|
1047
1092
|
return `${entry.method ?? 'POST'} ${entry.url}`
|
|
1048
1093
|
}
|
|
@@ -1121,6 +1166,19 @@ function findingList(documentResult, challengeResults, preflightResults, entries
|
|
|
1121
1166
|
if (!result.headers?.['access-control-allow-origin']) {
|
|
1122
1167
|
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
1168
|
}
|
|
1169
|
+
else {
|
|
1170
|
+
const hiddenHeaders = missingExposedPaymentHeaders(result)
|
|
1171
|
+
if (
|
|
1172
|
+
result.challengeHeaderName
|
|
1173
|
+
&& hiddenHeaders.includes(result.challengeHeaderName)
|
|
1174
|
+
&& !result.bodyHadChallenge
|
|
1175
|
+
) {
|
|
1176
|
+
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.`)
|
|
1177
|
+
}
|
|
1178
|
+
if (hiddenHeaders.includes('x-session-id')) {
|
|
1179
|
+
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.`)
|
|
1180
|
+
}
|
|
1181
|
+
}
|
|
1124
1182
|
if (summary.resourceUrl.startsWith('http://') || summary.extraResource.startsWith('http://')) {
|
|
1125
1183
|
findings.push(`P1 - ${result.name} challenge uses a non-HTTPS resource URL: ${summary.resourceUrl || summary.extraResource}.`)
|
|
1126
1184
|
}
|
|
@@ -1186,11 +1244,17 @@ function findingList(documentResult, challengeResults, preflightResults, entries
|
|
|
1186
1244
|
findings.push(`P1 - ${result.name} CORS preflight does not allow the requesting origin; observed allow-origin: none.`)
|
|
1187
1245
|
}
|
|
1188
1246
|
const allowed = result.headers['access-control-allow-headers'] ?? ''
|
|
1189
|
-
if (allowed
|
|
1247
|
+
if (hasX402RetryChallenge(challengeResult) && !allowsAnyHeader(allowed, ['x-payment', 'payment-signature'])) {
|
|
1190
1248
|
const observed = result.status >= 400
|
|
1191
1249
|
? `HTTP ${result.status}; allow headers: ${allowed || 'none'}`
|
|
1192
1250
|
: `allow headers: ${allowed || 'none'}`
|
|
1193
|
-
findings.push(`P1 - ${result.name} CORS preflight does not allow X-PAYMENT; observed ${observed}.`)
|
|
1251
|
+
findings.push(`P1 - ${result.name} CORS preflight does not allow a known x402 retry header (X-PAYMENT or PAYMENT-SIGNATURE); observed ${observed}.`)
|
|
1252
|
+
}
|
|
1253
|
+
if (hasMppRetryChallenge(challengeResult) && !headerListAllows(allowed, 'authorization')) {
|
|
1254
|
+
const observed = result.status >= 400
|
|
1255
|
+
? `HTTP ${result.status}; allow headers: ${allowed || 'none'}`
|
|
1256
|
+
: `allow headers: ${allowed || 'none'}`
|
|
1257
|
+
findings.push(`P1 - ${result.name} CORS preflight does not allow Authorization for MPP retry; observed ${observed}.`)
|
|
1194
1258
|
}
|
|
1195
1259
|
const allowedMethods = result.headers['access-control-allow-methods'] ?? ''
|
|
1196
1260
|
if (/delete|put|patch/i.test(allowedMethods)) {
|
|
@@ -1216,8 +1280,11 @@ function groupedFindingLabel(finding) {
|
|
|
1216
1280
|
if (/CORS preflight does not allow the requesting origin/.test(finding)) {
|
|
1217
1281
|
return 'P1 - CORS preflight does not allow the requesting origin.'
|
|
1218
1282
|
}
|
|
1219
|
-
if (/CORS preflight does not allow
|
|
1220
|
-
return 'P1 - CORS preflight does not allow
|
|
1283
|
+
if (/CORS preflight does not allow a known x402 retry header/.test(finding)) {
|
|
1284
|
+
return 'P1 - CORS preflight does not allow a known x402 retry header.'
|
|
1285
|
+
}
|
|
1286
|
+
if (/CORS preflight does not allow Authorization for MPP retry/.test(finding)) {
|
|
1287
|
+
return 'P1 - CORS preflight does not allow Authorization for MPP retry.'
|
|
1221
1288
|
}
|
|
1222
1289
|
if (/challenge does not expose a signed\/intended resource URL|challenge does not repeat the resource URL|challenge resource URL differs/.test(finding)) {
|
|
1223
1290
|
return 'P2 - Challenges have incomplete or inconsistent resource binding.'
|
|
@@ -1272,7 +1339,7 @@ function referenceGuides(findings) {
|
|
|
1272
1339
|
if (!guides.some(guide => guide.url === url)) guides.push({ label, url })
|
|
1273
1340
|
}
|
|
1274
1341
|
const text = findings.join('\n')
|
|
1275
|
-
if (/CORS|402 challenge response does not allow the requesting origin|X-PAYMENT/i.test(text)) {
|
|
1342
|
+
if (/CORS|402 challenge response does not allow the requesting origin|X-PAYMENT|PAYMENT-SIGNATURE|MPP retry/i.test(text)) {
|
|
1276
1343
|
add('x402 CORS Fix', 'https://tateprograms.com/x402-cors-fix.html')
|
|
1277
1344
|
add('Cloudflare x402 Worker Starter', 'https://tateprograms.com/cloudflare-x402-worker.html')
|
|
1278
1345
|
}
|