Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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
10 changes: 5 additions & 5 deletions go/http/echo/middleware.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
95 changes: 95 additions & 0 deletions go/http/echo/middleware_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
})
}
10 changes: 5 additions & 5 deletions go/http/gin/middleware.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
96 changes: 96 additions & 0 deletions go/http/gin/middleware_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
})
}
10 changes: 5 additions & 5 deletions go/http/nethttp/middleware.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
79 changes: 79 additions & 0 deletions go/http/nethttp/middleware_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
})
}
Loading
Loading