From 1f3430cafd7f7cd609da4d2b5d109af30ef483e4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ois=C3=ADn=20Kyne?= <4981644+OisinKyne@users.noreply.github.com> Date: Thu, 2 Jul 2026 14:10:49 +0100 Subject: [PATCH] Revert "fix(x402): accept x402 v2 PAYMENT-SIGNATURE header in verifier" This reverts commit 707cb268295de3db07d0d9c796cf2730b9f13f2c. --- internal/x402/forwardauth.go | 40 +++++----------- internal/x402/forwardauth_test.go | 77 ------------------------------- 2 files changed, 12 insertions(+), 105 deletions(-) diff --git a/internal/x402/forwardauth.go b/internal/x402/forwardauth.go index 66cf09b9..e2ec9170 100644 --- a/internal/x402/forwardauth.go +++ b/internal/x402/forwardauth.go @@ -99,10 +99,10 @@ var ( facilitatorSettleTimeout = 60 * time.Second ) -// NewForwardAuthMiddleware creates an x402 payment-gating middleware that accepts -// both x402 wire versions. It reads the payment from the X-PAYMENT (v1) or -// PAYMENT-SIGNATURE (v2) header, verifies the payment with the facilitator, and -// optionally settles after a successful downstream response. +// NewForwardAuthMiddleware creates an x402 payment-gating middleware compatible +// with the v1 wire format. It checks the X-PAYMENT header, verifies the payment +// with the facilitator, and optionally settles after a successful downstream +// response. // // When VerifyOnly is true (Traefik ForwardAuth path), settlement is skipped. // When VerifyOnly is false (standalone gateway path), settlement runs only @@ -128,15 +128,7 @@ func NewForwardAuthMiddleware(cfg ForwardAuthConfig, requirements []x402types.Pa return func(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - // x402 v1 clients send the payment under X-PAYMENT; x402 v2 clients - // (agentcash, poncho, coinbase SDK >= v2) send it under PAYMENT-SIGNATURE. - // Our 402 challenge advertises x402Version 2, so spec-compliant v2 buyers - // use PAYMENT-SIGNATURE. Accept both — otherwise a v2 payment is silently - // ignored and the caller is re-challenged with no way to pay. paymentHeader := r.Header.Get("X-PAYMENT") - if paymentHeader == "" { - paymentHeader = r.Header.Get("PAYMENT-SIGNATURE") - } if paymentHeader == "" { send(w, r, requirements, cfg.Extensions) return @@ -145,20 +137,18 @@ func NewForwardAuthMiddleware(cfg ForwardAuthConfig, requirements []x402types.Pa // Decode the base64-encoded payment payload. payloadBytes, err := base64.StdEncoding.DecodeString(paymentHeader) if err != nil { - log.Printf("x402: invalid payment header base64: %v", err) + log.Printf("x402: invalid X-PAYMENT base64: %v", err) http.Error(w, "Invalid payment header", http.StatusBadRequest) return } - // Unmarshal via the canonical x402 types helper rather than a local - // json.Unmarshal, so the payload envelope stays in lockstep with the SDK. - payloadPtr, err := x402types.ToPaymentPayload(payloadBytes) - if err != nil { - log.Printf("x402: invalid payment payload: %v", err) + // Find matching requirement by scheme+network. + var payload x402types.PaymentPayload + if err := json.Unmarshal(payloadBytes, &payload); err != nil { + log.Printf("x402: invalid payment JSON: %v", err) http.Error(w, "Invalid payment header", http.StatusBadRequest) return } - payload := *payloadPtr matchedReq, found := findMatchingRequirementV1(payload, requirements) if !found { @@ -205,9 +195,7 @@ func NewForwardAuthMiddleware(cfg ForwardAuthConfig, requirements []x402types.Pa // before http.Error commits the status code. if settleResp != nil && settleResp.Transaction != "" { settleJSON, _ := json.Marshal(settleResp) - encodedSettle := base64.StdEncoding.EncodeToString(settleJSON) - w.Header().Set("X-PAYMENT-RESPONSE", encodedSettle) - w.Header().Set("PAYMENT-RESPONSE", encodedSettle) + w.Header().Set("X-PAYMENT-RESPONSE", base64.StdEncoding.EncodeToString(settleJSON)) log.Printf("x402: facilitator returned tx %s with the error — verify on-chain (network=%s payer=%s)", settleResp.Transaction, settleResp.Network, settleResp.Payer) } @@ -221,13 +209,9 @@ func NewForwardAuthMiddleware(cfg ForwardAuthConfig, requirements []x402types.Pa return false } - // Encode the settlement receipt. v1 clients read it from - // X-PAYMENT-RESPONSE; x402 v2 clients read PAYMENT-RESPONSE. - // Emit both so either wire version can confirm the settle. + // Encode settlement response as X-PAYMENT-RESPONSE header. settleJSON, _ := json.Marshal(settleResp) - encodedSettle := base64.StdEncoding.EncodeToString(settleJSON) - w.Header().Set("X-PAYMENT-RESPONSE", encodedSettle) - w.Header().Set("PAYMENT-RESPONSE", encodedSettle) + w.Header().Set("X-PAYMENT-RESPONSE", base64.StdEncoding.EncodeToString(settleJSON)) return true }, onFailure: func(statusCode int) { diff --git a/internal/x402/forwardauth_test.go b/internal/x402/forwardauth_test.go index af2e4d6d..ae366935 100644 --- a/internal/x402/forwardauth_test.go +++ b/internal/x402/forwardauth_test.go @@ -159,83 +159,6 @@ func TestForwardAuth_ValidPayment_VerifyOnly(t *testing.T) { } } -// TestForwardAuth_ValidPayment_PaymentSignatureHeader_V2 pins the x402 v2 wire -// fix: our 402 challenge advertises x402Version 2, so spec-compliant v2 buyers -// (agentcash, poncho, coinbase SDK >= v2) attach the payment under the -// PAYMENT-SIGNATURE header, not the legacy X-PAYMENT. Before the fix the verifier -// only read X-PAYMENT, so a v2 payment was silently ignored and re-challenged — -// no verify, no settle, no log. This asserts the v2 header is now honored. -func TestForwardAuth_ValidPayment_PaymentSignatureHeader_V2(t *testing.T) { - var verifyCalled, settleCalled atomic.Int32 - fac := mockFacilitatorV1(true, true, &verifyCalled, &settleCalled) - defer fac.Close() - - mw := NewForwardAuthMiddleware(ForwardAuthConfig{ - FacilitatorURL: fac.URL, - VerifyOnly: true, - }, testRequirements()) - - var innerCalled bool - inner := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - innerCalled = true - w.WriteHeader(http.StatusOK) - }) - - req := httptest.NewRequest("POST", "/v1/chat/completions", nil) - req.Header.Set("PAYMENT-SIGNATURE", validPaymentHeader()) // v2 header, no X-PAYMENT - rec := httptest.NewRecorder() - mw(inner).ServeHTTP(rec, req) - - if rec.Code != http.StatusOK { - t.Errorf("status = %d, want %d (v2 PAYMENT-SIGNATURE must be accepted)", rec.Code, http.StatusOK) - } - if !innerCalled { - t.Error("inner handler was not called for a valid v2 PAYMENT-SIGNATURE payment") - } - if verifyCalled.Load() != 1 { - t.Errorf("verify called %d times, want 1 (v2 header should reach the facilitator)", verifyCalled.Load()) - } -} - -// TestForwardAuth_SettleOnSuccess_PaymentSignatureHeader_V2 asserts a v2 payment -// settles end-to-end and that the settlement receipt is mirrored onto BOTH the -// legacy X-PAYMENT-RESPONSE and the v2 PAYMENT-RESPONSE header so either wire -// version can read it. -func TestForwardAuth_SettleOnSuccess_PaymentSignatureHeader_V2(t *testing.T) { - var verifyCalled, settleCalled atomic.Int32 - fac := mockFacilitatorV1(true, true, &verifyCalled, &settleCalled) - defer fac.Close() - - mw := NewForwardAuthMiddleware(ForwardAuthConfig{ - FacilitatorURL: fac.URL, - VerifyOnly: false, - }, testRequirements()) - - inner := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(http.StatusOK) - _, _ = w.Write([]byte(`{"result":"ok"}`)) - }) - - req := httptest.NewRequest("POST", "/v1/chat/completions", nil) - req.Header.Set("PAYMENT-SIGNATURE", validPaymentHeader()) - rec := httptest.NewRecorder() - mw(inner).ServeHTTP(rec, req) - - if rec.Code != http.StatusOK { - t.Errorf("status = %d, want %d", rec.Code, http.StatusOK) - } - if settleCalled.Load() != 1 { - t.Errorf("settle called %d times, want 1", settleCalled.Load()) - } - // Both receipt headers must be present for cross-version clients. - if rec.Header().Get("X-PAYMENT-RESPONSE") == "" { - t.Error("X-PAYMENT-RESPONSE header not set after settlement") - } - if rec.Header().Get("PAYMENT-RESPONSE") == "" { - t.Error("PAYMENT-RESPONSE (v2) header not set after settlement") - } -} - func TestForwardAuth_InvalidPayment_Returns402(t *testing.T) { var verifyCalled, settleCalled atomic.Int32 fac := mockFacilitatorV1(false, true, &verifyCalled, &settleCalled)