x402-surface-check 0.2.2 → 0.2.4
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 -1
- package/bin/x402-surface-check.mjs +55 -10
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -17,13 +17,14 @@ npx --yes x402-surface-check --endpoint --method POST https://x402.rpc.ankr.com/
|
|
|
17
17
|
- Manifest endpoint discovery from `items[]`, `endpoints[]`, `x402Endpoints`, category arrays, resource strings, and OpenAPI paths
|
|
18
18
|
- No-payment HTTP 402 challenge shape
|
|
19
19
|
- x402 v1 and v2 price fields
|
|
20
|
-
- MPP `WWW-Authenticate: Payment` challenges
|
|
20
|
+
- MPP `WWW-Authenticate: Payment` and x402 V2 `WWW-Authenticate: X402 requirements=...` challenges
|
|
21
21
|
- `amount` / `maxAmountRequired`, `asset`, `network`, and `payTo`
|
|
22
22
|
- Placeholder recipients such as zero addresses and Solana system-program values
|
|
23
23
|
- Testnet or staging rails such as Base Sepolia and Solana devnet
|
|
24
24
|
- HTTPS resource URLs and stable resource metadata
|
|
25
25
|
- Browser CORS allowance for `X-PAYMENT`
|
|
26
26
|
- Over-broad public method surfaces
|
|
27
|
+
- Auth, validation, and free/trial responses that appear before a payment challenge, without piling on missing-field findings when no challenge was actually returned
|
|
27
28
|
|
|
28
29
|
## Options
|
|
29
30
|
|
|
@@ -228,17 +228,25 @@ function parseEncodedChallenge(value) {
|
|
|
228
228
|
}
|
|
229
229
|
}
|
|
230
230
|
|
|
231
|
-
function
|
|
232
|
-
|
|
231
|
+
function authenticateParams(value, scheme) {
|
|
232
|
+
const header = String(value ?? '').replace(/^www-authenticate:\s*/i, '').trim()
|
|
233
|
+
if (!header || !new RegExp(`^${scheme}\\s+`, 'i').test(header)) return null
|
|
233
234
|
const params = {}
|
|
234
235
|
const pattern = /([a-zA-Z][\w-]*)="([^"]*)"/g
|
|
235
|
-
let match = pattern.exec(
|
|
236
|
+
let match = pattern.exec(header)
|
|
236
237
|
|
|
237
238
|
while (match) {
|
|
238
239
|
params[match[1]] = match[2]
|
|
239
|
-
match = pattern.exec(
|
|
240
|
+
match = pattern.exec(header)
|
|
240
241
|
}
|
|
241
242
|
|
|
243
|
+
return params
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
function parsePaymentAuthenticate(value) {
|
|
247
|
+
const params = authenticateParams(value, 'Payment')
|
|
248
|
+
if (!params) return null
|
|
249
|
+
|
|
242
250
|
const request = parseEncodedChallenge(params.request)
|
|
243
251
|
if (!request) return null
|
|
244
252
|
|
|
@@ -264,6 +272,19 @@ function parsePaymentAuthenticate(value) {
|
|
|
264
272
|
}
|
|
265
273
|
}
|
|
266
274
|
|
|
275
|
+
function parseX402Authenticate(value) {
|
|
276
|
+
const params = authenticateParams(value, 'X402')
|
|
277
|
+
if (!params) return null
|
|
278
|
+
|
|
279
|
+
const requirements = parseEncodedChallenge(params.requirements ?? params.request)
|
|
280
|
+
if (!requirements || !Array.isArray(requirements.accepts)) return null
|
|
281
|
+
|
|
282
|
+
return {
|
|
283
|
+
protocol: requirements.protocol ?? 'x402',
|
|
284
|
+
...requirements,
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
|
|
267
288
|
async function fetchDocument(url) {
|
|
268
289
|
const response = await fetch(url, {
|
|
269
290
|
headers: {
|
|
@@ -296,16 +317,18 @@ async function probeEndpoint(entry) {
|
|
|
296
317
|
const headerChallenge = parseEncodedChallenge(
|
|
297
318
|
response.headers.get('payment-required') ?? response.headers.get('x-payment-required'),
|
|
298
319
|
)
|
|
299
|
-
const
|
|
320
|
+
const authenticateChallenge = parsePaymentAuthenticate(response.headers.get('www-authenticate'))
|
|
321
|
+
?? parseX402Authenticate(response.headers.get('www-authenticate'))
|
|
300
322
|
|
|
301
323
|
if (!body.json?.accepts?.length) {
|
|
302
324
|
if (headerChallenge) {
|
|
303
325
|
body.json = headerChallenge
|
|
304
326
|
}
|
|
305
|
-
else if (
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
327
|
+
else if (authenticateChallenge) {
|
|
328
|
+
authenticateChallenge.resource = authenticateChallenge.resource ?? { url: entry.url }
|
|
329
|
+
authenticateChallenge.resource.url = authenticateChallenge.resource.url || entry.url
|
|
330
|
+
authenticateChallenge.accepts[0].resource = authenticateChallenge.accepts[0].resource || entry.url
|
|
331
|
+
body.json = authenticateChallenge
|
|
309
332
|
}
|
|
310
333
|
}
|
|
311
334
|
|
|
@@ -350,6 +373,11 @@ function challengeAccepts(result) {
|
|
|
350
373
|
return Array.isArray(result.body.json?.accepts) ? result.body.json.accepts : []
|
|
351
374
|
}
|
|
352
375
|
|
|
376
|
+
function hasPaymentChallenge(result) {
|
|
377
|
+
const challenge = result.body.json
|
|
378
|
+
return challengeAccepts(result).length > 0 || Boolean(challenge?.resource || challenge?.payment || result.headers?.['www-authenticate'])
|
|
379
|
+
}
|
|
380
|
+
|
|
353
381
|
function challengeSummary(result) {
|
|
354
382
|
const challenge = result.body.json
|
|
355
383
|
const firstAccept = challenge?.accepts?.[0] ?? {}
|
|
@@ -404,10 +432,27 @@ function findingList(documentResult, challengeResults, preflightResults, entries
|
|
|
404
432
|
for (const result of challengeResults) {
|
|
405
433
|
const summary = challengeSummary(result)
|
|
406
434
|
if (summary.network) challengeNetworks.add(summary.network)
|
|
435
|
+
const hasChallenge = hasPaymentChallenge(result)
|
|
407
436
|
|
|
408
437
|
if (result.status !== 402) {
|
|
409
|
-
|
|
438
|
+
if (result.status >= 200 && result.status < 300) {
|
|
439
|
+
findings.push(`P3 - ${result.name} returned ${result.status} without a payment challenge for a no-payment ${result.method ?? 'POST'} probe; document this as free/trial access or move the 402 challenge before content.`)
|
|
440
|
+
}
|
|
441
|
+
else if (result.status === 400 || result.status === 422) {
|
|
442
|
+
findings.push(`P1 - ${result.name} returned validation HTTP ${result.status} before a payment challenge for a no-payment ${result.method ?? 'POST'} probe.`)
|
|
443
|
+
}
|
|
444
|
+
else if (result.status === 401 || result.status === 403) {
|
|
445
|
+
findings.push(`P2 - ${result.name} returned auth HTTP ${result.status} before a payment challenge for a no-payment ${result.method ?? 'POST'} probe; document the auth/free-tier order if this is intentional.`)
|
|
446
|
+
}
|
|
447
|
+
else {
|
|
448
|
+
findings.push(`P1 - ${result.name} returned ${result.status}, not 402, for a no-payment ${result.method ?? 'POST'} probe.`)
|
|
449
|
+
}
|
|
410
450
|
}
|
|
451
|
+
|
|
452
|
+
if (!hasChallenge) {
|
|
453
|
+
continue
|
|
454
|
+
}
|
|
455
|
+
|
|
411
456
|
if (summary.resourceUrl.startsWith('http://') || summary.extraResource.startsWith('http://')) {
|
|
412
457
|
findings.push(`P1 - ${result.name} challenge uses a non-HTTPS resource URL: ${summary.resourceUrl || summary.extraResource}.`)
|
|
413
458
|
}
|