x402-surface-check 0.2.35 → 0.2.37
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 -0
- package/bin/x402-surface-check.mjs +45 -3
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -30,6 +30,8 @@ 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
|
+
- x402 challenges nested inside framework error wrappers such as FastAPI-style `{"detail": {...}}`
|
|
34
|
+
- MPP descriptor-only 402s that advertise discovery headers but do not return a machine-readable payment retry challenge
|
|
33
35
|
- Atomic-unit `amount` / `maxAmountRequired` fields, plus legacy decimal `amount` + `token` x402 v1 challenges
|
|
34
36
|
- `asset` or token metadata, `network`, and `payTo`
|
|
35
37
|
- OpenAPI-declared `x-payment-info.price.amount` drift versus the live 402 challenge price
|
|
@@ -648,6 +648,29 @@ function parseX402Authenticate(value) {
|
|
|
648
648
|
}
|
|
649
649
|
}
|
|
650
650
|
|
|
651
|
+
function bodyChallengeJson(value) {
|
|
652
|
+
if (!value || typeof value !== 'object') return null
|
|
653
|
+
if (Array.isArray(value.accepts) || Array.isArray(value.schemes)) return value
|
|
654
|
+
|
|
655
|
+
const candidates = [
|
|
656
|
+
value.detail,
|
|
657
|
+
value.error,
|
|
658
|
+
value.payment,
|
|
659
|
+
value.paymentRequired,
|
|
660
|
+
value.payment_required,
|
|
661
|
+
value.challenge,
|
|
662
|
+
value.x402,
|
|
663
|
+
]
|
|
664
|
+
|
|
665
|
+
for (const candidate of candidates) {
|
|
666
|
+
if (!candidate || typeof candidate !== 'object') continue
|
|
667
|
+
const nested = bodyChallengeJson(candidate)
|
|
668
|
+
if (nested) return nested
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
return null
|
|
672
|
+
}
|
|
673
|
+
|
|
651
674
|
async function fetchDocument(url) {
|
|
652
675
|
const response = await fetch(url, {
|
|
653
676
|
headers: {
|
|
@@ -750,7 +773,11 @@ async function probeEndpoint(entry, origin) {
|
|
|
750
773
|
const authenticateChallenge = parsePaymentAuthenticate(response.headers.get('www-authenticate'))
|
|
751
774
|
?? parseX402Authenticate(response.headers.get('www-authenticate'))
|
|
752
775
|
|
|
753
|
-
const
|
|
776
|
+
const bodyChallenge = bodyChallengeJson(body.json)
|
|
777
|
+
const bodyHasChallenge = Boolean(bodyChallenge)
|
|
778
|
+
if (bodyChallenge && body.json !== bodyChallenge) {
|
|
779
|
+
body.json = bodyChallenge
|
|
780
|
+
}
|
|
754
781
|
if (!bodyHasChallenge) {
|
|
755
782
|
if (headerChallenge && typeof headerChallenge === 'object') {
|
|
756
783
|
body.json = headerChallenge
|
|
@@ -1084,6 +1111,17 @@ function hasMppRetryChallenge(result) {
|
|
|
1084
1111
|
return challengeAccepts(result).some(accept => String(accept.scheme ?? '').toLowerCase() === 'mpp')
|
|
1085
1112
|
}
|
|
1086
1113
|
|
|
1114
|
+
function mppDiscoveryHeaders(headers = {}) {
|
|
1115
|
+
return [
|
|
1116
|
+
'x-mpp-descriptor',
|
|
1117
|
+
'x-agent-card',
|
|
1118
|
+
].filter(name => headers[name] !== undefined && headers[name] !== '')
|
|
1119
|
+
}
|
|
1120
|
+
|
|
1121
|
+
function advertisesMppDiscovery(result) {
|
|
1122
|
+
return mppDiscoveryHeaders(result.headers).length > 0
|
|
1123
|
+
}
|
|
1124
|
+
|
|
1087
1125
|
function allowsAnyHeader(headerValue = '', names = []) {
|
|
1088
1126
|
return names.some(name => headerListAllows(headerValue, name))
|
|
1089
1127
|
}
|
|
@@ -1160,6 +1198,10 @@ function findingList(documentResult, challengeResults, preflightResults, entries
|
|
|
1160
1198
|
}
|
|
1161
1199
|
|
|
1162
1200
|
if (!hasChallenge) {
|
|
1201
|
+
const descriptorHeaders = mppDiscoveryHeaders(result.headers)
|
|
1202
|
+
if (result.status === 402 && descriptorHeaders.length > 0) {
|
|
1203
|
+
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.`)
|
|
1204
|
+
}
|
|
1163
1205
|
continue
|
|
1164
1206
|
}
|
|
1165
1207
|
|
|
@@ -1238,7 +1280,7 @@ function findingList(documentResult, challengeResults, preflightResults, entries
|
|
|
1238
1280
|
|
|
1239
1281
|
for (const result of preflightResults) {
|
|
1240
1282
|
const challengeResult = challengesByEntry.get(entryKey(result))
|
|
1241
|
-
if (!challengeResult || (!hasPaymentChallenge(challengeResult) && !advertisesPaymentEnforcement(challengeResult.headers))) continue
|
|
1283
|
+
if (!challengeResult || (!hasPaymentChallenge(challengeResult) && !advertisesPaymentEnforcement(challengeResult.headers) && !advertisesMppDiscovery(challengeResult))) continue
|
|
1242
1284
|
const allowedOrigin = result.headers['access-control-allow-origin'] ?? ''
|
|
1243
1285
|
if (!allowedOrigin) {
|
|
1244
1286
|
findings.push(`P1 - ${result.name} CORS preflight does not allow the requesting origin; observed allow-origin: none.`)
|
|
@@ -1250,7 +1292,7 @@ function findingList(documentResult, challengeResults, preflightResults, entries
|
|
|
1250
1292
|
: `allow headers: ${allowed || 'none'}`
|
|
1251
1293
|
findings.push(`P1 - ${result.name} CORS preflight does not allow a known x402 retry header (X-PAYMENT or PAYMENT-SIGNATURE); observed ${observed}.`)
|
|
1252
1294
|
}
|
|
1253
|
-
if (hasMppRetryChallenge(challengeResult) && !headerListAllows(allowed, 'authorization')) {
|
|
1295
|
+
if ((hasMppRetryChallenge(challengeResult) || advertisesMppDiscovery(challengeResult)) && !headerListAllows(allowed, 'authorization')) {
|
|
1254
1296
|
const observed = result.status >= 400
|
|
1255
1297
|
? `HTTP ${result.status}; allow headers: ${allowed || 'none'}`
|
|
1256
1298
|
: `allow headers: ${allowed || 'none'}`
|