x402-surface-check 0.2.23 → 0.2.25
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 +4 -1
- package/bin/x402-surface-check.mjs +91 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -17,7 +17,8 @@ npx --yes x402-surface-check --strict-cache https://api.example.com/openapi.json
|
|
|
17
17
|
|
|
18
18
|
## What It Checks
|
|
19
19
|
|
|
20
|
-
- Manifest endpoint discovery from `items[]`, `endpoints[]`, `resources[]`, `x402Endpoints`, category arrays, raw resource URL strings, method-prefixed resource strings, and OpenAPI paths
|
|
20
|
+
- Manifest endpoint discovery from `items[]`, `endpoints[]`, object-valued `endpoints`, `resources[]`, `x402Endpoints`, category arrays, raw resource URL strings, method-prefixed resource strings, and OpenAPI paths
|
|
21
|
+
- Object-valued manifest endpoint query examples, public catalog/discovery GETs, and payment-bearing two-phase operations without treating expected public catalog reads as failed payment gates
|
|
21
22
|
- Linked discovery documents via `discovery_url`, `discoveryUrl`, `resources_url`, `resourcesUrl`, or manifest-level OpenAPI links
|
|
22
23
|
- OpenAPI `servers[]` base-path preservation, so `/paths` are probed through the documented gateway rather than the domain root
|
|
23
24
|
- OpenAPI query/path examples, JSON request-body examples, nested request schemas, local `$ref` request schemas, and explicit direct-endpoint bodies for safer no-payment probes
|
|
@@ -33,6 +34,7 @@ npx --yes x402-surface-check --strict-cache https://api.example.com/openapi.json
|
|
|
33
34
|
- HTTPS resource URLs and stable resource metadata
|
|
34
35
|
- Browser CORS allowance for the requesting origin and `X-PAYMENT`, including the actual 402 challenge response
|
|
35
36
|
- Cache-Control posture on no-payment challenge responses, with warnings for explicitly cacheable payment gates and optional strict-cache findings for missing policy headers
|
|
37
|
+
- Payment-enforcement headers on `200` responses, so public telemetry/free-trial endpoints do not accidentally advertise enforced x402 while returning content before a challenge
|
|
36
38
|
- Grouped finding summaries for repeated route-wide issues, so large manifests keep the patch order readable
|
|
37
39
|
- Contextual reference guides for CORS, cache policy, Worker gates, resource echo, validation/auth ordering, and the May 2026 x402 attack-control map
|
|
38
40
|
- Over-broad public method surfaces
|
|
@@ -51,6 +53,7 @@ Recent public no-payment checks have found and verified real launch fixes:
|
|
|
51
53
|
- HYRE Agent: OpenAPI-declared prices found 10x below live 402 challenge prices. https://github.com/solana-foundation/pay-skills/pull/19#issuecomment-4455641258
|
|
52
54
|
- anchor-x402: multi-rail x402 challenges verified, with browser preflight blockers isolated before merge. https://github.com/solana-foundation/pay-skills/pull/47#issuecomment-4455678163
|
|
53
55
|
- Agent Trust Bench: three no-payment passes converged on zero findings after discovery, browser preflight, cache, and resource-echo fixes. https://github.com/solana-foundation/pay-skills/pull/23#issuecomment-4467597309
|
|
56
|
+
- SolSentry: x402 stats endpoint flagged for returning `200` content while headers advertised payment enforcement and browser preflight omitted `X-PAYMENT`. https://github.com/solsentry/solsentry-app/issues/2
|
|
54
57
|
- Solrouter: private LLM inference route verified with HTTPS resource-binding and price-alignment notes. https://github.com/solana-foundation/pay-skills/pull/39#issuecomment-4455800060
|
|
55
58
|
- Tetrac: Solana market-data payment gates verified, with browser payment-header preflight blocker isolated. https://github.com/solana-foundation/pay-skills/pull/32#issuecomment-4455923744
|
|
56
59
|
|
|
@@ -322,6 +322,47 @@ function openApiProbeUrl(path, operation, baseUrl, document) {
|
|
|
322
322
|
return url.toString()
|
|
323
323
|
}
|
|
324
324
|
|
|
325
|
+
function manifestEndpointPaymentSignal(endpoint) {
|
|
326
|
+
if (!endpoint || typeof endpoint !== 'object') return 0
|
|
327
|
+
if (Number(endpoint.phase1_response?.status) === 402) return 2
|
|
328
|
+
if (/payment-required|x-payment|402/i.test(String(endpoint.phase1_response?.header ?? ''))) return 2
|
|
329
|
+
if (/payment|required|402/i.test(String(endpoint.description ?? ''))) return 1
|
|
330
|
+
if (endpoint.accepts || endpoint.schemes || endpoint.payment || endpoint['x-payment-info']) return 1
|
|
331
|
+
return 0
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
function manifestEndpointBody(endpoint, document) {
|
|
335
|
+
const body = endpoint?.request_body ?? endpoint?.requestBody
|
|
336
|
+
if (!body || typeof body !== 'object') return undefined
|
|
337
|
+
if (body.example !== undefined) return body.example
|
|
338
|
+
if (body.safe_example !== undefined) return body.safe_example
|
|
339
|
+
if (body.safeExample !== undefined) return body.safeExample
|
|
340
|
+
return exampleValue(body, document)
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
function manifestEndpointUrl(rawPath, endpoint, baseUrl, sourceUrl) {
|
|
344
|
+
const url = new URL(endpointUrl(rawPath, baseUrl, sourceUrl))
|
|
345
|
+
const parameters = endpoint?.parameters
|
|
346
|
+
if (!parameters || typeof parameters !== 'object') return url.toString()
|
|
347
|
+
|
|
348
|
+
for (const [name, parameter] of Object.entries(parameters)) {
|
|
349
|
+
if (url.pathname.includes(`{${name}}`)) {
|
|
350
|
+
const pathValue = parameter?.example ?? parameter?.default
|
|
351
|
+
if (pathValue !== undefined && pathValue !== '') {
|
|
352
|
+
url.pathname = url.pathname.replaceAll(`{${name}}`, encodeURIComponent(String(pathValue)))
|
|
353
|
+
}
|
|
354
|
+
continue
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
const value = parameter?.example ?? parameter?.default
|
|
358
|
+
if (value !== undefined && value !== '') {
|
|
359
|
+
url.searchParams.set(name, String(value))
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
return url.toString()
|
|
364
|
+
}
|
|
365
|
+
|
|
325
366
|
function endpointEntries(document, sourceUrl, limit) {
|
|
326
367
|
const entries = []
|
|
327
368
|
const baseUrl = documentBaseUrl(document, sourceUrl)
|
|
@@ -356,6 +397,24 @@ function endpointEntries(document, sourceUrl, limit) {
|
|
|
356
397
|
})
|
|
357
398
|
}
|
|
358
399
|
}
|
|
400
|
+
else if (document.endpoints && typeof document.endpoints === 'object') {
|
|
401
|
+
for (const [key, endpoint] of Object.entries(document.endpoints)) {
|
|
402
|
+
if (!endpoint || typeof endpoint !== 'object') continue
|
|
403
|
+
const rawPath = endpoint.url ?? endpoint.endpoint ?? endpoint.path
|
|
404
|
+
if (!rawPath) continue
|
|
405
|
+
const method = String(endpoint.method ?? 'POST').toUpperCase()
|
|
406
|
+
const paymentSignal = manifestEndpointPaymentSignal(endpoint)
|
|
407
|
+
const hasPathParameters = /\{[^}]+\}/.test(String(rawPath))
|
|
408
|
+
if (paymentSignal === 0 && (method !== 'GET' || hasPathParameters)) continue
|
|
409
|
+
entries.push({
|
|
410
|
+
name: endpoint.id ?? endpoint.name ?? key,
|
|
411
|
+
url: manifestEndpointUrl(rawPath, endpoint, baseUrl, sourceUrl),
|
|
412
|
+
method,
|
|
413
|
+
requestBody: manifestEndpointBody(endpoint, document),
|
|
414
|
+
publicDiscovery: paymentSignal === 0,
|
|
415
|
+
})
|
|
416
|
+
}
|
|
417
|
+
}
|
|
359
418
|
|
|
360
419
|
if (Array.isArray(document.items)) {
|
|
361
420
|
for (const item of document.items) {
|
|
@@ -710,6 +769,24 @@ function cachePolicy(headers = {}) {
|
|
|
710
769
|
return headers['cache-control'] ?? headers.cacheControl ?? ''
|
|
711
770
|
}
|
|
712
771
|
|
|
772
|
+
function paymentSignalHeaders(headers = {}) {
|
|
773
|
+
return [
|
|
774
|
+
'x-payment-required',
|
|
775
|
+
'x-payment-enforce',
|
|
776
|
+
'x-price-usdc',
|
|
777
|
+
'x-payment-address',
|
|
778
|
+
'x-payment-network',
|
|
779
|
+
'x-payment-token',
|
|
780
|
+
'x-payment-protocol',
|
|
781
|
+
].filter(name => headers[name] !== undefined && headers[name] !== '')
|
|
782
|
+
}
|
|
783
|
+
|
|
784
|
+
function advertisesPaymentEnforcement(headers = {}) {
|
|
785
|
+
const required = String(headers['x-payment-required'] ?? '').toLowerCase() === 'true'
|
|
786
|
+
const enforced = String(headers['x-payment-enforce'] ?? '').toLowerCase() === 'true'
|
|
787
|
+
return required || enforced || (required && paymentSignalHeaders(headers).length > 1)
|
|
788
|
+
}
|
|
789
|
+
|
|
713
790
|
function looksExplicitlyCacheable(headers = {}) {
|
|
714
791
|
const policy = cachePolicy(headers)
|
|
715
792
|
if (!policy) return false
|
|
@@ -750,11 +827,21 @@ function findingList(documentResult, challengeResults, preflightResults, entries
|
|
|
750
827
|
if (summary.network) challengeNetworks.add(summary.network)
|
|
751
828
|
const hasChallenge = hasPaymentChallenge(result)
|
|
752
829
|
|
|
830
|
+
if (result.publicDiscovery && !hasChallenge) {
|
|
831
|
+
if (result.status < 200 || result.status >= 300) {
|
|
832
|
+
findings.push(`P2 - ${result.name} is documented as a public discovery route but returned HTTP ${result.status}; check the manifest example parameters or route availability.`)
|
|
833
|
+
}
|
|
834
|
+
continue
|
|
835
|
+
}
|
|
836
|
+
|
|
753
837
|
if (result.status !== 402) {
|
|
754
838
|
if (result.status >= 200 && result.status < 300) {
|
|
755
839
|
if (!looksLikeOperationalHealthEndpoint(result)) {
|
|
756
840
|
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.`)
|
|
757
841
|
}
|
|
842
|
+
if (advertisesPaymentEnforcement(result.headers)) {
|
|
843
|
+
findings.push(`P2 - ${result.name} returned ${result.status} content while payment headers advertise enforcement (${paymentSignalHeaders(result.headers).join(', ')}); either return a 402 before content or document this endpoint as public telemetry.`)
|
|
844
|
+
}
|
|
758
845
|
}
|
|
759
846
|
else if (result.status === 400 || result.status === 422) {
|
|
760
847
|
findings.push(`P1 - ${result.name} returned validation HTTP ${result.status} before a payment challenge for a no-payment ${result.method ?? 'POST'} probe.`)
|
|
@@ -807,7 +894,7 @@ function findingList(documentResult, challengeResults, preflightResults, entries
|
|
|
807
894
|
|
|
808
895
|
for (const result of preflightResults) {
|
|
809
896
|
const challengeResult = challengesByEntry.get(entryKey(result))
|
|
810
|
-
if (!challengeResult || !hasPaymentChallenge(challengeResult)) continue
|
|
897
|
+
if (!challengeResult || (!hasPaymentChallenge(challengeResult) && !advertisesPaymentEnforcement(challengeResult.headers))) continue
|
|
811
898
|
const allowedOrigin = result.headers['access-control-allow-origin'] ?? ''
|
|
812
899
|
if (!allowedOrigin) {
|
|
813
900
|
findings.push(`P1 - ${result.name} CORS preflight does not allow the requesting origin; observed allow-origin: none.`)
|
|
@@ -864,6 +951,9 @@ function groupedFindingLabel(finding) {
|
|
|
864
951
|
if (/payment challenge response did not expose Cache-Control/.test(finding)) {
|
|
865
952
|
return 'P3 - Payment challenge responses do not expose Cache-Control in strict cache mode.'
|
|
866
953
|
}
|
|
954
|
+
if (/content while payment headers advertise enforcement/.test(finding)) {
|
|
955
|
+
return 'P2 - Payment headers advertise enforcement on a 200 response.'
|
|
956
|
+
}
|
|
867
957
|
return null
|
|
868
958
|
}
|
|
869
959
|
|