Skip to content

Potential reusable payment-header credit issue: entitlement lookup is keyed by stored header value #21

Description

@chenshj73

Hi, I noticed a possible reusable payment-header credit issue while reviewing the current source.

In routes/handler.go, an incoming payment header is treated as a lookup key for an existing purchase:

297: // tryExistingPayment checks if a client-provided X-Payment header corresponds to a valid, usable purchase.
302: func (h *PaidRouteHandler) tryExistingPayment(gCtx *gin.Context, route *PaidRoute) (usedExistingCredit bool, proceedToNewPayment bool) {
303:   paymentHeaderName := getPaymentHeaderNameForRoute(route)
304:   clientPaymentHeader := gCtx.GetHeader(paymentHeaderName)
305:   if clientPaymentHeader == "" {
306:     return false, true
307:   }
314:   existingPurchase, err := h.purchaseService.GetPurchaseByRouteIDAndPaymentHeader(gCtx.Request.Context(), route.ID, clientPaymentHeader)

If a purchase record is found, the code consumes one local credit and continues toward proxying without re-verifying or re-settling the original payment proof:

334: if existingPurchase.CreditsUsed >= existingPurchase.CreditsAvailable {
335:   h.logger.Info("Existing purchase (via header) has no credits left. Proceeding to new payment.",
337:   return false, true
340: // Credits are available, attempt to use one.
344: errIncrement := h.purchaseService.IncrementCreditsUsed(gCtx.Request.Context(), existingPurchase.ID)
351: // Successfully used a credit from an existing purchase.
352: h.logger.Info("Successfully used a credit from existing purchase via payment header.",

The database query uses only the route id and the raw stored payment header:

59: -- name: GetPurchaseByRouteIDAndPaymentHeader :one
60: SELECT * FROM purchases
61: WHERE paid_route_id = $1 AND payment_header = $2
62: ORDER BY created_at DESC LIMIT 1;
64: -- name: IncrementPurchaseCreditsUsed :exec
65: UPDATE purchases
66: SET credits_used = credits_used + 1, updated_at = $2
67: WHERE id = $1 AND credits_used < credits_available;

For new purchases, the same raw header is persisted together with local credit state:

427: paymentPayloadJSON, settleResponseJSON := x402.Payment(gCtx, priceFloat, paymentAddress,
446: // If we get here, payment verification within x402.Payment succeeded.
447: paymentHeaderForNewPurchase := gCtx.GetHeader(getPaymentHeaderNameForRoute(route))
448: err = h.savePurchaseRecordJSON(gCtx.Request.Context(), route, paymentAddress, paymentPayloadJSON, settleResponseJSON, paymentHeaderForNewPurchase)
877: purchase := &purchases.Purchase{
881:   Price:            route.Price,
883:   CreditsAvailable: route.Credits,
884:   CreditsUsed:      1, // If we create a purchase record, we know it's a new payment. (1 use already)
887:   PaidRouteID:   route.ID,
888:   PaidToAddress: paymentAddress,
890:   PaymentHeader:  paymentHeader,
891:   PaymentPayload: paymentPayloadJSON,
892:   SettleResponse: settleResponseJSON,

The normal fresh-payment path does settle before storing the purchase. For example, x402/payment.go settles successfully before returning:

183: settleResponse, err := facilitator.Settle(c.Request.Context(), decodedPayload, requirementsJSON)
184: if err != nil || settleResponse == nil || !settleResponse.Success {
191:   respondPaymentRequiredV1(c, isWebBrowser, resource, amount, options, paymentRequirements, paymentRequirementsJSON, errMsg)
192:   return nil, nil
204: c.Header("X-PAYMENT-RESPONSE", base64.StdEncoding.EncodeToString(settleResponseJSON))
206: return paymentPayloadJSON, settleResponseJSON

The concern is specifically the later reuse path:

source: X-Payment / PAYMENT-SIGNATURE header
transform: lookup purchase by paid_route_id + raw header
sink: increment local credits_used and allow route access

If the raw header is copied, logged, shared, or replayed by another client, it may act as a bearer credit token until credits_available is exhausted. The lookup does not appear to bind reuse to the original payer identity, request context, client session, or a server-generated opaque entitlement id. This may be intended as multi-use credits, but it would be safer if the reusable artifact were a narrow server-issued entitlement rather than the original payment proof header.

Possible hardening directions:

  • Store a server-generated purchase token for credit reuse instead of reusing the payment header itself.
  • Bind reusable credits to the original payer, authenticated user, or client account where that identity exists.
  • Treat the original payment proof as one-time settlement material and separate it from post-settlement entitlement state.
  • Consider hashing stored proof material and avoiding raw proof reuse as an authorization credential.

I am reporting this as a potential issue rather than a confirmed exploit, since the intended product model may explicitly allow bearer-style prepaid credits for a route.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions