From 09e81e6df6aa51c04a817b35c414f004e10ab3ed Mon Sep 17 00:00:00 2001 From: Umputun Date: Thu, 20 Aug 2026 18:52:24 -0500 Subject: [PATCH] fix: match routes on canonical escaped paths --- app/proxy/proxy.go | 4 ++- app/proxy/proxy_test.go | 70 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 73 insertions(+), 1 deletion(-) diff --git a/app/proxy/proxy.go b/app/proxy/proxy.go index 3d45ebe9..3e44cb35 100644 --- a/app/proxy/proxy.go +++ b/app/proxy/proxy.go @@ -359,7 +359,9 @@ func (h *Http) matchHandler(next http.Handler) http.Handler { if server == "" { server = strings.Split(r.Host, ":")[0] // drop port } - matches := h.Match(server, r.URL.EscapedPath()) // get all matches for the server:path pair + // normalize from decoded Path so alternate encodings cannot bypass route auth or IP policies + canonicalPath := (&url.URL{Path: r.URL.Path}).EscapedPath() + matches := h.Match(server, canonicalPath) // get all matches for the server:path pair match, ok := getMatch(matches, h.LBSelector) if !ok { next.ServeHTTP(w, r) diff --git a/app/proxy/proxy_test.go b/app/proxy/proxy_test.go index b966e4b1..ae035578 100644 --- a/app/proxy/proxy_test.go +++ b/app/proxy/proxy_test.go @@ -1348,6 +1348,76 @@ func TestHttp_withPerRouteAuth(t *testing.T) { }) } +func TestHttp_EncodedPathRoutePolicy(t *testing.T) { + port, releasePort := getFreePort(t) + authHash := "$2y$05$zMxDmK65SjcH2vJQNopVSO/nE8ngVLx65RoETyHpez7yTS/8CLEiW" // passwd + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _, _ = fmt.Fprint(w, r.URL.EscapedPath()) + })) + defer upstream.Close() + + p := &mockAuthProvider{mappers: []discovery.URLMapper{ + { + Server: "*", SrcMatch: *regexp.MustCompile("^/api/private/(.*)"), Dst: upstream.URL + "/private/$1", + MatchType: discovery.MTProxy, AuthUsers: []string{"test:" + authHash}, + }, + { + Server: "*", SrcMatch: *regexp.MustCompile("^/api/remote/(.*)"), Dst: upstream.URL + "/remote/$1", + MatchType: discovery.MTProxy, OnlyFromIPs: []string{"192.0.2.1"}, + }, + { + Server: "*", SrcMatch: *regexp.MustCompile("^/api/(.*)"), Dst: upstream.URL + "/$1", + MatchType: discovery.MTProxy, + }, + }} + + svc := discovery.NewService([]discovery.Provider{p}, 10*time.Millisecond) + go func() { _ = svc.Run(ctx) }() + require.Eventually(t, func() bool { return len(svc.Mappers()) == 3 }, time.Second, 10*time.Millisecond) + + h := Http{ + Timeouts: Timeouts{ResponseHeader: 200 * time.Millisecond}, Address: fmt.Sprintf("127.0.0.1:%d", port), + AccessLog: io.Discard, Reporter: &ErrorReporter{Nice: true}, Matcher: svc, + Metrics: mgmt.NewMetrics(mgmt.MetricsConfig{}), OnlyFrom: NewOnlyFrom(OFRemoteAddr), + } + releasePort() + go func() { _ = h.Run(ctx) }() + waitForServer(t, fmt.Sprintf("127.0.0.1:%d", port)) + + tests := []struct { + name string + path string + wantStatus int + wantBody string + }{ + {name: "encoded slash uppercase uses auth route", path: "/api/private%2Fsecret", wantStatus: http.StatusUnauthorized}, + {name: "encoded slash lowercase uses auth route", path: "/api/private%2fsecret", wantStatus: http.StatusUnauthorized}, + {name: "encoded unreserved uses auth route", path: "/api/%70rivate/secret", wantStatus: http.StatusUnauthorized}, + {name: "encoded slash uses remote allow-list route", path: "/api/remote%2Fsecret", wantStatus: http.StatusForbidden}, + {name: "space and percent pass through", path: "/api/te%20st%25value", wantStatus: http.StatusOK, wantBody: "/te%20st%25value"}, + } + + client := http.Client{Timeout: time.Second} + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + req, err := http.NewRequest(http.MethodGet, fmt.Sprintf("http://127.0.0.1:%d%s", port, tt.path), http.NoBody) + require.NoError(t, err) + resp, err := client.Do(req) + require.NoError(t, err) + defer resp.Body.Close() + assert.Equal(t, tt.wantStatus, resp.StatusCode) + if tt.wantBody != "" { + body, readErr := io.ReadAll(resp.Body) + require.NoError(t, readErr) + assert.Equal(t, tt.wantBody, string(body)) + } + }) + } +} + func TestHttp_withGlobalAndPerRouteAuth(t *testing.T) { port, releasePort := getFreePort(t) globalHash := "$2y$05$zMxDmK65SjcH2vJQNopVSO/nE8ngVLx65RoETyHpez7yTS/8CLEiW" // passwd