x402-surface-check 0.2.34 → 0.2.36
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 -2
- package/bin/x402-surface-check.mjs +49 -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
|
|
@@ -30,6 +30,7 @@ npx --yes x402-surface-check --strict-proof https://api.example.com/openapi.json
|
|
|
30
30
|
- No-payment HTTP 402 challenge shape
|
|
31
31
|
- x402 v1 and v2 price fields, including `accepts[]` and `schemes[]` challenge arrays
|
|
32
32
|
- MPP `WWW-Authenticate: Payment` and x402 V2 `WWW-Authenticate: X402 requirements=...` challenges
|
|
33
|
+
- MPP descriptor-only 402s that advertise discovery headers but do not return a machine-readable payment retry challenge
|
|
33
34
|
- Atomic-unit `amount` / `maxAmountRequired` fields, plus legacy decimal `amount` + `token` x402 v1 challenges
|
|
34
35
|
- `asset` or token metadata, `network`, and `payTo`
|
|
35
36
|
- OpenAPI-declared `x-payment-info.price.amount` drift versus the live 402 challenge price
|
|
@@ -38,7 +39,7 @@ npx --yes x402-surface-check --strict-proof https://api.example.com/openapi.json
|
|
|
38
39
|
- HTTPS resource URLs and stable resource metadata
|
|
39
40
|
- Resource binding across top-level `resource.url` and every accept leg, including localhost/private-development resource URLs that should not ship in production
|
|
40
41
|
- Timeout/expiry metadata on challenges, so payment capabilities have an explicit bounded freshness window
|
|
41
|
-
- Browser CORS allowance for the requesting origin,
|
|
42
|
+
- Browser CORS allowance for the requesting origin, common x402/MPP retry headers, and exposed challenge/session headers on the actual 402 response
|
|
42
43
|
- 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
44
|
- 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
45
|
- Payment-enforcement headers on `200` responses, so public telemetry/free-trial endpoints do not accidentally advertise enforced x402 while returning content before a challenge
|
|
@@ -785,7 +785,7 @@ async function probePreflight(entry, origin) {
|
|
|
785
785
|
headers: {
|
|
786
786
|
origin,
|
|
787
787
|
'access-control-request-method': entry.method ?? 'POST',
|
|
788
|
-
'access-control-request-headers': 'content-type,x-payment',
|
|
788
|
+
'access-control-request-headers': 'content-type,x-payment,payment-signature,authorization',
|
|
789
789
|
},
|
|
790
790
|
})
|
|
791
791
|
|
|
@@ -1070,6 +1070,35 @@ function missingExposedPaymentHeaders(result) {
|
|
|
1070
1070
|
return critical.filter(name => !headerListAllows(expose, name))
|
|
1071
1071
|
}
|
|
1072
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 mppDiscoveryHeaders(headers = {}) {
|
|
1088
|
+
return [
|
|
1089
|
+
'x-mpp-descriptor',
|
|
1090
|
+
'x-agent-card',
|
|
1091
|
+
].filter(name => headers[name] !== undefined && headers[name] !== '')
|
|
1092
|
+
}
|
|
1093
|
+
|
|
1094
|
+
function advertisesMppDiscovery(result) {
|
|
1095
|
+
return mppDiscoveryHeaders(result.headers).length > 0
|
|
1096
|
+
}
|
|
1097
|
+
|
|
1098
|
+
function allowsAnyHeader(headerValue = '', names = []) {
|
|
1099
|
+
return names.some(name => headerListAllows(headerValue, name))
|
|
1100
|
+
}
|
|
1101
|
+
|
|
1073
1102
|
function entryKey(entry) {
|
|
1074
1103
|
return `${entry.method ?? 'POST'} ${entry.url}`
|
|
1075
1104
|
}
|
|
@@ -1142,6 +1171,10 @@ function findingList(documentResult, challengeResults, preflightResults, entries
|
|
|
1142
1171
|
}
|
|
1143
1172
|
|
|
1144
1173
|
if (!hasChallenge) {
|
|
1174
|
+
const descriptorHeaders = mppDiscoveryHeaders(result.headers)
|
|
1175
|
+
if (result.status === 402 && descriptorHeaders.length > 0) {
|
|
1176
|
+
findings.push(`P1 - ${result.name} returns 402 and advertises MPP discovery via ${descriptorHeaders.join(', ')}, but does not include a WWW-Authenticate: Payment challenge or machine-readable body challenge; autonomous callers cannot construct a paid retry from this response alone.`)
|
|
1177
|
+
}
|
|
1145
1178
|
continue
|
|
1146
1179
|
}
|
|
1147
1180
|
|
|
@@ -1220,17 +1253,23 @@ function findingList(documentResult, challengeResults, preflightResults, entries
|
|
|
1220
1253
|
|
|
1221
1254
|
for (const result of preflightResults) {
|
|
1222
1255
|
const challengeResult = challengesByEntry.get(entryKey(result))
|
|
1223
|
-
if (!challengeResult || (!hasPaymentChallenge(challengeResult) && !advertisesPaymentEnforcement(challengeResult.headers))) continue
|
|
1256
|
+
if (!challengeResult || (!hasPaymentChallenge(challengeResult) && !advertisesPaymentEnforcement(challengeResult.headers) && !advertisesMppDiscovery(challengeResult))) continue
|
|
1224
1257
|
const allowedOrigin = result.headers['access-control-allow-origin'] ?? ''
|
|
1225
1258
|
if (!allowedOrigin) {
|
|
1226
1259
|
findings.push(`P1 - ${result.name} CORS preflight does not allow the requesting origin; observed allow-origin: none.`)
|
|
1227
1260
|
}
|
|
1228
1261
|
const allowed = result.headers['access-control-allow-headers'] ?? ''
|
|
1229
|
-
if (allowed
|
|
1262
|
+
if (hasX402RetryChallenge(challengeResult) && !allowsAnyHeader(allowed, ['x-payment', 'payment-signature'])) {
|
|
1230
1263
|
const observed = result.status >= 400
|
|
1231
1264
|
? `HTTP ${result.status}; allow headers: ${allowed || 'none'}`
|
|
1232
1265
|
: `allow headers: ${allowed || 'none'}`
|
|
1233
|
-
findings.push(`P1 - ${result.name} CORS preflight does not allow X-PAYMENT; observed ${observed}.`)
|
|
1266
|
+
findings.push(`P1 - ${result.name} CORS preflight does not allow a known x402 retry header (X-PAYMENT or PAYMENT-SIGNATURE); observed ${observed}.`)
|
|
1267
|
+
}
|
|
1268
|
+
if ((hasMppRetryChallenge(challengeResult) || advertisesMppDiscovery(challengeResult)) && !headerListAllows(allowed, 'authorization')) {
|
|
1269
|
+
const observed = result.status >= 400
|
|
1270
|
+
? `HTTP ${result.status}; allow headers: ${allowed || 'none'}`
|
|
1271
|
+
: `allow headers: ${allowed || 'none'}`
|
|
1272
|
+
findings.push(`P1 - ${result.name} CORS preflight does not allow Authorization for MPP retry; observed ${observed}.`)
|
|
1234
1273
|
}
|
|
1235
1274
|
const allowedMethods = result.headers['access-control-allow-methods'] ?? ''
|
|
1236
1275
|
if (/delete|put|patch/i.test(allowedMethods)) {
|
|
@@ -1256,8 +1295,11 @@ function groupedFindingLabel(finding) {
|
|
|
1256
1295
|
if (/CORS preflight does not allow the requesting origin/.test(finding)) {
|
|
1257
1296
|
return 'P1 - CORS preflight does not allow the requesting origin.'
|
|
1258
1297
|
}
|
|
1259
|
-
if (/CORS preflight does not allow
|
|
1260
|
-
return 'P1 - CORS preflight does not allow
|
|
1298
|
+
if (/CORS preflight does not allow a known x402 retry header/.test(finding)) {
|
|
1299
|
+
return 'P1 - CORS preflight does not allow a known x402 retry header.'
|
|
1300
|
+
}
|
|
1301
|
+
if (/CORS preflight does not allow Authorization for MPP retry/.test(finding)) {
|
|
1302
|
+
return 'P1 - CORS preflight does not allow Authorization for MPP retry.'
|
|
1261
1303
|
}
|
|
1262
1304
|
if (/challenge does not expose a signed\/intended resource URL|challenge does not repeat the resource URL|challenge resource URL differs/.test(finding)) {
|
|
1263
1305
|
return 'P2 - Challenges have incomplete or inconsistent resource binding.'
|
|
@@ -1312,7 +1354,7 @@ function referenceGuides(findings) {
|
|
|
1312
1354
|
if (!guides.some(guide => guide.url === url)) guides.push({ label, url })
|
|
1313
1355
|
}
|
|
1314
1356
|
const text = findings.join('\n')
|
|
1315
|
-
if (/CORS|402 challenge response does not allow the requesting origin|X-PAYMENT/i.test(text)) {
|
|
1357
|
+
if (/CORS|402 challenge response does not allow the requesting origin|X-PAYMENT|PAYMENT-SIGNATURE|MPP retry/i.test(text)) {
|
|
1316
1358
|
add('x402 CORS Fix', 'https://tateprograms.com/x402-cors-fix.html')
|
|
1317
1359
|
add('Cloudflare x402 Worker Starter', 'https://tateprograms.com/cloudflare-x402-worker.html')
|
|
1318
1360
|
}
|