diff --git a/.golangci.yml b/.golangci.yml index 6b94f9941d..5201820dd6 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -326,6 +326,10 @@ linters: text: 'SA1019: cfg.(SSLRedirect|SSLTemporaryRedirect|SSLHost|SSLForceHost|FeaturePolicy) is deprecated' - path: (.+)\.go$ text: 'SA1019: c.Providers.(ConsulCatalog|Consul|Nomad).Namespace is deprecated' + - path: pkg/middlewares/auth/basic_auth_test.go + text: 'SA1008: keys in http.Header are canonicalized, "x-user" is not canonical; fix the constant or use http.CanonicalHeaderKey' + - path: pkg/middlewares/auth/digest_auth_test.go + text: 'SA1008: keys in http.Header are canonicalized, "x-user" is not canonical; fix the constant or use http.CanonicalHeaderKey' - path: pkg/provider/kubernetes/crd/kubernetes.go text: "Function 'loadConfigurationFromCRD' has too many statements" linters: diff --git a/CHANGELOG.md b/CHANGELOG.md index d1c54358c5..113e5a0aa9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,14 @@ - Remove unsupported servers[n].address from TCP label examples ([#12817](https://github.com/traefik/traefik/pull/12817) @sheddy-traefik) - Bump mkdocs-traefiklabs to use consent mode ([#12804](https://github.com/traefik/traefik/pull/12804) @darkweaver87) +## [v2.11.42](https://github.com/traefik/traefik/tree/v2.11.42) (2026-03-26) +[All Commits](https://github.com/traefik/traefik/compare/v2.11.41...v2.11.42) + +**Bug fixes:** +- **[grpc]** Bump google.golang.org/grpc to v1.79.3 ([#12845](https://github.com/traefik/traefik/pull/12845) @mmatur) +- **[middleware, authentication]** Prevent duplicate user headers in basic and digest auth middleware ([#12851](https://github.com/traefik/traefik/pull/12851) @juliens) +- **[middleware]** Fix StripPrefix and StripPrefixRegex to slice the prefix using encoded prefix length ([#12863](https://github.com/traefik/traefik/pull/12863) @gndz07) + ## [v2.11.41](https://github.com/traefik/traefik/tree/v2.11.41) (2026-03-18) [All Commits](https://github.com/traefik/traefik/compare/v2.11.40...v2.11.41) diff --git a/go.mod b/go.mod index d75fbe7b12..becc90a96c 100644 --- a/go.mod +++ b/go.mod @@ -102,7 +102,7 @@ require ( golang.org/x/text v0.34.0 golang.org/x/time v0.14.0 golang.org/x/tools v0.41.0 - google.golang.org/grpc v1.79.1 + google.golang.org/grpc v1.79.3 gopkg.in/natefinch/lumberjack.v2 v2.2.1 gopkg.in/yaml.v3 v3.0.1 k8s.io/api v0.34.3 diff --git a/go.sum b/go.sum index 73bba37fd0..703e10109c 100644 --- a/go.sum +++ b/go.sum @@ -1931,8 +1931,8 @@ google.golang.org/grpc v1.33.2/go.mod h1:JMHMWHQWaTccqQQlmk3MJZS+GWXOdAesneDmEnv google.golang.org/grpc v1.36.0/go.mod h1:qjiiYl8FncCW8feJPdyg3v6XW24KsRHe+dy9BAGRRjU= google.golang.org/grpc v1.38.0/go.mod h1:NREThFqKR1f3iQ6oBuvc5LadQuXVGo9rkm5ZGrQdJfM= google.golang.org/grpc v1.40.0/go.mod h1:ogyxbiOoUXAkP+4+xa6PZSE9DZgIHtSpzjDTB9KAK34= -google.golang.org/grpc v1.79.1 h1:zGhSi45ODB9/p3VAawt9a+O/MULLl9dpizzNNpq7flY= -google.golang.org/grpc v1.79.1/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ= +google.golang.org/grpc v1.79.3 h1:sybAEdRIEtvcD68Gx7dmnwjZKlyfuc61Dyo9pGXXkKE= +google.golang.org/grpc v1.79.3/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ= google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8= google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0= google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM= diff --git a/pkg/middlewares/auth/basic_auth.go b/pkg/middlewares/auth/basic_auth.go index 18d7995e0e..a6b99b45e8 100644 --- a/pkg/middlewares/auth/basic_auth.go +++ b/pkg/middlewares/auth/basic_auth.go @@ -99,6 +99,8 @@ func (b *basicAuth) ServeHTTP(rw http.ResponseWriter, req *http.Request) { req.URL.User = url.User(user) if b.headerField != "" { + // TODO Deprecated we should add the header with canonical key. + req.Header.Del(b.headerField) req.Header[b.headerField] = []string{user} } diff --git a/pkg/middlewares/auth/basic_auth_test.go b/pkg/middlewares/auth/basic_auth_test.go index 8741ec8abd..83a56390e3 100644 --- a/pkg/middlewares/auth/basic_auth_test.go +++ b/pkg/middlewares/auth/basic_auth_test.go @@ -105,6 +105,30 @@ func TestBasicAuthUserHeader(t *testing.T) { assert.Equal(t, "traefik\n", string(body)) } +func TestBasicAuthUserHeaderCanonical(t *testing.T) { + var nextCalled bool + next := http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) { + nextCalled = true + assert.Empty(t, req.Header.Get("X-User")) + assert.Equal(t, []string{"test"}, req.Header["x-user"]) + }) + auth := dynamic.BasicAuth{ + Users: []string{"test:$apr1$H6uskkkW$IgXLP6ewTrSuBkTrqE8wj/"}, + HeaderField: "x-user", + } + m, err := NewBasic(t.Context(), next, auth, "test") + require.NoError(t, err) + + req := httptest.NewRequest(http.MethodGet, "http://localhost/", nil) + req.SetBasicAuth("test", "test") + req.Header.Set("X-User", "admin") + rw := httptest.NewRecorder() + m.ServeHTTP(rw, req) + + assert.Equal(t, http.StatusOK, rw.Result().StatusCode) + assert.True(t, nextCalled) +} + func TestBasicAuthHeaderRemoved(t *testing.T) { next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { assert.Empty(t, r.Header.Get(authorizationHeader)) diff --git a/pkg/middlewares/auth/digest_auth.go b/pkg/middlewares/auth/digest_auth.go index af64a134ba..02a4336cc2 100644 --- a/pkg/middlewares/auth/digest_auth.go +++ b/pkg/middlewares/auth/digest_auth.go @@ -97,6 +97,8 @@ func (d *digestAuth) ServeHTTP(rw http.ResponseWriter, req *http.Request) { } if d.headerField != "" { + // TODO Deprecated we should add the header with canonical key. + req.Header.Del(d.headerField) req.Header[d.headerField] = []string{username} } diff --git a/pkg/middlewares/auth/digest_auth_test.go b/pkg/middlewares/auth/digest_auth_test.go index b0f485b385..9389992df6 100644 --- a/pkg/middlewares/auth/digest_auth_test.go +++ b/pkg/middlewares/auth/digest_auth_test.go @@ -151,3 +151,30 @@ func TestDigestAuthUsersFromFile(t *testing.T) { }) } } + +func TestDigestAuthUserHeaderCanonical(t *testing.T) { + var nextCalled bool + next := http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) { + nextCalled = true + assert.Empty(t, req.Header.Get("X-User")) + assert.Equal(t, []string{"test"}, req.Header["x-user"]) + }) + auth := dynamic.DigestAuth{ + Users: []string{"test:traefik:a2688e031edb4be6a3797f3882655c05"}, + HeaderField: "x-user", + } + m, err := NewDigest(t.Context(), next, auth, "test") + require.NoError(t, err) + + srv := httptest.NewServer(m) + t.Cleanup(srv.Close) + + req := testhelpers.MustNewRequest(http.MethodGet, srv.URL, nil) + req.Header.Set("X-User", "admin") + digestReq := newDigestRequest("test", "test", http.DefaultClient) + res, err := digestReq.Do(req) + require.NoError(t, err) + + assert.Equal(t, http.StatusOK, res.StatusCode) + assert.True(t, nextCalled) +} diff --git a/pkg/middlewares/stripprefix/strip_prefix.go b/pkg/middlewares/stripprefix/strip_prefix.go index 1632814ce9..f4cd8e9d7b 100644 --- a/pkg/middlewares/stripprefix/strip_prefix.go +++ b/pkg/middlewares/stripprefix/strip_prefix.go @@ -51,9 +51,9 @@ func (s *stripPrefix) GetTracingInformation() (string, string) { func (s *stripPrefix) ServeHTTP(rw http.ResponseWriter, req *http.Request) { for _, prefix := range s.prefixes { if strings.HasPrefix(req.URL.Path, prefix) { - req.URL.Path = s.getPrefixStripped(req.URL.Path, prefix) + req.URL.Path = s.getPathStripped(req.URL.Path, prefix) if req.URL.RawPath != "" { - req.URL.RawPath = s.getPrefixStripped(req.URL.RawPath, prefix) + req.URL.RawPath = s.getRawPathStripped(req.URL.RawPath, prefix) } s.serveRequest(rw, req, strings.TrimSpace(prefix)) return @@ -68,7 +68,7 @@ func (s *stripPrefix) serveRequest(rw http.ResponseWriter, req *http.Request, pr s.next.ServeHTTP(rw, req) } -func (s *stripPrefix) getPrefixStripped(urlPath, prefix string) string { +func (s *stripPrefix) getPathStripped(urlPath, prefix string) string { if s.forceSlash { // Only for compatibility reason with the previous behavior, // but the previous behavior is wrong. @@ -79,6 +79,33 @@ func (s *stripPrefix) getPrefixStripped(urlPath, prefix string) string { return ensureLeadingSlash(strings.TrimPrefix(urlPath, prefix)) } +func (s *stripPrefix) getRawPathStripped(rawPath, prefix string) string { + if s.forceSlash { + // Only for compatibility reason with the previous behavior, + // but the previous behavior is wrong. + // This needs to be removed in the next breaking version. + return "/" + strings.TrimPrefix(rawPath[encodedPrefixLen(rawPath, prefix):], "/") + } + + return ensureLeadingSlash(rawPath[encodedPrefixLen(rawPath, prefix):]) +} + +// encodedPrefixLen returns the number of bytes in rawPath that correspond to +// the decoded prefix, advancing 3 bytes per %XX sequence and 1 byte otherwise. +func encodedPrefixLen(rawPath, decodedPrefix string) int { + decoded := 0 + i := 0 + for i < len(rawPath) && decoded < len(decodedPrefix) { + if rawPath[i] == '%' && i+2 < len(rawPath) { + i += 3 + } else { + i++ + } + decoded++ + } + return i +} + func ensureLeadingSlash(str string) string { if str == "" { return str diff --git a/pkg/middlewares/stripprefix/strip_prefix_test.go b/pkg/middlewares/stripprefix/strip_prefix_test.go index 50cee8e289..6615b32e32 100644 --- a/pkg/middlewares/stripprefix/strip_prefix_test.go +++ b/pkg/middlewares/stripprefix/strip_prefix_test.go @@ -130,6 +130,17 @@ func TestStripPrefix(t *testing.T) { expectedRawPath: "/a%2Fb", expectedHeader: "/stat", }, + { + desc: "encoded char in prefix segment of raw path", + config: dynamic.StripPrefix{ + Prefixes: []string{"/api/"}, + }, + path: "/ap%69/a%2Fb", + expectedStatusCode: http.StatusOK, + expectedPath: "/a/b", + expectedRawPath: "/a%2Fb", + expectedHeader: "/api/", + }, } for _, test := range testCases { diff --git a/pkg/middlewares/stripprefixregex/strip_prefix_regex.go b/pkg/middlewares/stripprefixregex/strip_prefix_regex.go index a38752659c..15e34c3538 100644 --- a/pkg/middlewares/stripprefixregex/strip_prefix_regex.go +++ b/pkg/middlewares/stripprefixregex/strip_prefix_regex.go @@ -59,7 +59,7 @@ func (s *stripPrefixRegex) ServeHTTP(rw http.ResponseWriter, req *http.Request) req.URL.Path = ensureLeadingSlash(strings.Replace(req.URL.Path, prefix, "", 1)) if req.URL.RawPath != "" { - req.URL.RawPath = ensureLeadingSlash(req.URL.RawPath[len(prefix):]) + req.URL.RawPath = ensureLeadingSlash(req.URL.RawPath[encodedPrefixLen(req.URL.RawPath, prefix):]) } req.RequestURI = req.URL.RequestURI() @@ -71,6 +71,22 @@ func (s *stripPrefixRegex) ServeHTTP(rw http.ResponseWriter, req *http.Request) s.next.ServeHTTP(rw, req) } +// encodedPrefixLen returns the number of bytes in rawPath that correspond to +// the decoded prefix, advancing 3 bytes per %XX sequence and 1 byte otherwise. +func encodedPrefixLen(rawPath, decodedPrefix string) int { + decoded := 0 + i := 0 + for i < len(rawPath) && decoded < len(decodedPrefix) { + if rawPath[i] == '%' && i+2 < len(rawPath) { + i += 3 + } else { + i++ + } + decoded++ + } + return i +} + func ensureLeadingSlash(str string) string { if str == "" { return str diff --git a/pkg/middlewares/stripprefixregex/strip_prefix_regex_test.go b/pkg/middlewares/stripprefixregex/strip_prefix_regex_test.go index 7b7e3092e1..472670e3e0 100644 --- a/pkg/middlewares/stripprefixregex/strip_prefix_regex_test.go +++ b/pkg/middlewares/stripprefixregex/strip_prefix_regex_test.go @@ -13,111 +13,204 @@ import ( ) func TestStripPrefixRegex(t *testing.T) { - testPrefixRegex := dynamic.StripPrefixRegex{ - Regex: []string{"/a/api/", "/b/([a-z0-9]+)/", "/c/[a-z0-9]+/[0-9]+/"}, - } - testCases := []struct { + desc string + config dynamic.StripPrefixRegex path string expectedStatusCode int expectedPath string expectedRawPath string + expectedRequestURI string expectedHeader string }{ { + desc: "/a/test", + config: dynamic.StripPrefixRegex{Regex: []string{"/a/api/"}}, path: "/a/test", expectedStatusCode: http.StatusOK, expectedPath: "/a/test", }, { + desc: "/a/test/", + config: dynamic.StripPrefixRegex{Regex: []string{"/a/api/"}}, path: "/a/test/", expectedStatusCode: http.StatusOK, expectedPath: "/a/test/", }, { + desc: "/a/api/", + config: dynamic.StripPrefixRegex{Regex: []string{"/a/api/"}}, path: "/a/api/", expectedStatusCode: http.StatusOK, + // ensureLeadingSlash do not add a slash when the path is empty. expectedPath: "", + expectedRequestURI: "/", expectedHeader: "/a/api/", }, { + desc: "/a/api/test", + config: dynamic.StripPrefixRegex{Regex: []string{"/a/api/"}}, path: "/a/api/test", expectedStatusCode: http.StatusOK, expectedPath: "/test", + expectedRequestURI: "/test", expectedHeader: "/a/api/", }, { + desc: "/a/api/test/", + config: dynamic.StripPrefixRegex{Regex: []string{"/a/api/"}}, path: "/a/api/test/", expectedStatusCode: http.StatusOK, expectedPath: "/test/", + expectedRequestURI: "/test/", expectedHeader: "/a/api/", }, { + desc: "/b/api/", + config: dynamic.StripPrefixRegex{Regex: []string{"/b/([a-z0-9]+)/"}}, path: "/b/api/", expectedStatusCode: http.StatusOK, expectedPath: "", + expectedRequestURI: "/", expectedHeader: "/b/api/", }, { + desc: "/b/api", + config: dynamic.StripPrefixRegex{Regex: []string{"/b/([a-z0-9]+)/"}}, path: "/b/api", expectedStatusCode: http.StatusOK, expectedPath: "/b/api", + // When the path do not match, the requestURI is not computed. + expectedRequestURI: "", }, { + desc: "/b/api/test1", + config: dynamic.StripPrefixRegex{Regex: []string{"/b/([a-z0-9]+)/"}}, path: "/b/api/test1", expectedStatusCode: http.StatusOK, expectedPath: "/test1", + expectedRequestURI: "/test1", expectedHeader: "/b/api/", }, { + desc: "/b/api2/test2", + config: dynamic.StripPrefixRegex{Regex: []string{"/b/([a-z0-9]+)/"}}, path: "/b/api2/test2", expectedStatusCode: http.StatusOK, expectedPath: "/test2", + expectedRequestURI: "/test2", expectedHeader: "/b/api2/", }, { + desc: "/c/api/123/", + config: dynamic.StripPrefixRegex{Regex: []string{"/c/[a-z0-9]+/[0-9]+/"}}, path: "/c/api/123/", expectedStatusCode: http.StatusOK, expectedPath: "", + expectedRequestURI: "/", expectedHeader: "/c/api/123/", }, { + desc: "/c/api/123", + config: dynamic.StripPrefixRegex{Regex: []string{"/c/[a-z0-9]+/[0-9]+/"}}, path: "/c/api/123", expectedStatusCode: http.StatusOK, expectedPath: "/c/api/123", + // When the path do not match, the requestURI is not computed. + expectedRequestURI: "", }, { + desc: "/c/api/123/test3", + config: dynamic.StripPrefixRegex{Regex: []string{"/c/[a-z0-9]+/[0-9]+/"}}, path: "/c/api/123/test3", expectedStatusCode: http.StatusOK, expectedPath: "/test3", + expectedRequestURI: "/test3", expectedHeader: "/c/api/123/", }, { + desc: "/c/api/abc/test4", + config: dynamic.StripPrefixRegex{Regex: []string{"/c/[a-z0-9]+/[0-9]+/"}}, path: "/c/api/abc/test4", expectedStatusCode: http.StatusOK, expectedPath: "/c/api/abc/test4", + // When the path do not match, the requestURI is not computed. + expectedRequestURI: "", }, { + desc: "/a/api/a2Fb", + config: dynamic.StripPrefixRegex{Regex: []string{"/a/api/"}}, path: "/a/api/a%2Fb", expectedStatusCode: http.StatusOK, expectedPath: "/a/b", expectedRawPath: "/a%2Fb", + expectedRequestURI: "/a%2Fb", expectedHeader: "/a/api/", }, + { + desc: "/b/ap69/test", + config: dynamic.StripPrefixRegex{Regex: []string{"/b/([a-z0-9]+)/"}}, + path: "/b/ap%69/test", + expectedStatusCode: http.StatusOK, + expectedPath: "/test", + expectedRawPath: "/test", + expectedRequestURI: "/test", + expectedHeader: "/b/api/", + }, + { + desc: "/b/ap69/a2Fb", + config: dynamic.StripPrefixRegex{Regex: []string{"/b/([a-z0-9]+)/"}}, + path: "/b/ap%69/a%2Fb", + expectedStatusCode: http.StatusOK, + expectedPath: "/a/b", + expectedRawPath: "/a%2Fb", + expectedRequestURI: "/a%2Fb", + expectedHeader: "/b/api/", + }, + { + desc: "/t2F/test/foo", + config: dynamic.StripPrefixRegex{Regex: []string{"/t /test"}}, + path: "/t%2F/test/foo", + expectedStatusCode: http.StatusOK, + expectedPath: "/t//test/foo", + expectedRawPath: "/t%2F/test/foo", + // When the path do not match, the requestURI is not computed. + expectedRequestURI: "", + }, + { + desc: "/t /test/a2Fb", + config: dynamic.StripPrefixRegex{Regex: []string{"/t /test"}}, + path: "/t /test/a%2Fb", + expectedStatusCode: http.StatusOK, + expectedPath: "/a/b", + expectedRawPath: "/a%2Fb", + expectedRequestURI: "/a%2Fb", + expectedHeader: "/t /test", + }, + { + desc: "/t20/test/a2Fb", + config: dynamic.StripPrefixRegex{Regex: []string{"/t /test"}}, + path: "/t%20/test/a%2Fb", + expectedStatusCode: http.StatusOK, + expectedPath: "/a/b", + expectedRawPath: "/a%2Fb", + expectedRequestURI: "/a%2Fb", + expectedHeader: "/t /test", + }, } for _, test := range testCases { - t.Run(test.path, func(t *testing.T) { + t.Run(test.desc, func(t *testing.T) { t.Parallel() - var actualPath, actualRawPath, actualHeader, requestURI string + var actualPath, actualRawPath, actualHeader, actualRequestURI string handlerPath := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { actualPath = r.URL.Path actualRawPath = r.URL.RawPath actualHeader = r.Header.Get(stripprefix.ForwardedPrefixHeader) - requestURI = r.RequestURI + actualRequestURI = r.RequestURI }) - handler, err := New(t.Context(), handlerPath, testPrefixRegex, "foo-strip-prefix-regex") + handler, err := New(t.Context(), handlerPath, test.config, "foo-strip-prefix-regex") require.NoError(t, err) req := testhelpers.MustNewRequest(http.MethodGet, "http://localhost"+test.path, nil) @@ -129,18 +222,7 @@ func TestStripPrefixRegex(t *testing.T) { assert.Equal(t, test.expectedPath, actualPath, "Unexpected path.") assert.Equal(t, test.expectedRawPath, actualRawPath, "Unexpected raw path.") assert.Equal(t, test.expectedHeader, actualHeader, "Unexpected '%s' header.", stripprefix.ForwardedPrefixHeader) - - if test.expectedPath != test.path { - expectedRequestURI := test.expectedPath - if test.expectedRawPath != "" { - // go HTTP uses the raw path when existent in the RequestURI - expectedRequestURI = test.expectedRawPath - } - if test.expectedPath == "" { - expectedRequestURI = "/" - } - assert.Equal(t, expectedRequestURI, requestURI, "Unexpected request URI.") - } + assert.Equal(t, test.expectedRequestURI, actualRequestURI, "Unexpected request uri.") }) } }