From 02eab6902b1351a43f325cc4dac508cc1d2bddd3 Mon Sep 17 00:00:00 2001 From: PhilBot <9mmnwvp6vs@privaterelay.appleid.com> Date: Mon, 21 Sep 2026 08:17:53 +0000 Subject: [PATCH] fix(go): match HTTP routes on escaped and decoded paths Port Python #3502. getRouteConfig now requires payment if either the escaped path or the framework decoded routing view matches a protected route, so a literal route such as GET /api/premium cannot be reached unpaid via /api%2Fpremium. Co-authored-by: phdargen --- ...xed-20260921-decoded-path-route-match.yaml | 3 + go/http/echo/middleware.go | 10 +- go/http/echo/middleware_test.go | 95 ++++++++++++++++++ go/http/gin/middleware.go | 10 +- go/http/gin/middleware_test.go | 96 +++++++++++++++++++ go/http/nethttp/middleware.go | 10 +- go/http/nethttp/middleware_test.go | 79 +++++++++++++++ go/http/server.go | 56 ++++++++--- go/http/server_test.go | 78 +++++++++++++++ 9 files changed, 411 insertions(+), 26 deletions(-) create mode 100644 go/.changes/unreleased/fixed-20260921-decoded-path-route-match.yaml diff --git a/go/.changes/unreleased/fixed-20260921-decoded-path-route-match.yaml b/go/.changes/unreleased/fixed-20260921-decoded-path-route-match.yaml new file mode 100644 index 0000000000..4b0119a84c --- /dev/null +++ b/go/.changes/unreleased/fixed-20260921-decoded-path-route-match.yaml @@ -0,0 +1,3 @@ +kind: fixed +body: HTTP resource servers now match protected routes against both the escaped request path and the framework's decoded routing view (net/url URL.Path), requiring payment if either matches. A literal route such as GET /api/premium could previously be reached unpaid by encoding its path separator (/api%2Fpremium) when the adapter only consulted EscapedPath() while Gin, Echo, and net/http dispatched on the decoded path. +time: 2026-09-21T08:00:00Z diff --git a/go/http/echo/middleware.go b/go/http/echo/middleware.go index 86e760e735..7255a6a376 100644 --- a/go/http/echo/middleware.go +++ b/go/http/echo/middleware.go @@ -294,11 +294,11 @@ func createMiddlewareHandler(server *x402http.HTTPServer, config *MiddlewareConf adapter := NewEchoAdapter(c) reqCtx := x402http.HTTPRequestContext{ Adapter: adapter, - // EscapedPath, not Path: routers dispatch on the escaped path, so - // matching on the decoded one lets "%2F" split a segment here but - // not in the router, bypassing the payment gate. - Path: c.Request().URL.EscapedPath(), - Method: c.Request().Method, + // Match both EscapedPath and URL.Path (the decoded routing view) + // so a route can't be bypassed via either representation. + Path: c.Request().URL.EscapedPath(), + DecodedPath: c.Request().URL.Path, + Method: c.Request().Method, } // Check if route requires payment before waiting for initialization diff --git a/go/http/echo/middleware_test.go b/go/http/echo/middleware_test.go index 62937c5167..1d69a0f39f 100644 --- a/go/http/echo/middleware_test.go +++ b/go/http/echo/middleware_test.go @@ -1565,3 +1565,98 @@ func TestPaymentMiddleware_EncodedPathDoesNotBypassPaymentGate(t *testing.T) { }) } } + +// TestPaymentMiddleware_LiteralRoutePercentEncodedSeparatorBypass guards the +// complementary CWE-436: when EscapedPath and URL.Path diverge, payment +// matching must consult both so a decoded-path dispatcher cannot fail-open +// a literal route such as GET /api/premium via /api%2Fpremium. +func TestPaymentMiddleware_LiteralRoutePercentEncodedSeparatorBypass(t *testing.T) { + bypassPaths := []string{ + "/api/premium", // baseline: plainly protected + "/api%2Fpremium", // encoded slash + "/api%2fpremium", // lowercase encoded slash + "/%61pi%2Fpremium", // encoded slash and letter + } + + routes := x402http.RoutesConfig{ + "GET /api/premium": x402http.RouteConfig{ + Accepts: x402http.PaymentOptions{ + {Scheme: "exact", PayTo: "0xtest", Price: "$1.00", Network: "eip155:1"}, + }, + }, + } + + for _, path := range bypassPaths { + t.Run(path, func(t *testing.T) { + mockClient := &mockFacilitatorClient{ + supportedFunc: func(ctx context.Context) (x402.SupportedResponse, error) { + return x402.SupportedResponse{ + Kinds: []x402.SupportedKind{ + {X402Version: 2, Scheme: "exact", Network: "eip155:1"}, + }, + Extensions: []string{}, + Signers: make(map[string][]string), + }, nil + }, + } + + e := createTestEcho() + e.Use(PaymentMiddlewareFromConfig(routes, + WithFacilitatorClient(mockClient), + WithScheme("eip155:1", &mockSchemeServer{scheme: "exact"}), + WithSyncFacilitatorOnStart(true), + WithTimeout(5*time.Second), + )) + + handlerRan := false + e.GET("/api/premium", func(c echo.Context) error { + handlerRan = true + return c.JSON(http.StatusOK, map[string]string{"secret": "paid content"}) + }) + + req := httptest.NewRequest("GET", path, nil) + req.Header.Set("Accept", "application/json") + w := httptest.NewRecorder() + e.ServeHTTP(w, req) + + if handlerRan { + t.Errorf("payment bypassed: paid handler ran for %s (status %d)", path, w.Code) + } + if w.Code != http.StatusPaymentRequired { + t.Errorf("Expected status 402 for %s, got %d", path, w.Code) + } + }) + } + + t.Run("/health", func(t *testing.T) { + mockClient := &mockFacilitatorClient{ + supportedFunc: func(ctx context.Context) (x402.SupportedResponse, error) { + return x402.SupportedResponse{ + Kinds: []x402.SupportedKind{ + {X402Version: 2, Scheme: "exact", Network: "eip155:1"}, + }, + Extensions: []string{}, + Signers: make(map[string][]string), + }, nil + }, + } + + e := createTestEcho() + e.Use(PaymentMiddlewareFromConfig(routes, + WithFacilitatorClient(mockClient), + WithScheme("eip155:1", &mockSchemeServer{scheme: "exact"}), + WithSyncFacilitatorOnStart(true), + WithTimeout(5*time.Second), + )) + e.GET("/api/premium", func(c echo.Context) error { + return c.JSON(http.StatusOK, map[string]string{"secret": "paid content"}) + }) + + req := httptest.NewRequest("GET", "/health", nil) + w := httptest.NewRecorder() + e.ServeHTTP(w, req) + if w.Code != http.StatusNotFound { + t.Errorf("Expected status 404 for /health, got %d", w.Code) + } + }) +} diff --git a/go/http/gin/middleware.go b/go/http/gin/middleware.go index 18e9d30bbd..c7761d4643 100644 --- a/go/http/gin/middleware.go +++ b/go/http/gin/middleware.go @@ -295,11 +295,11 @@ func createMiddlewareHandler(server *x402http.HTTPServer, config *MiddlewareConf adapter := NewGinAdapter(c) reqCtx := x402http.HTTPRequestContext{ Adapter: adapter, - // EscapedPath, not Path: routers dispatch on the escaped path, so - // matching on the decoded one lets "%2F" split a segment here but - // not in the router, bypassing the payment gate. - Path: c.Request.URL.EscapedPath(), - Method: c.Request.Method, + // Match both EscapedPath and URL.Path (the decoded routing view) + // so a route can't be bypassed via either representation. + Path: c.Request.URL.EscapedPath(), + DecodedPath: c.Request.URL.Path, + Method: c.Request.Method, } // Check if route requires payment before waiting for initialization diff --git a/go/http/gin/middleware_test.go b/go/http/gin/middleware_test.go index e22d9103e8..2072f8ba68 100644 --- a/go/http/gin/middleware_test.go +++ b/go/http/gin/middleware_test.go @@ -1827,3 +1827,99 @@ func TestPaymentMiddleware_EncodedPathDoesNotBypassPaymentGate(t *testing.T) { }) } } + +// TestPaymentMiddleware_LiteralRoutePercentEncodedSeparatorBypass guards the +// complementary CWE-436: Gin's default router (UseRawPath=false) dispatches +// literal routes on the decoded path. A request for /api%2Fpremium therefore +// reaches GET /api/premium, while matching only EscapedPath() misses the +// literal pattern and fail-opens. +func TestPaymentMiddleware_LiteralRoutePercentEncodedSeparatorBypass(t *testing.T) { + bypassPaths := []string{ + "/api/premium", // baseline: plainly protected + "/api%2Fpremium", // encoded slash + "/api%2fpremium", // lowercase encoded slash + "/%61pi%2Fpremium", // encoded slash and letter + } + + routes := x402http.RoutesConfig{ + "GET /api/premium": x402http.RouteConfig{ + Accepts: x402http.PaymentOptions{ + {Scheme: "exact", PayTo: "0xtest", Price: "$1.00", Network: "eip155:1"}, + }, + }, + } + + for _, path := range bypassPaths { + t.Run(path, func(t *testing.T) { + mockClient := &mockFacilitatorClient{ + supportedFunc: func(ctx context.Context) (x402.SupportedResponse, error) { + return x402.SupportedResponse{ + Kinds: []x402.SupportedKind{ + {X402Version: 2, Scheme: "exact", Network: "eip155:1"}, + }, + Extensions: []string{}, + Signers: make(map[string][]string), + }, nil + }, + } + + router := createTestRouter() + router.Use(PaymentMiddlewareFromConfig(routes, + WithFacilitatorClient(mockClient), + WithScheme("eip155:1", &mockSchemeServer{scheme: "exact"}), + WithSyncFacilitatorOnStart(true), + WithTimeout(5*time.Second), + )) + + handlerRan := false + router.GET("/api/premium", func(c *gin.Context) { + handlerRan = true + c.JSON(http.StatusOK, gin.H{"secret": "paid content"}) + }) + + req := httptest.NewRequest("GET", path, nil) + req.Header.Set("Accept", "application/json") + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if handlerRan { + t.Errorf("payment bypassed: paid handler ran for %s (status %d)", path, w.Code) + } + if w.Code != http.StatusPaymentRequired { + t.Errorf("Expected status 402 for %s, got %d", path, w.Code) + } + }) + } + + t.Run("/health", func(t *testing.T) { + mockClient := &mockFacilitatorClient{ + supportedFunc: func(ctx context.Context) (x402.SupportedResponse, error) { + return x402.SupportedResponse{ + Kinds: []x402.SupportedKind{ + {X402Version: 2, Scheme: "exact", Network: "eip155:1"}, + }, + Extensions: []string{}, + Signers: make(map[string][]string), + }, nil + }, + } + + router := createTestRouter() + router.Use(PaymentMiddlewareFromConfig(routes, + WithFacilitatorClient(mockClient), + WithScheme("eip155:1", &mockSchemeServer{scheme: "exact"}), + WithSyncFacilitatorOnStart(true), + WithTimeout(5*time.Second), + )) + router.GET("/api/premium", func(c *gin.Context) { + c.JSON(http.StatusOK, gin.H{"secret": "paid content"}) + }) + + req := httptest.NewRequest("GET", "/health", nil) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + if w.Code != http.StatusNotFound { + t.Errorf("Expected status 404 for /health, got %d", w.Code) + } + }) +} diff --git a/go/http/nethttp/middleware.go b/go/http/nethttp/middleware.go index 913893580d..e36e2e53d7 100644 --- a/go/http/nethttp/middleware.go +++ b/go/http/nethttp/middleware.go @@ -228,11 +228,11 @@ func createMiddlewareHandler(server *x402http.HTTPServer, config *MiddlewareConf adapter := NewNetHTTPAdapter(r) reqCtx := x402http.HTTPRequestContext{ Adapter: adapter, - // EscapedPath, not Path: routers dispatch on the escaped path, so - // matching on the decoded one lets "%2F" split a segment here but - // not in the router, bypassing the payment gate. - Path: r.URL.EscapedPath(), - Method: r.Method, + // Match both EscapedPath and URL.Path (the decoded routing view) + // so a route can't be bypassed via either representation. + Path: r.URL.EscapedPath(), + DecodedPath: r.URL.Path, + Method: r.Method, } // Check if route requires payment diff --git a/go/http/nethttp/middleware_test.go b/go/http/nethttp/middleware_test.go index efae68211e..12b5e819b8 100644 --- a/go/http/nethttp/middleware_test.go +++ b/go/http/nethttp/middleware_test.go @@ -1619,3 +1619,82 @@ func TestPaymentMiddleware_EncodedPathDoesNotBypassPaymentGate(t *testing.T) { }) } } + +// TestPaymentMiddleware_LiteralRoutePercentEncodedSeparatorBypass guards the +// complementary CWE-436: when EscapedPath and URL.Path diverge, payment +// matching must consult both so a decoded-path dispatcher cannot fail-open +// a literal route such as GET /api/premium via /api%2Fpremium. +func TestPaymentMiddleware_LiteralRoutePercentEncodedSeparatorBypass(t *testing.T) { + bypassPaths := []string{ + "/api/premium", // baseline: plainly protected + "/api%2Fpremium", // encoded slash + "/api%2fpremium", // lowercase encoded slash + "/%61pi%2Fpremium", // encoded slash and letter + } + + routes := x402http.RoutesConfig{ + "GET /api/premium": x402http.RouteConfig{ + Accepts: x402http.PaymentOptions{ + {Scheme: "exact", PayTo: "0xtest", Price: "$1.00", Network: "eip155:1"}, + }, + }, + } + + for _, path := range bypassPaths { + t.Run(path, func(t *testing.T) { + mockClient := &mockFacilitatorClient{supportedFunc: defaultSupportedFunc()} + + handlerRan := false + paidHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + handlerRan = true + w.WriteHeader(http.StatusOK) + _ = json.NewEncoder(w).Encode(map[string]string{"secret": "paid content"}) + }) + + mux := http.NewServeMux() + mux.Handle("GET /api/premium", paidHandler) + + middleware := PaymentMiddlewareFromConfig(routes, + WithFacilitatorClient(mockClient), + WithScheme("eip155:1", &mockSchemeServer{scheme: "exact"}), + WithSyncFacilitatorOnStart(true), + WithTimeout(5*time.Second), + ) + wrapped := middleware(mux) + + req := httptest.NewRequest("GET", path, nil) + req.Header.Set("Accept", "application/json") + w := httptest.NewRecorder() + wrapped.ServeHTTP(w, req) + + if handlerRan { + t.Errorf("payment bypassed: paid handler ran for %s (status %d)", path, w.Code) + } + if w.Code != http.StatusPaymentRequired { + t.Errorf("Expected status 402 for %s, got %d", path, w.Code) + } + }) + } + + t.Run("/health", func(t *testing.T) { + mockClient := &mockFacilitatorClient{supportedFunc: defaultSupportedFunc()} + mux := http.NewServeMux() + mux.Handle("GET /api/premium", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + })) + middleware := PaymentMiddlewareFromConfig(routes, + WithFacilitatorClient(mockClient), + WithScheme("eip155:1", &mockSchemeServer{scheme: "exact"}), + WithSyncFacilitatorOnStart(true), + WithTimeout(5*time.Second), + ) + wrapped := middleware(mux) + + req := httptest.NewRequest("GET", "/health", nil) + w := httptest.NewRecorder() + wrapped.ServeHTTP(w, req) + if w.Code != http.StatusNotFound { + t.Errorf("Expected status 404 for /health, got %d", w.Code) + } + }) +} diff --git a/go/http/server.go b/go/http/server.go index f510b00a0f..b83bf2e1a7 100644 --- a/go/http/server.go +++ b/go/http/server.go @@ -172,7 +172,10 @@ type HTTPRequestContext struct { Method string PaymentHeader string RoutePattern string - Requirements []types.PaymentRequirements + // DecodedPath is the framework's own decoded routing view of the path + // (e.g. net/url's URL.Path), if distinct from Path. + DecodedPath string + Requirements []types.PaymentRequirements } // HTTPTransportContext carries request and response data through settlement processing. @@ -564,7 +567,7 @@ func (s *x402HTTPResourceServer) ProcessHTTPRequest(ctx context.Context, reqCtx } // Find matching route - routeConfig, routePattern := s.getRouteConfig(reqCtx.Path, reqCtx.Method) + routeConfig, routePattern := s.getRouteConfig(reqCtx.Path, reqCtx.Method, reqCtx.DecodedPath) if routeConfig == nil { return HTTPProcessResult{Type: ResultNoPaymentRequired} } @@ -884,7 +887,7 @@ func (s *x402HTTPResourceServer) RequiresPayment(reqCtx HTTPRequestContext) bool if method == "" { method = reqCtx.Adapter.GetMethod() } - routeConfig, _ := s.getRouteConfig(reqCtx.Path, method) + routeConfig, _ := s.getRouteConfig(reqCtx.Path, method, reqCtx.DecodedPath) return routeConfig != nil } @@ -1192,17 +1195,30 @@ func (s *x402HTTPResourceServer) buildSettlementFailureResult(errorReason string // Helper Methods // ============================================================================ -// getRouteConfig finds matching route configuration and returns the route pattern -func (s *x402HTTPResourceServer) getRouteConfig(path, method string) (*RouteConfig, string) { - normalizedPath := normalizePath(path) +// getRouteConfig finds matching route configuration and returns the route pattern. +// +// Checks the escaped path first, then the framework's decodedPath (if distinct), +// so a route can't be bypassed via either representation. +func (s *x402HTTPResourceServer) getRouteConfig(path, method, decodedPath string) (*RouteConfig, string) { upperMethod := strings.ToUpper(method) - for _, route := range s.compiledRoutes { - if route.Regex.MatchString(normalizedPath) && - (route.Verb == "*" || route.Verb == upperMethod) { - config := route.Config // Make a copy - return &config, route.Pattern + findMatch := func(candidate string) (*RouteConfig, string) { + for _, route := range s.compiledRoutes { + if route.Regex.MatchString(candidate) && + (route.Verb == "*" || route.Verb == upperMethod) { + config := route.Config // Make a copy + return &config, route.Pattern + } } + return nil, "" + } + + if config, pattern := findMatch(normalizePath(path)); config != nil { + return config, pattern + } + + if decodedPath != "" && decodedPath != path { + return findMatch(normalizeDecodedPath(decodedPath)) } return nil, "" @@ -1610,3 +1626,21 @@ func normalizePath(path string) string { return path } + +// normalizeDecodedPath normalizes an already framework-decoded path. It does +// not decode percent-escapes, unlike normalizePath, since this input was +// already decoded once by the router. +func normalizeDecodedPath(path string) string { + if idx := strings.IndexAny(path, "?#"); idx >= 0 { + path = path[:idx] + } + + path = multiSlashRegex.ReplaceAllString(path, `/`) + path = strings.TrimSuffix(path, `/`) + + if path == "" { + path = "/" + } + + return path +} diff --git a/go/http/server_test.go b/go/http/server_test.go index aac6d4ac28..f2a190cb63 100644 --- a/go/http/server_test.go +++ b/go/http/server_test.go @@ -1250,6 +1250,84 @@ func TestRouteMatching_PathNormalizationBypass(t *testing.T) { } } +func TestNormalizeDecodedPath(t *testing.T) { + tests := []struct { + input string + expected string + }{ + {"/api", "/api"}, + {"/api/", "/api"}, + {"/api//users", "/api/users"}, + {"/api?query=1", "/api"}, + {"/api#fragment", "/api"}, + {"", "/"}, + // Already-decoded input is passed through, not re-decoded. + {"/api/x%41", "/api/x%41"}, + {"/api/premium", "/api/premium"}, + } + + for _, tt := range tests { + t.Run(tt.input, func(t *testing.T) { + result := normalizeDecodedPath(tt.input) + if result != tt.expected { + t.Errorf("Expected %s, got %s", tt.expected, result) + } + }) + } +} + +func TestDecodedPathDivergenceBypass(t *testing.T) { + serverFor := func(pattern string) *x402HTTPResourceServer { + return Newx402HTTPResourceServer(RoutesConfig{ + pattern: {Accepts: PaymentOptions{}}, + }) + } + + t.Run("literal route requires payment when decoded path matches", func(t *testing.T) { + escapedPaths := []string{"/api%2Fpremium", "/api%2fpremium", "/%61pi%2Fpremium"} + for _, escapedPath := range escapedPaths { + reqCtx := HTTPRequestContext{ + Path: escapedPath, + Method: "GET", + DecodedPath: "/api/premium", + } + if !serverFor("GET /api/premium").RequiresPayment(reqCtx) { + t.Errorf("expected payment required for escaped path %s", escapedPath) + } + } + }) + + t.Run("literal route misses without decoded path", func(t *testing.T) { + // Pre-fix behavior: only the escaped path is checked. + reqCtx := HTTPRequestContext{Path: "/api%2Fpremium", Method: "GET"} + if serverFor("GET /api/premium").RequiresPayment(reqCtx) { + t.Error("expected no payment required when decoded path is absent") + } + }) + + t.Run("real extra segment still not matched", func(t *testing.T) { + reqCtx := HTTPRequestContext{ + Path: "/api/users/x/y", + Method: "GET", + DecodedPath: "/api/users/x/y", + } + if serverFor("GET /api/users/:id").RequiresPayment(reqCtx) { + t.Error("expected extra segment not to match :id route") + } + }) + + t.Run("unrelated decoded path does not require payment", func(t *testing.T) { + reqCtx := HTTPRequestContext{ + Path: "/public/report", + Method: "GET", + DecodedPath: "/public/report", + } + if serverFor("GET /api/premium").RequiresPayment(reqCtx) { + t.Error("expected unrelated path not to require payment") + } + }) +} + func TestGetDisplayAmount(t *testing.T) { server := Newx402HTTPResourceServer(RoutesConfig{})