Skip to content

Commit 0ae0f30

Browse files
committed
Flag MPP descriptor-only payment gates
1 parent f06b710 commit 0ae0f30

5 files changed

Lines changed: 50 additions & 5 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+
- MPP descriptor-only 402s that advertise discovery headers but do not return a machine-readable payment retry challenge
3334
- Atomic-unit `amount` / `maxAmountRequired` fields, plus legacy decimal `amount` + `token` x402 v1 challenges
3435
- `asset` or token metadata, `network`, and `payTo`
3536
- OpenAPI-declared `x-payment-info.price.amount` drift versus the live 402 challenge price

bin/x402-surface-check.mjs

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1084,6 +1084,17 @@ function hasMppRetryChallenge(result) {
10841084
return challengeAccepts(result).some(accept => String(accept.scheme ?? '').toLowerCase() === 'mpp')
10851085
}
10861086

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+
10871098
function allowsAnyHeader(headerValue = '', names = []) {
10881099
return names.some(name => headerListAllows(headerValue, name))
10891100
}
@@ -1160,6 +1171,10 @@ function findingList(documentResult, challengeResults, preflightResults, entries
11601171
}
11611172

11621173
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+
}
11631178
continue
11641179
}
11651180

@@ -1238,7 +1253,7 @@ function findingList(documentResult, challengeResults, preflightResults, entries
12381253

12391254
for (const result of preflightResults) {
12401255
const challengeResult = challengesByEntry.get(entryKey(result))
1241-
if (!challengeResult || (!hasPaymentChallenge(challengeResult) && !advertisesPaymentEnforcement(challengeResult.headers))) continue
1256+
if (!challengeResult || (!hasPaymentChallenge(challengeResult) && !advertisesPaymentEnforcement(challengeResult.headers) && !advertisesMppDiscovery(challengeResult))) continue
12421257
const allowedOrigin = result.headers['access-control-allow-origin'] ?? ''
12431258
if (!allowedOrigin) {
12441259
findings.push(`P1 - ${result.name} CORS preflight does not allow the requesting origin; observed allow-origin: none.`)
@@ -1250,7 +1265,7 @@ function findingList(documentResult, challengeResults, preflightResults, entries
12501265
: `allow headers: ${allowed || 'none'}`
12511266
findings.push(`P1 - ${result.name} CORS preflight does not allow a known x402 retry header (X-PAYMENT or PAYMENT-SIGNATURE); observed ${observed}.`)
12521267
}
1253-
if (hasMppRetryChallenge(challengeResult) && !headerListAllows(allowed, 'authorization')) {
1268+
if ((hasMppRetryChallenge(challengeResult) || advertisesMppDiscovery(challengeResult)) && !headerListAllows(allowed, 'authorization')) {
12541269
const observed = result.status >= 400
12551270
? `HTTP ${result.status}; allow headers: ${allowed || 'none'}`
12561271
: `allow headers: ${allowed || 'none'}`

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.35",
3+
"version": "0.2.36",
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: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -970,6 +970,21 @@ data: ${JSON.stringify({
970970
return
971971
}
972972

973+
if (request.url === '/mpp/descriptor-only') {
974+
response.statusCode = 402
975+
response.setHeader('content-type', 'application/json')
976+
response.setHeader('access-control-allow-origin', '*')
977+
response.setHeader('access-control-expose-headers', 'x-mpp-descriptor, x-agent-card')
978+
response.setHeader('cache-control', 'no-store')
979+
response.setHeader('x-mpp-descriptor', `${serverUrl}/.well-known/mpp.json`)
980+
response.setHeader('x-agent-card', `${serverUrl}/.well-known/agent.json`)
981+
response.end(JSON.stringify({
982+
error: 'payment_required',
983+
message: 'MPP payment required for tools/call',
984+
}))
985+
return
986+
}
987+
973988
if (request.url === '/x402v2/header') {
974989
const requirements = Buffer.from(JSON.stringify({
975990
x402Version: 2,
@@ -1440,6 +1455,20 @@ try {
14401455
assert.match(mpp.stdout, /mainnet/)
14411456
assert.match(mpp.stdout, /\$0\.001/)
14421457

1458+
const mppDescriptorOnly = await execFileAsync('node', [
1459+
'bin/x402-surface-check.mjs',
1460+
'--endpoint',
1461+
'--method',
1462+
'POST',
1463+
`${serverUrl}/mpp/descriptor-only`,
1464+
'--origin',
1465+
'https://example.com',
1466+
], { cwd: new URL('..', import.meta.url) })
1467+
1468+
assert.match(mppDescriptorOnly.stdout, /advertises MPP discovery via x-mpp-descriptor, x-agent-card/)
1469+
assert.match(mppDescriptorOnly.stdout, /WWW-Authenticate: Payment challenge/)
1470+
assert.match(mppDescriptorOnly.stdout, /CORS preflight does not allow Authorization for MPP retry/)
1471+
14431472
const x402Header = await execFileAsync('node', [
14441473
'bin/x402-surface-check.mjs',
14451474
'--endpoint',

0 commit comments

Comments
 (0)