diff --git a/docs/content/migration/v2.md b/docs/content/migration/v2.md index cfffaadfd..a2106a5cd 100644 --- a/docs/content/migration/v2.md +++ b/docs/content/migration/v2.md @@ -674,3 +674,32 @@ it can lead to unsafe routing when the `sanitizePath` option is set to `false`. Setting the `sanitizePath` option to `false` is not safe. Ensure every request is properly url encoded instead. + +## v2.11.25 + +### Request Path Normalization + +Since `v2.11.25`, the request path is now normalized by decoding unreserved characters in the request path, +and also uppercasing the percent-encoded characters. +This follows [RFC 3986 percent-encoding normalization](https://datatracker.ietf.org/doc/html/rfc3986#section-6.2.2.2), +and [RFC 3986 case normalization](https://datatracker.ietf.org/doc/html/rfc3986#section-6.2.2.1). + +The normalization happens before the request path is sanitized, +and cannot be disabled. +This notably helps with encoded dots characters (which are unreserved characters) to be sanitized properly. + +### Routing Path + +Since `v2.11.25`, the reserved characters [(as per RFC 3986)](https://datatracker.ietf.org/doc/html/rfc3986#section-2.2) are kept encoded in the request path when matching the router rules. +Those characters, when decoded, change the meaning of the request path for routing purposes, +and Traefik now keeps them encoded to avoid any ambiguity. + +### Request Path Matching Examples + +| Request Path | Router Rule | Traefik v2.11.24 | Traefik v2.11.25 | +|-------------------|------------------------|------------------|------------------| +| `/foo%2Fbar` | PathPrefix(`/foo/bar`) | Match | No match | +| `/foo/../bar` | PathPrefix(`/foo`) | No match | No match | +| `/foo/../bar` | PathPrefix(`/bar`) | Match | Match | +| `/foo/%2E%2E/bar` | PathPrefix(`/foo`) | Match | No match | +| `/foo/%2E%2E/bar` | PathPrefix(`/bar`) | No match | Match | diff --git a/go.mod b/go.mod index fb7d17462..970fd883e 100644 --- a/go.mod +++ b/go.mod @@ -391,7 +391,7 @@ require ( // Containous forks replace ( github.com/abbot/go-http-auth => github.com/containous/go-http-auth v0.4.1-0.20200324110947-a37a7636d23e - github.com/gorilla/mux => github.com/containous/mux v0.0.0-20220627093034-b2dd784e613f + github.com/gorilla/mux => github.com/containous/mux v0.0.0-20250523120546-41b6ec3aed59 github.com/mailgun/minheap => github.com/containous/minheap v0.0.0-20190809180810-6e71eb837595 ) diff --git a/go.sum b/go.sum index 632f28cbb..3a0bcab1d 100644 --- a/go.sum +++ b/go.sum @@ -298,8 +298,8 @@ github.com/containous/go-http-auth v0.4.1-0.20200324110947-a37a7636d23e h1:D+uTE github.com/containous/go-http-auth v0.4.1-0.20200324110947-a37a7636d23e/go.mod h1:s8kLgBQolDbsJOPVIGCEEv9zGAKUUf/685Gi0Qqg8z8= github.com/containous/minheap v0.0.0-20190809180810-6e71eb837595 h1:aPspFRO6b94To3gl4yTDOEtpjFwXI7V2W+z0JcNljQ4= github.com/containous/minheap v0.0.0-20190809180810-6e71eb837595/go.mod h1:+lHFbEasIiQVGzhVDVw/cn0ZaOzde2OwNncp1NhXV4c= -github.com/containous/mux v0.0.0-20220627093034-b2dd784e613f h1:1uEtynq2C0ljy3630jt7EAxg8jZY2gy6YHdGwdqEpWw= -github.com/containous/mux v0.0.0-20220627093034-b2dd784e613f/go.mod h1:z8WW7n06n8/1xF9Jl9WmuDeZuHAhfL+bwarNjsciwwg= +github.com/containous/mux v0.0.0-20250523120546-41b6ec3aed59 h1:lJUOWjGohYjLKEfAz2nyI/dpzfKNPQLi5GLH7aaOZkw= +github.com/containous/mux v0.0.0-20250523120546-41b6ec3aed59/go.mod h1:z8WW7n06n8/1xF9Jl9WmuDeZuHAhfL+bwarNjsciwwg= github.com/coreos/bbolt v1.3.2/go.mod h1:iRUV2dpdMOn7Bo10OQBFzIJO9kkE559Wcmn+qkEiiKk= github.com/coreos/etcd v3.3.13+incompatible/go.mod h1:uF7uidLiAD3TWHmW31ZFd/JWoc32PjwdhPthX9715RE= github.com/coreos/go-oidc v2.1.0+incompatible/go.mod h1:CgnwVTmzoESiwO9qyAFEMiHoZ1nMCKZlZ9V6mm3/LKc= diff --git a/integration/simple_test.go b/integration/simple_test.go index b48e41081..316c78797 100644 --- a/integration/simple_test.go +++ b/integration/simple_test.go @@ -1429,6 +1429,18 @@ func (s *SimpleSuite) TestSanitizePath() { target: "127.0.0.1:8000", expected: http.StatusFound, }, + { + desc: "Implicit encoded dot dots call to the route with a middleware", + request: "GET /without/%2E%2E/with HTTP/1.1\r\nHost: other.localhost\r\n\r\n", + target: "127.0.0.1:8000", + expected: http.StatusFound, + }, + { + desc: "Implicit with encoded unreserved character call to the route with a middleware", + request: "GET /%77ith HTTP/1.1\r\nHost: other.localhost\r\n\r\n", + target: "127.0.0.1:8000", + expected: http.StatusFound, + }, { desc: "Explicit call to the route with a middleware, and disable path sanitization", request: "GET /with HTTP/1.1\r\nHost: other.localhost\r\n\r\n", diff --git a/pkg/muxer/http/mux.go b/pkg/muxer/http/mux.go index ca011b79c..db398216e 100644 --- a/pkg/muxer/http/mux.go +++ b/pkg/muxer/http/mux.go @@ -48,7 +48,7 @@ func NewMuxer() (*Muxer, error) { } return &Muxer{ - Router: mux.NewRouter().SkipClean(true), + Router: mux.NewRouter().UseRoutingPath().SkipClean(true), parser: parser, }, nil } diff --git a/pkg/server/server_entrypoint_tcp.go b/pkg/server/server_entrypoint_tcp.go index b3b42edb7..ea1f1975b 100644 --- a/pkg/server/server_entrypoint_tcp.go +++ b/pkg/server/server_entrypoint_tcp.go @@ -16,6 +16,7 @@ import ( "time" "github.com/containous/alice" + "github.com/gorilla/mux" "github.com/pires/go-proxyproto" "github.com/sirupsen/logrus" "github.com/traefik/traefik/v2/pkg/config/static" @@ -571,18 +572,6 @@ func createHTTPServer(ctx context.Context, ln net.Listener, configuration *stati return nil, err } - if configuration.HTTP.SanitizePath != nil && *configuration.HTTP.SanitizePath { - // sanitizePath is used to clean the URL path by removing /../, /./ and duplicate slash sequences, - // to make sure the path is interpreted by the backends as it is evaluated inside rule matchers. - handler = sanitizePath(handler) - } - - if configuration.HTTP.EncodeQuerySemicolons { - handler = encodeQuerySemicolons(handler) - } else { - handler = http.AllowQuerySemicolons(handler) - } - debugConnection := os.Getenv(debugConnectionEnv) != "" if debugConnection || (configuration.Transport != nil && (configuration.Transport.KeepAliveMaxTime > 0 || configuration.Transport.KeepAliveMaxRequests > 0)) { handler = newKeepAliveMiddleware(handler, configuration.Transport.KeepAliveMaxRequests, configuration.Transport.KeepAliveMaxTime) @@ -594,6 +583,22 @@ func createHTTPServer(ctx context.Context, ln net.Listener, configuration *stati }) } + if configuration.HTTP.EncodeQuerySemicolons { + handler = encodeQuerySemicolons(handler) + } else { + handler = http.AllowQuerySemicolons(handler) + } + + handler = routingPath(handler) + + // Note that the Path sanitization has to be done after the path normalization, + // hence the wrapping has to be done before the normalize path wrapping. + if configuration.HTTP.SanitizePath != nil && *configuration.HTTP.SanitizePath { + handler = sanitizePath(handler) + } + + handler = normalizePath(handler) + handler = denyFragment(handler) serverHTTP := &http.Server{ @@ -721,7 +726,7 @@ func denyFragment(h http.Handler) http.Handler { }) } -// sanitizePath removes the "..", "." and duplicate slash segments from the URL. +// sanitizePath removes the "..", "." and duplicate slash segments from the URL according to https://datatracker.ietf.org/doc/html/rfc3986#section-6.2.2.3. // It cleans the request URL Path and RawPath, and updates the request URI. func sanitizePath(h http.Handler) http.Handler { return http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) { @@ -737,3 +742,158 @@ func sanitizePath(h http.Handler) http.Handler { h.ServeHTTP(rw, r2) }) } + +// unreservedCharacters contains the mapping of the percent-encoded form to the ASCII form +// of the unreserved characters according to https://datatracker.ietf.org/doc/html/rfc3986#section-2.3. +var unreservedCharacters = map[string]rune{ + "%41": 'A', "%42": 'B', "%43": 'C', "%44": 'D', "%45": 'E', "%46": 'F', + "%47": 'G', "%48": 'H', "%49": 'I', "%4A": 'J', "%4B": 'K', "%4C": 'L', + "%4D": 'M', "%4E": 'N', "%4F": 'O', "%50": 'P', "%51": 'Q', "%52": 'R', + "%53": 'S', "%54": 'T', "%55": 'U', "%56": 'V', "%57": 'W', "%58": 'X', + "%59": 'Y', "%5A": 'Z', + + "%61": 'a', "%62": 'b', "%63": 'c', "%64": 'd', "%65": 'e', "%66": 'f', + "%67": 'g', "%68": 'h', "%69": 'i', "%6A": 'j', "%6B": 'k', "%6C": 'l', + "%6D": 'm', "%6E": 'n', "%6F": 'o', "%70": 'p', "%71": 'q', "%72": 'r', + "%73": 's', "%74": 't', "%75": 'u', "%76": 'v', "%77": 'w', "%78": 'x', + "%79": 'y', "%7A": 'z', + + "%30": '0', "%31": '1', "%32": '2', "%33": '3', "%34": '4', + "%35": '5', "%36": '6', "%37": '7', "%38": '8', "%39": '9', + + "%2D": '-', "%2E": '.', "%5F": '_', "%7E": '~', +} + +// normalizePath removes from the RawPath unreserved percent-encoded characters as they are equivalent to their non-encoded +// form according to https://datatracker.ietf.org/doc/html/rfc3986#section-2.3 and capitalizes percent-encoded characters +// according to https://datatracker.ietf.org/doc/html/rfc3986#section-6.2.2.1. +func normalizePath(h http.Handler) http.Handler { + return http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) { + rawPath := req.URL.RawPath + + // When the RawPath is empty the encoded form of the Path is equivalent to the original request Path. + // Thus, the normalization is not needed as no unreserved characters were encoded and the encoded version + // of Path obtained with URL.EscapedPath contains only percent-encoded characters in upper case. + if rawPath == "" { + h.ServeHTTP(rw, req) + return + } + + var normalizedRawPathBuilder strings.Builder + for i := 0; i < len(rawPath); i++ { + if rawPath[i] != '%' { + normalizedRawPathBuilder.WriteString(string(rawPath[i])) + continue + } + + // This should never happen as the standard library will reject requests containing invalid percent-encodings. + // This discards URLs with a percent character at the end. + if i+2 >= len(rawPath) { + rw.WriteHeader(http.StatusBadRequest) + return + } + + encodedCharacter := strings.ToUpper(rawPath[i : i+3]) + if r, unreserved := unreservedCharacters[encodedCharacter]; unreserved { + normalizedRawPathBuilder.WriteRune(r) + } else { + normalizedRawPathBuilder.WriteString(encodedCharacter) + } + + i += 2 + } + + normalizedRawPath := normalizedRawPathBuilder.String() + + // We do not have to alter the request URL as the original RawPath is already normalized. + if normalizedRawPath == rawPath { + h.ServeHTTP(rw, req) + return + } + + r2 := new(http.Request) + *r2 = *req + + // Decoding unreserved characters only alter the RAW version of the URL, + // as unreserved percent-encoded characters are equivalent to their non encoded form. + r2.URL.RawPath = normalizedRawPath + + // Because the reverse proxy director is building query params from RequestURI it needs to be updated as well. + r2.RequestURI = r2.URL.RequestURI() + + h.ServeHTTP(rw, r2) + }) +} + +// reservedCharacters contains the mapping of the percent-encoded form to the ASCII form +// of the reserved characters according to https://datatracker.ietf.org/doc/html/rfc3986#section-2.2. +// By extension to https://datatracker.ietf.org/doc/html/rfc3986#section-2.1 the percent character is also considered a reserved character. +// Because decoding the percent character would change the meaning of the URL. +var reservedCharacters = map[string]rune{ + "%3A": ':', + "%2F": '/', + "%3F": '?', + "%23": '#', + "%5B": '[', + "%5D": ']', + "%40": '@', + "%21": '!', + "%24": '$', + "%26": '&', + "%27": '\'', + "%28": '(', + "%29": ')', + "%2A": '*', + "%2B": '+', + "%2C": ',', + "%3B": ';', + "%3D": '=', + "%25": '%', +} + +// routingPath decodes non-allowed characters in the EscapedPath and stores it in the context to be able to use it for routing. +// This allows using the decoded version of the non-allowed characters in the routing rules for a better UX. +// For example, the rule PathPrefix(`/foo bar`) will match the following request path `/foo%20bar`. +func routingPath(h http.Handler) http.Handler { + return http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) { + escapedPath := req.URL.EscapedPath() + + var routingPathBuilder strings.Builder + for i := 0; i < len(escapedPath); i++ { + if escapedPath[i] != '%' { + routingPathBuilder.WriteString(string(escapedPath[i])) + continue + } + + // This should never happen as the standard library will reject requests containing invalid percent-encodings. + // This discards URLs with a percent character at the end. + if i+2 >= len(escapedPath) { + rw.WriteHeader(http.StatusBadRequest) + return + } + + encodedCharacter := escapedPath[i : i+3] + if _, reserved := reservedCharacters[encodedCharacter]; reserved { + routingPathBuilder.WriteString(encodedCharacter) + } else { + // This should never happen as the standard library will reject requests containing invalid percent-encodings. + decodedCharacter, err := url.PathUnescape(encodedCharacter) + if err != nil { + rw.WriteHeader(http.StatusBadRequest) + return + } + routingPathBuilder.WriteString(decodedCharacter) + } + + i += 2 + } + + h.ServeHTTP(rw, req.WithContext( + context.WithValue( + req.Context(), + mux.RoutingPathKey, + routingPathBuilder.String(), + ), + )) + }) +} diff --git a/pkg/server/server_entrypoint_tcp_test.go b/pkg/server/server_entrypoint_tcp_test.go index 98066a4ca..e007fc325 100644 --- a/pkg/server/server_entrypoint_tcp_test.go +++ b/pkg/server/server_entrypoint_tcp_test.go @@ -13,10 +13,12 @@ import ( "testing" "time" + "github.com/gorilla/mux" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ptypes "github.com/traefik/paerser/types" "github.com/traefik/traefik/v2/pkg/config/static" + "github.com/traefik/traefik/v2/pkg/middlewares/requestdecorator" tcprouter "github.com/traefik/traefik/v2/pkg/server/router/tcp" "github.com/traefik/traefik/v2/pkg/tcp" "golang.org/x/net/http2" @@ -424,3 +426,287 @@ func TestSanitizePath(t *testing.T) { }) } } + +func TestNormalizePath(t *testing.T) { + unreservedDecoded := "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~" + unreserved := []string{ + "%41", "%42", "%43", "%44", "%45", "%46", "%47", "%48", "%49", "%4A", "%4B", "%4C", "%4D", "%4E", "%4F", "%50", "%51", "%52", "%53", "%54", "%55", "%56", "%57", "%58", "%59", "%5A", + "%61", "%62", "%63", "%64", "%65", "%66", "%67", "%68", "%69", "%6A", "%6B", "%6C", "%6D", "%6E", "%6F", "%70", "%71", "%72", "%73", "%74", "%75", "%76", "%77", "%78", "%79", "%7A", + "%30", "%31", "%32", "%33", "%34", "%35", "%36", "%37", "%38", "%39", + "%2D", "%2E", "%5F", "%7E", + } + + reserved := []string{ + "%3A", "%2F", "%3F", "%23", "%5B", "%5D", "%40", "%21", "%24", "%26", "%27", "%28", "%29", "%2A", "%2B", "%2C", "%3B", "%3D", "%25", + } + reservedJoined := strings.Join(reserved, "") + + unallowedCharacter := "%0a" // line feed + unallowedCharacterUpperCased := "%0A" // line feed upper case + + var callCount int + handler := normalizePath(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + callCount++ + + wantRawPath := "/" + unreservedDecoded + reservedJoined + unallowedCharacterUpperCased + assert.Equal(t, wantRawPath, r.URL.RawPath) + })) + + req := httptest.NewRequest(http.MethodGet, "http://foo/"+strings.Join(unreserved, "")+reservedJoined+unallowedCharacter, http.NoBody) + res := httptest.NewRecorder() + + handler.ServeHTTP(res, req) + + assert.Equal(t, http.StatusOK, res.Code) + assert.Equal(t, 1, callCount) +} + +func TestNormalizePath_malformedPercentEncoding(t *testing.T) { + tests := []struct { + desc string + path string + wantErr bool + }{ + { + desc: "well formed path", + path: "/%20", + }, + { + desc: "percent sign at the end", + path: "/%", + wantErr: true, + }, + { + desc: "incomplete percent encoding at the end", + path: "/%f", + wantErr: true, + }, + } + + for _, test := range tests { + t.Run(test.desc, func(t *testing.T) { + t.Parallel() + + var callCount int + handler := normalizePath(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + callCount++ + })) + + req := httptest.NewRequest(http.MethodGet, "http://foo", http.NoBody) + req.URL.RawPath = test.path + + res := httptest.NewRecorder() + + handler.ServeHTTP(res, req) + + if test.wantErr { + assert.Equal(t, http.StatusBadRequest, res.Code) + assert.Equal(t, 0, callCount) + } else { + assert.Equal(t, http.StatusOK, res.Code) + assert.Equal(t, 1, callCount) + } + }) + } +} + +func TestRoutingPath(t *testing.T) { + tests := []struct { + desc string + path string + expRoutingPath string + expStatus int + }{ + { + desc: "unallowed percent-encoded character is decoded", + path: "/foo%20bar", + expRoutingPath: "/foo bar", + expStatus: http.StatusOK, + }, + { + desc: "reserved percent-encoded character is kept encoded", + path: "/foo%2Fbar", + expRoutingPath: "/foo%2Fbar", + expStatus: http.StatusOK, + }, + { + desc: "multiple mixed characters", + path: "/foo%20bar%2Fbaz%23qux", + expRoutingPath: "/foo bar%2Fbaz%23qux", + expStatus: http.StatusOK, + }, + } + + for _, test := range tests { + t.Run(test.desc, func(t *testing.T) { + t.Parallel() + + var gotRoute string + handler := routingPath(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotRoute, _ = r.Context().Value(mux.RoutingPathKey).(string) + w.WriteHeader(http.StatusOK) + })) + + req := httptest.NewRequest(http.MethodGet, "http://foo"+test.path, http.NoBody) + + res := httptest.NewRecorder() + + handler.ServeHTTP(res, req) + + assert.Equal(t, test.expStatus, res.Code) + assert.Equal(t, test.expRoutingPath, gotRoute) + }) + } +} + +// TestPathOperations tests the whole behavior of normalizePath, sanitizePath, and routingPath combined through the use of the createHTTPServer func. +// It aims to guarantee the server entrypoint handler is secure regarding a large variety of cases that could lead to path traversal attacks. +func TestPathOperations(t *testing.T) { + // Create a listener for the server. + ln, err := net.Listen("tcp", "127.0.0.1:0") + require.NoError(t, err) + t.Cleanup(func() { + _ = ln.Close() + }) + + // Define the server configuration. + configuration := &static.EntryPoint{} + configuration.SetDefaults() + + // Create the HTTP server using createHTTPServer. + server, err := createHTTPServer(context.Background(), ln, configuration, false, requestdecorator.New(nil)) + require.NoError(t, err) + + server.Switcher.UpdateHandler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Path", r.URL.Path) + w.Header().Set("RawPath", r.URL.EscapedPath()) + w.Header().Set("RoutingPath", r.Context().Value(mux.RoutingPathKey).(string)) + w.WriteHeader(http.StatusOK) + })) + + go func() { + // server is expected to return an error if the listener is closed. + _ = server.Server.Serve(ln) + }() + + client := http.Client{ + Transport: &http.Transport{ + ResponseHeaderTimeout: 1 * time.Second, + }, + } + + tests := []struct { + desc string + rawPath string + expectedPath string + expectedRaw string + expectedRoutingPath string + expectedStatus int + }{ + { + desc: "normalize and sanitize path", + rawPath: "/a/../b/%41%42%43//%2f/", + expectedPath: "/b/ABC///", + expectedRaw: "/b/ABC/%2F/", + expectedRoutingPath: "/b/ABC/%2F/", + expectedStatus: http.StatusOK, + }, + { + desc: "path with traversal attempt", + rawPath: "/../../b/", + expectedPath: "/b/", + expectedRaw: "/b/", + expectedRoutingPath: "/b/", + expectedStatus: http.StatusOK, + }, + { + desc: "path with multiple traversal attempts", + rawPath: "/a/../../b/../c/", + expectedPath: "/c/", + expectedRaw: "/c/", + expectedRoutingPath: "/c/", + expectedStatus: http.StatusOK, + }, + { + desc: "path with mixed traversal and valid segments", + rawPath: "/a/../b/./c/../d/", + expectedPath: "/b/d/", + expectedRaw: "/b/d/", + expectedRoutingPath: "/b/d/", + expectedStatus: http.StatusOK, + }, + { + desc: "path with trailing slash and traversal", + rawPath: "/a/b/../", + expectedPath: "/a/", + expectedRaw: "/a/", + expectedRoutingPath: "/a/", + expectedStatus: http.StatusOK, + }, + { + desc: "path with encoded traversal sequences", + rawPath: "/a/%2E%2E/%2E%2E/b/", + expectedPath: "/b/", + expectedRaw: "/b/", + expectedRoutingPath: "/b/", + expectedStatus: http.StatusOK, + }, + { + desc: "path with over-encoded traversal sequences", + rawPath: "/a/%252E%252E/%252E%252E/b/", + expectedPath: "/a/%2E%2E/%2E%2E/b/", + expectedRaw: "/a/%252E%252E/%252E%252E/b/", + expectedRoutingPath: "/a/%252E%252E/%252E%252E/b/", + expectedStatus: http.StatusOK, + }, + { + desc: "routing path with unallowed percent-encoded character", + rawPath: "/foo%20bar", + expectedPath: "/foo bar", + expectedRaw: "/foo%20bar", + expectedRoutingPath: "/foo bar", + expectedStatus: http.StatusOK, + }, + { + desc: "routing path with reserved percent-encoded character", + rawPath: "/foo%2Fbar", + expectedPath: "/foo/bar", + expectedRaw: "/foo%2Fbar", + expectedRoutingPath: "/foo%2Fbar", + expectedStatus: http.StatusOK, + }, + { + desc: "routing path with unallowed and reserved percent-encoded character", + rawPath: "/foo%20%2Fbar", + expectedPath: "/foo /bar", + expectedRaw: "/foo%20%2Fbar", + expectedRoutingPath: "/foo %2Fbar", + expectedStatus: http.StatusOK, + }, + { + desc: "path with traversal and encoded slash", + rawPath: "/a/..%2Fb/", + expectedPath: "/a/../b/", + expectedRaw: "/a/..%2Fb/", + expectedRoutingPath: "/a/..%2Fb/", + expectedStatus: http.StatusOK, + }, + } + + for _, test := range tests { + t.Run(test.desc, func(t *testing.T) { + t.Parallel() + + req, err := http.NewRequest(http.MethodGet, "http://"+ln.Addr().String()+test.rawPath, http.NoBody) + require.NoError(t, err) + + res, err := client.Do(req) + require.NoError(t, err) + + assert.Equal(t, test.expectedStatus, res.StatusCode) + assert.Equal(t, test.expectedPath, res.Header.Get("Path")) + assert.Equal(t, test.expectedRaw, res.Header.Get("RawPath")) + assert.Equal(t, test.expectedRoutingPath, res.Header.Get("RoutingPath")) + }) + } +}