Root cause
Other / insufficient public mechanism detail introduced
fix: add HTTP middleware to redirect trailing slash URLs to canonical form (#420)
How AI contributed: Direct introductionPublished May 8, 2026 in
modelcontextprotocol/registryAll 3 findings in this repository
The AI-written TrailingSlashMiddleware in internal/api/server.go trimmed the final slash with TrimSuffix and returned a 308 redirect for any non-root path. For a protocol-relative request such as a double-slash evil.com address, the stripped path became //evil.com, which browsers resolve to a different host, turning the registry canonical-url redirect into an open redirect usable for phishing or credential theft. The fix normalizes the path with path.Clean before it is written into the Location header.
TrimSuffix removed only the last character of the raw request path, so //evil.com/ became the scheme-relative //evil.com that a browser reads as another host, and the middleware never passed the path through path.Clean before building the redirect.
Root cause
fix: add HTTP middleware to redirect trailing slash URLs to canonical form (#420)
Fix
security: fix open redirect and add small hardening (#1227)
AI-assisted fix: Claude Code · Radoslav Dimitrov
Case facts
@@ -0,0 +1,95 @@+package api_test++import (+ "net/http"+ "net/http/httptest"+ "testing"++ "github.com/modelcontextprotocol/registry/internal/api"+)++func TestTrailingSlashMiddleware(t *testing.T) {+ // Create a simple handler that returns "OK"+ handler := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {+ w.WriteHeader(http.StatusOK)+ _, _ = w.Write([]byte("OK"))+ })++ // Wrap with our middleware+ middleware := api.TrailingSlashMiddleware(handler)++ tests := []struct {+ name string+ path string+ expectedStatus int+ expectedLocation string+ expectRedirect bool+ }{+ {+ name: "root path should not redirect",+ path: "/",+ expectedStatus: http.StatusOK,+ expectRedirect: false,+ },+ {+ name: "path without trailing slash should pass through",+ path: "/v0/servers",+ expectedStatus: http.StatusOK,+ expectRedirect: false,+ },+ {+ name: "path with trailing slash should redirect",+ path: "/v0/servers/",+ expectedStatus: http.StatusPermanentRedirect,+ expectedLocation: "/v0/servers",+ expectRedirect: true,+ },+ {+ name: "nested path with trailing slash should redirect",+ path: "/v0/servers/123/",+ expectedStatus: http.StatusPermanentRedirect,+ expectedLocation: "/v0/servers/123",+ expectRedirect: true,+ },+ {+ name: "deep nested path with trailing slash should redirect",+ path: "/v0/auth/github/token/",+ expectedStatus: http.StatusPermanentRedirect,+ expectedLocation: "/v0/auth/github/token",+ expectRedirect: true,+ },+ {+ name: "path with query params and no trailing slash should pass through",+ path: "/v0/servers?limit=10",+ expectedStatus: http.StatusOK,+ expectRedirect: false,+ },+ {+ name: "path with query params and trailing slash should redirect preserving query params",+ path: "/v0/servers/?limit=10",+ expectedStatus: http.StatusPermanentRedirect,+ expectedLocation: "/v0/servers?limit=10",+ expectRedirect: true,+ },+ }++ for _, tt := range tests {+ t.Run(tt.name, func(t *testing.T) {+ req := httptest.NewRequest(http.MethodGet, tt.path, nil)+ w := httptest.NewRecorder()++ middleware.ServeHTTP(w, req)++ if w.Code != tt.expectedStatus {+ t.Errorf("expected status %d, got %d", tt.expectedStatus, w.Code)+ }++ if tt.expectRedirect {+ location := w.Header().Get("Location")+ if location != tt.expectedLocation {@@ -4,6 +4,7 @@ import ( "context" "log" "net/http"+ "strings" "time" "github.com/danielgtaylor/huma/v2"@@ -14,6 +15,24 @@ import ( "github.com/modelcontextprotocol/registry/internal/telemetry" ) +// TrailingSlashMiddleware redirects requests with trailing slashes to their canonical form+func TrailingSlashMiddleware(next http.Handler) http.Handler {+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {+ // Only redirect if the path is not "/" and ends with a "/"+ if r.URL.Path != "/" && strings.HasSuffix(r.URL.Path, "/") {+ // Create a copy of the URL and remove the trailing slash+ newURL := *r.URL+ newURL.Path = strings.TrimSuffix(r.URL.Path, "/")+ + // Use 308 Permanent Redirect to preserve the request method+ http.Redirect(w, r, newURL.String(), http.StatusPermanentRedirect)+ return+ }+ + next.ServeHTTP(w, r)+ })+}+ // Server represents the HTTP server type Server struct { config *config.Config@@ -29,13 +48,16 @@ func NewServer(cfg *config.Config, registryService service.RegistryService, metr api := router.NewHumaAPI(cfg, registryService, mux, metrics) + // Wrap the mux with trailing slash middleware+ handler := TrailingSlashMiddleware(mux)+ server := &Server{ config: cfg, registry: registryService, humaAPI: api, server: &http.Server{ Addr: cfg.ServerAddress,- Handler: mux,+ Handler: handler, ReadHeaderTimeout: 10 * time.Second, }, }@@ -5,6 +5,7 @@ import ( "encoding/json" "log" "net/http"+ "path" "strings" "time" @@ -71,9 +72,11 @@ func TrailingSlashMiddleware(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { // Only redirect if the path is not "/" and ends with a "/" if r.URL.Path != "/" && strings.HasSuffix(r.URL.Path, "/") {- // Create a copy of the URL and remove the trailing slash+ // path.Clean both removes the trailing slash and collapses any+ // leading "//" to "/", which prevents an open-redirect via a+ // protocol-relative path like "//evil.com/" (GHSA-v8vw-gw5j-w7m6). newURL := *r.URL- newURL.Path = strings.TrimSuffix(r.URL.Path, "/")+ newURL.Path = path.Clean(r.URL.Path) // Use 308 Permanent Redirect to preserve the request method http.Redirect(w, r, newURL.String(), http.StatusPermanentRedirect)@@ -127,6 +130,17 @@ func NewServer(cfg *config.Config, registryService service.RegistryService, metr Addr: cfg.ServerAddress, Handler: handler, ReadHeaderTimeout: 10 * time.Second,+ ReadTimeout: 30 * time.Second,+ // WriteTimeout intentionally not set: the publish path runs+ // outbound package validators sequentially (npm/pypi/nuget up to+ // 10s each, OCI up to 30s), so any tight cap could cut off a+ // legitimate multi-package publish mid-response — surfacing as a+ // truncated read to the publisher even when the DB commit+ // succeeded. Slow-response-read DoS is bounded upstream by+ // NGINX ingress timeouts and the per-IP rate limit. Revisit once+ // validators are parallelised or per-request package counts are+ // bounded.+ IdleTimeout: 120 * time.Second, }, } @@ -232,6 +232,22 @@ func TestTrailingSlashMiddleware(t *testing.T) { expectedLocation: "/v0/servers?limit=10", expectRedirect: true, },+ {+ // Regression test for GHSA-v8vw-gw5j-w7m6: a protocol-relative+ // path like "//evil.com/" must not redirect off-host.+ name: "protocol-relative path should not redirect off-host",+ path: "//evil.com/",+ expectedStatus: http.StatusPermanentRedirect,+ expectedLocation: "/evil.com",+ expectRedirect: true,+ },+ {+ name: "path with multiple leading slashes should be collapsed",+ path: "///evil.com/foo/",+ expectedStatus: http.StatusPermanentRedirect,+ expectedLocation: "/evil.com/foo",+ expectRedirect: true,+ }, } for _, tt := range tests {AI-assisted change ec5206b40c2ccfb641312a48cd47668529ff618559d9f433d480c40f95da9794 · Fix d7a0ee5e15023faeb34bd1118f7f1e3eafa5a55a1f092020c6ec8658e2cc903a