Skip to content

Potential optional-verifier issue in payment gates #2

Description

@chenshj73

Hi, I noticed a possible optional-verifier issue in the current payment gate helpers.

In src/server/x402-gate.ts, the gate returns a challenge when no X-PAYMENT header exists:

72: export function createX402Gate(config: X402GateConfig) {
82:   // Check for existing X-PAYMENT header
83:   const paymentHeader =
84:     (req.headers['x-payment'] as string) ||
85:     (req.headers['X-PAYMENT'] as string);
87:   if (!paymentHeader) {
91:     const challenge = {
98:       accepts: [
101:         network: config.network,
102:         amount: config.amount,
103:         payTo: config.payTo,
104:         asset: config.asset,

When a header is present, the gate decodes it and performs some local field checks:

114: // Decode X-PAYMENT proof
116: const decoded = Buffer.from(paymentHeader, 'base64').toString('utf-8');
117: const proof: X402Proof = JSON.parse(decoded);
119: // Basic validation
120: if (!proof.payload?.transaction) {
127: // Verify payment destination matches
128: if (proof.accepted?.payTo && proof.accepted.payTo !== config.payTo) {
135: // Verify amount matches
136: if (proof.accepted?.amount && proof.accepted.amount !== config.amount) {

The actual payment verification hook is optional. If config.verifyPayment is not supplied, the middleware attaches the decoded proof and calls the protected handler:

143: // Custom verification (if provided)
144: if (config.verifyPayment) {
145:   const isValid = await config.verifyPayment(proof);
146:   if (!isValid) {
148:     res.writeHead(402, { 'Content-Type': 'application/json' });
149:     res.end(JSON.stringify({ error: 'Payment verification failed' }));
150:     return;
151:   }
152: }
154: // Attach proof to request for downstream handlers
155: req.payment = proof;
156: log(`[x402-gate] Payment verified: tx=${proof.payload.transaction.slice(0, 16)}...`);
157: next();

The MPP gate has the same shape: a valid-looking credential plus optional verifier is enough to reach next():

123: // Decode Payment credential
125: const credentialBase64 = authHeader.slice('Payment '.length);
130: const decoded = Buffer.from(padded, 'base64').toString('utf-8');
131: const credential: MppCredential = JSON.parse(decoded);
133: // Validate credential structure
134: if (!credential.challenge?.id || !credential.payload?.transaction) {
141: // Check realm matches
142: if (credential.challenge.realm !== config.realm) {
149: // Check expiration
160: // Custom verification
161: if (config.verifyCredential) {
162:   const isValid = await config.verifyCredential(credential, credential.challenge.id);
170: // Attach credential to request
171: req.mppPayment = credential;
173: log(`[mpp-gate] Payment verified: tx=${credential.payload.transaction.slice(0, 16)}...`);
174: next();

The combined gate dispatches to either protocol based only on header presence:

52: // Detect which protocol the client is using
53: const hasX402 = !!(req.headers['x-payment'] || req.headers['X-PAYMENT']);
54: const hasAuth = !!(
55:   req.headers['authorization'] &&
56:   (req.headers['authorization'] as string).startsWith('Payment ')
59: if (hasX402) {
61:   return x402Middleware(req, res, next);
64: if (hasAuth) {
66:   return mppMiddleware(req, res, next);

The risky path is:

source: X-PAYMENT or Authorization: Payment header
transform: base64/JSON decode and local shape checks
sink: req.payment/req.mppPayment assignment and next()

Field checks on client-provided accepted or credential data are useful, but they are not the same as verifier-backed authorization, settlement, replay protection, or transaction lookup. If users instantiate the gate without verifyPayment or verifyCredential, a forged proof with matching local fields may be enough to enter the protected handler.

Possible hardening directions:

  • Make verifyPayment / verifyCredential required for production gates.
  • Provide an explicit unsafeAllowUnverifiedProofsForDevelopment option if shallow validation is needed for tests.
  • Fail closed when no verifier is configured, unless the caller opts into mock mode.
  • Add tests that instantiate the gates without verifier callbacks and pass forged matching headers; decide whether those should be rejected by default.

I am reporting this as a potential issue rather than a confirmed exploit, since the library may expect callers to always provide verifier callbacks in real deployments.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions