Skip to content

Commit cbea7c3

Browse files
committed
Detect nested x402 challenge bodies
1 parent 0ae0f30 commit cbea7c3

5 files changed

Lines changed: 83 additions & 4 deletions

File tree

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ npx --yes x402-surface-check --strict-proof https://api.example.com/openapi.json
3030
- No-payment HTTP 402 challenge shape
3131
- x402 v1 and v2 price fields, including `accepts[]` and `schemes[]` challenge arrays
3232
- 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": {...}}`
3334
- MPP descriptor-only 402s that advertise discovery headers but do not return a machine-readable payment retry challenge
3435
- Atomic-unit `amount` / `maxAmountRequired` fields, plus legacy decimal `amount` + `token` x402 v1 challenges
3536
- `asset` or token metadata, `network`, and `payTo`

bin/x402-surface-check.mjs

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -648,6 +648,29 @@ function parseX402Authenticate(value) {
648648
}
649649
}
650650

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+
651674
async function fetchDocument(url) {
652675
const response = await fetch(url, {
653676
headers: {
@@ -750,7 +773,11 @@ async function probeEndpoint(entry, origin) {
750773
const authenticateChallenge = parsePaymentAuthenticate(response.headers.get('www-authenticate'))
751774
?? parseX402Authenticate(response.headers.get('www-authenticate'))
752775

753-
const bodyHasChallenge = Array.isArray(body.json?.accepts) || Array.isArray(body.json?.schemes)
776+
const bodyChallenge = bodyChallengeJson(body.json)
777+
const bodyHasChallenge = Boolean(bodyChallenge)
778+
if (bodyChallenge && body.json !== bodyChallenge) {
779+
body.json = bodyChallenge
780+
}
754781
if (!bodyHasChallenge) {
755782
if (headerChallenge && typeof headerChallenge === 'object') {
756783
body.json = headerChallenge

package-lock.json

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "x402-surface-check",
3-
"version": "0.2.36",
3+
"version": "0.2.37",
44
"description": "No-payment x402 public-surface checker for manifests, OpenAPI specs, and HTTP 402 challenges.",
55
"type": "module",
66
"bin": {

test/smoke.mjs

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,14 @@ const server = createServer((request, response) => {
3232
return
3333
}
3434

35+
if (request.method === 'OPTIONS' && request.url === '/nested-detail') {
36+
response.statusCode = 405
37+
response.setHeader('allow', 'POST')
38+
response.setHeader('content-type', 'application/json')
39+
response.end(JSON.stringify({ detail: 'Method Not Allowed' }))
40+
return
41+
}
42+
3543
if (request.method === 'OPTIONS') {
3644
response.statusCode = 204
3745
response.setHeader('access-control-allow-origin', '*')
@@ -970,6 +978,30 @@ data: ${JSON.stringify({
970978
return
971979
}
972980

981+
if (request.url === '/nested-detail') {
982+
response.statusCode = 402
983+
response.setHeader('content-type', 'application/json')
984+
response.end(JSON.stringify({
985+
detail: {
986+
x402Version: 1,
987+
error: 'Payment Required',
988+
accepts: [{
989+
scheme: 'exact',
990+
network: 'base-mainnet',
991+
maxAmountRequired: '500000',
992+
resource: `${serverUrl}/nested-detail`,
993+
description: 'Nested framework detail challenge',
994+
mimeType: 'application/json',
995+
payTo: '0xc174A7C8088BF271399CD3E9f88c20D37CAb967D',
996+
maxTimeoutSeconds: 300,
997+
asset: '0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913',
998+
extra: { name: 'USD Coin', version: '2' },
999+
}],
1000+
},
1001+
}))
1002+
return
1003+
}
1004+
9731005
if (request.url === '/mpp/descriptor-only') {
9741006
response.statusCode = 402
9751007
response.setHeader('content-type', 'application/json')
@@ -1510,6 +1542,25 @@ try {
15101542
assert.match(strictCache.stdout, /Cloudflare x402 Worker Starter/)
15111543
assert.match(strictCache.stdout, /x402 Attack Map 2026/)
15121544

1545+
const nestedDetail = await execFileAsync('node', [
1546+
'bin/x402-surface-check.mjs',
1547+
'--endpoint',
1548+
'--method',
1549+
'POST',
1550+
`${serverUrl}/nested-detail`,
1551+
'--origin',
1552+
'https://example.com',
1553+
'--strict-cache',
1554+
], { cwd: new URL('..', import.meta.url) })
1555+
1556+
assert.match(nestedDetail.stdout, /direct endpoint/)
1557+
assert.match(nestedDetail.stdout, /base-mainnet/)
1558+
assert.match(nestedDetail.stdout, /\$0\.50/)
1559+
assert.match(nestedDetail.stdout, /402 challenge response does not allow the requesting origin/)
1560+
assert.match(nestedDetail.stdout, /CORS preflight does not allow the requesting origin/)
1561+
assert.match(nestedDetail.stdout, /CORS preflight does not allow a known x402 retry header/)
1562+
assert.match(nestedDetail.stdout, /payment challenge response did not expose Cache-Control/)
1563+
15131564
const schemes = await execFileAsync('node', [
15141565
'bin/x402-surface-check.mjs',
15151566
'--endpoint',

0 commit comments

Comments
 (0)