Merge branch v2.11 into v3.6

This commit is contained in:
kevinpollet 2026-03-26 10:12:28 +01:00
commit 1b2e82453c
No known key found for this signature in database
GPG key ID: 0C9A5DDD1B292453
12 changed files with 230 additions and 27 deletions

View file

@ -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:

View file

@ -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)

2
go.mod
View file

@ -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

4
go.sum
View file

@ -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=

View file

@ -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}
}

View file

@ -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))

View file

@ -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}
}

View file

@ -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)
}

View file

@ -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

View file

@ -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 {

View file

@ -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

View file

@ -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.")
})
}
}