Skip to content

Commit d55afb9

Browse files
committed
Expose header-only x402 challenge gaps
1 parent 43a30ad commit d55afb9

5 files changed

Lines changed: 88 additions & 5 deletions

File tree

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@ npx --yes x402-surface-check --strict-proof https://api.example.com/openapi.json
3838
- HTTPS resource URLs and stable resource metadata
3939
- Resource binding across top-level `resource.url` and every accept leg, including localhost/private-development resource URLs that should not ship in production
4040
- Timeout/expiry metadata on challenges, so payment capabilities have an explicit bounded freshness window
41-
- Browser CORS allowance for the requesting origin and `X-PAYMENT`, including the actual 402 challenge response
41+
- Browser CORS allowance for the requesting origin, `X-PAYMENT`, and exposed challenge/session headers on the actual 402 response
4242
- 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
4343
- 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
4444
- Payment-enforcement headers on `200` responses, so public telemetry/free-trial endpoints do not accidentally advertise enforced x402 while returning content before a challenge

bin/x402-surface-check.mjs

Lines changed: 41 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -742,8 +742,10 @@ async function probeEndpoint(entry, origin) {
742742
: JSON.stringify(entry.requestBody ?? {}),
743743
})
744744
const body = await readText(response)
745+
const paymentRequiredHeader = response.headers.get('payment-required')
746+
const xPaymentRequiredHeader = response.headers.get('x-payment-required')
745747
const headerChallenge = parseEncodedChallenge(
746-
response.headers.get('payment-required') ?? response.headers.get('x-payment-required'),
748+
paymentRequiredHeader ?? xPaymentRequiredHeader,
747749
)
748750
const authenticateChallenge = parsePaymentAuthenticate(response.headers.get('www-authenticate'))
749751
?? parseX402Authenticate(response.headers.get('www-authenticate'))
@@ -766,6 +768,14 @@ async function probeEndpoint(entry, origin) {
766768
status: response.status,
767769
headers: Object.fromEntries(response.headers.entries()),
768770
body,
771+
bodyHadChallenge: bodyHasChallenge,
772+
challengeHeaderName: paymentRequiredHeader
773+
? 'payment-required'
774+
: xPaymentRequiredHeader
775+
? 'x-payment-required'
776+
: authenticateChallenge
777+
? 'www-authenticate'
778+
: '',
769779
}
770780
}
771781

@@ -1043,6 +1053,23 @@ function looksExplicitlyCacheable(headers = {}) {
10431053
return /\b(public|s-maxage|max-age\s*=)\b/i.test(policy)
10441054
}
10451055

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+
10461073
function entryKey(entry) {
10471074
return `${entry.method ?? 'POST'} ${entry.url}`
10481075
}
@@ -1121,6 +1148,19 @@ function findingList(documentResult, challengeResults, preflightResults, entries
11211148
if (!result.headers?.['access-control-allow-origin']) {
11221149
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.`)
11231150
}
1151+
else {
1152+
const hiddenHeaders = missingExposedPaymentHeaders(result)
1153+
if (
1154+
result.challengeHeaderName
1155+
&& hiddenHeaders.includes(result.challengeHeaderName)
1156+
&& !result.bodyHadChallenge
1157+
) {
1158+
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.`)
1159+
}
1160+
if (hiddenHeaders.includes('x-session-id')) {
1161+
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.`)
1162+
}
1163+
}
11241164
if (summary.resourceUrl.startsWith('http://') || summary.extraResource.startsWith('http://')) {
11251165
findings.push(`P1 - ${result.name} challenge uses a non-HTTPS resource URL: ${summary.resourceUrl || summary.extraResource}.`)
11261166
}

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.33",
3+
"version": "0.2.34",
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: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -986,6 +986,35 @@ data: ${JSON.stringify({
986986
return
987987
}
988988

989+
if (request.url === '/hidden-header-challenge') {
990+
const requirements = Buffer.from(JSON.stringify({
991+
x402Version: 2,
992+
error: 'payment_required',
993+
resource: {
994+
url: `${serverUrl}/hidden-header-challenge`,
995+
description: 'Header-only browser exposure fixture',
996+
mimeType: 'application/json',
997+
},
998+
accepts: [{
999+
scheme: 'exact',
1000+
network: 'eip155:8453',
1001+
amount: '10000',
1002+
asset: '0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913',
1003+
payTo: '0x549c82e6bfc54bdae9a2073744cbc2af5d1fc6d1',
1004+
maxTimeoutSeconds: 60,
1005+
resource: `${serverUrl}/hidden-header-challenge`,
1006+
}],
1007+
})).toString('base64url')
1008+
response.statusCode = 402
1009+
response.setHeader('content-type', 'application/json')
1010+
response.setHeader('access-control-allow-origin', '*')
1011+
response.setHeader('access-control-expose-headers', 'link, total')
1012+
response.setHeader('x-session-id', 'fixture-session')
1013+
response.setHeader('payment-required', requirements)
1014+
response.end(JSON.stringify({ error: 'payment_required' }))
1015+
return
1016+
}
1017+
9891018
if (request.url === '/no-origin') {
9901019
response.statusCode = 402
9911020
response.setHeader('content-type', 'application/json')
@@ -1461,6 +1490,20 @@ try {
14611490
assert.match(noOrigin.stdout, /x402 CORS Fix/)
14621491
assert.match(noOrigin.stdout, /Cloudflare x402 Worker Starter/)
14631492

1493+
const hiddenHeader = await execFileAsync('node', [
1494+
'bin/x402-surface-check.mjs',
1495+
'--endpoint',
1496+
'--method',
1497+
'GET',
1498+
`${serverUrl}/hidden-header-challenge`,
1499+
'--origin',
1500+
'https://example.com',
1501+
], { cwd: new URL('..', import.meta.url) })
1502+
1503+
assert.match(hiddenHeader.stdout, /payment requirements only in the payment-required response header/)
1504+
assert.match(hiddenHeader.stdout, /Access-Control-Expose-Headers does not expose it/)
1505+
assert.match(hiddenHeader.stdout, /returns x-session-id/)
1506+
14641507
const groupedNoOrigin = await execFileAsync('node', [
14651508
'bin/x402-surface-check.mjs',
14661509
`${serverUrl}/no-origin-list.json`,

0 commit comments

Comments
 (0)