Merge branch v3.3 into v3.4

This commit is contained in:
kevinpollet 2025-04-18 11:38:04 +02:00
commit 9c1902c62e
No known key found for this signature in database
GPG key ID: 0C9A5DDD1B292453
54 changed files with 1060 additions and 636 deletions

View file

@ -68,11 +68,14 @@ type HTTPConfig struct {
Middlewares []string `description:"Default middlewares for the routers linked to the entry point." json:"middlewares,omitempty" toml:"middlewares,omitempty" yaml:"middlewares,omitempty" export:"true"`
TLS *TLSConfig `description:"Default TLS configuration for the routers linked to the entry point." json:"tls,omitempty" toml:"tls,omitempty" yaml:"tls,omitempty" label:"allowEmpty" file:"allowEmpty" export:"true"`
EncodeQuerySemicolons bool `description:"Defines whether request query semicolons should be URLEncoded." json:"encodeQuerySemicolons,omitempty" toml:"encodeQuerySemicolons,omitempty" yaml:"encodeQuerySemicolons,omitempty"`
SanitizePath *bool `description:"Defines whether to enable request path sanitization (removal of /./, /../ and multiple slash sequences)." json:"sanitizePath,omitempty" toml:"sanitizePath,omitempty" yaml:"sanitizePath,omitempty" export:"true"`
MaxHeaderBytes int `description:"Maximum size of request headers in bytes." json:"maxHeaderBytes,omitempty" toml:"maxHeaderBytes,omitempty" yaml:"maxHeaderBytes,omitempty" export:"true"`
}
// SetDefaults sets the default values.
func (c *HTTPConfig) SetDefaults() {
sanitizePath := true
c.SanitizePath = &sanitizePath
c.MaxHeaderBytes = http.DefaultMaxHeaderBytes
}

View file

@ -7,6 +7,8 @@ import (
"github.com/traefik/traefik/v3/pkg/provider/acme"
)
func pointer[T any](v T) *T { return &v }
func TestHasEntrypoint(t *testing.T) {
tests := []struct {
desc string
@ -68,6 +70,7 @@ func TestConfiguration_SetEffectiveConfiguration(t *testing.T) {
ProxyProtocol: nil,
ForwardedHeaders: &ForwardedHeaders{},
HTTP: HTTPConfig{
SanitizePath: pointer(true),
MaxHeaderBytes: 1048576,
},
HTTP2: &HTTP2Config{
@ -113,6 +116,7 @@ func TestConfiguration_SetEffectiveConfiguration(t *testing.T) {
ProxyProtocol: nil,
ForwardedHeaders: &ForwardedHeaders{},
HTTP: HTTPConfig{
SanitizePath: pointer(true),
MaxHeaderBytes: 1048576,
},
HTTP2: &HTTP2Config{
@ -169,6 +173,7 @@ func TestConfiguration_SetEffectiveConfiguration(t *testing.T) {
ProxyProtocol: nil,
ForwardedHeaders: &ForwardedHeaders{},
HTTP: HTTPConfig{
SanitizePath: pointer(true),
MaxHeaderBytes: 1048576,
},
HTTP2: &HTTP2Config{
@ -229,6 +234,7 @@ func TestConfiguration_SetEffectiveConfiguration(t *testing.T) {
ProxyProtocol: nil,
ForwardedHeaders: &ForwardedHeaders{},
HTTP: HTTPConfig{
SanitizePath: pointer(true),
MaxHeaderBytes: 1048576,
},
HTTP2: &HTTP2Config{

View file

@ -386,9 +386,10 @@ func (h *Handler) logTheRoundTrip(ctx context.Context, logDataTable *LogData) {
func (h *Handler) redactHeaders(headers http.Header, fields logrus.Fields, prefix string) {
for k := range headers {
v := h.config.Fields.KeepHeader(k)
if v == types.AccessLogKeep {
switch v {
case types.AccessLogKeep:
fields[prefix+k] = strings.Join(headers.Values(k), ",")
} else if v == types.AccessLogRedact {
case types.AccessLogRedact:
fields[prefix+k] = "REDACTED"
}
}

View file

@ -346,7 +346,7 @@ func assertNotEmpty() func(t *testing.T, actual interface{}) {
return func(t *testing.T, actual interface{}) {
t.Helper()
assert.NotEqual(t, "", actual)
assert.NotEmpty(t, actual)
}
}
@ -590,7 +590,7 @@ func TestLoggerJSON(t *testing.T) {
err = json.Unmarshal(logData, &jsonData)
require.NoError(t, err)
assert.Equal(t, len(test.expected), len(jsonData))
assert.Len(t, jsonData, len(test.expected))
for field, assertion := range test.expected {
assertion(t, jsonData[field])
@ -649,7 +649,7 @@ func TestLogger_AbortedRequest(t *testing.T) {
err = json.Unmarshal(logData, &jsonData)
require.NoError(t, err)
assert.Equal(t, len(expected), len(jsonData))
assert.Len(t, jsonData, len(expected))
for field, assertion := range expected {
assertion(t, jsonData[field])
@ -880,7 +880,7 @@ func assertValidLogData(t *testing.T, expected string, logData []byte) {
formatErrMessage := fmt.Sprintf("Expected:\t%q\nActual:\t%q", expected, string(logData))
require.Equal(t, len(resultExpected), len(result), formatErrMessage)
require.Len(t, result, len(resultExpected), formatErrMessage)
assert.Equal(t, resultExpected[ClientHost], result[ClientHost], formatErrMessage)
assert.Equal(t, resultExpected[ClientUsername], result[ClientUsername], formatErrMessage)
assert.Equal(t, resultExpected[RequestMethod], result[RequestMethod], formatErrMessage)

View file

@ -65,7 +65,7 @@ func TestParseAccessLog(t *testing.T) {
result, err := ParseAccessLog(test.value)
assert.NoError(t, err)
assert.Equal(t, len(test.expected), len(result))
assert.Len(t, result, len(test.expected))
for key, value := range test.expected {
assert.Equal(t, value, result[key])
}

View file

@ -342,7 +342,7 @@ func TestForwardAuthRemoveHopByHopHeaders(t *testing.T) {
assert.Equal(t, http.StatusFound, res.StatusCode, "they should be equal")
for _, header := range forward.HopHeaders {
assert.Equal(t, "", res.Header.Get(header), "hop-by-hop header '%s' mustn't be set", header)
assert.Empty(t, res.Header.Get(header), "hop-by-hop header '%s' mustn't be set", header)
}
location, err := res.Location()

View file

@ -176,7 +176,7 @@ func TestShouldNotCompressWhenContentEncodingHeader(t *testing.T) {
assert.Equal(t, gzipName, rw.Header().Get(contentEncodingHeader))
assert.Equal(t, acceptEncodingHeader, rw.Header().Get(varyHeader))
assert.EqualValues(t, rw.Body.Bytes(), fakeCompressedBody)
assert.Equal(t, rw.Body.Bytes(), fakeCompressedBody)
}
func TestShouldNotCompressWhenNoAcceptEncodingHeader(t *testing.T) {
@ -197,7 +197,7 @@ func TestShouldNotCompressWhenNoAcceptEncodingHeader(t *testing.T) {
assert.Empty(t, rw.Header().Get(contentEncodingHeader))
assert.Empty(t, rw.Header().Get(varyHeader))
assert.EqualValues(t, rw.Body.Bytes(), fakeBody)
assert.Equal(t, rw.Body.Bytes(), fakeBody)
}
func TestEmptyAcceptEncoding(t *testing.T) {
@ -219,7 +219,7 @@ func TestEmptyAcceptEncoding(t *testing.T) {
assert.Empty(t, rw.Header().Get(contentEncodingHeader))
assert.Empty(t, rw.Header().Get(varyHeader))
assert.EqualValues(t, rw.Body.Bytes(), fakeBody)
assert.Equal(t, rw.Body.Bytes(), fakeBody)
}
func TestShouldNotCompressWhenIdentityAcceptEncodingHeader(t *testing.T) {
@ -246,7 +246,7 @@ func TestShouldNotCompressWhenIdentityAcceptEncodingHeader(t *testing.T) {
assert.Empty(t, rw.Header().Get(contentEncodingHeader))
assert.Empty(t, rw.Header().Get(varyHeader))
assert.EqualValues(t, rw.Body.Bytes(), fakeBody)
assert.Equal(t, rw.Body.Bytes(), fakeBody)
}
func TestShouldNotCompressWhenEmptyAcceptEncodingHeader(t *testing.T) {
@ -273,7 +273,7 @@ func TestShouldNotCompressWhenEmptyAcceptEncodingHeader(t *testing.T) {
assert.Empty(t, rw.Header().Get(contentEncodingHeader))
assert.Empty(t, rw.Header().Get(varyHeader))
assert.EqualValues(t, rw.Body.Bytes(), fakeBody)
assert.Equal(t, rw.Body.Bytes(), fakeBody)
}
func TestShouldNotCompressHeadRequest(t *testing.T) {
@ -295,7 +295,7 @@ func TestShouldNotCompressHeadRequest(t *testing.T) {
assert.Empty(t, rw.Header().Get(contentEncodingHeader))
assert.Empty(t, rw.Header().Get(varyHeader))
assert.EqualValues(t, rw.Body.Bytes(), fakeBody)
assert.Equal(t, rw.Body.Bytes(), fakeBody)
}
func TestShouldNotCompressWhenSpecificContentType(t *testing.T) {
@ -385,7 +385,7 @@ func TestShouldNotCompressWhenSpecificContentType(t *testing.T) {
assert.Empty(t, rw.Header().Get(acceptEncodingHeader))
assert.Empty(t, rw.Header().Get(contentEncodingHeader))
assert.EqualValues(t, rw.Body.Bytes(), baseBody)
assert.Equal(t, rw.Body.Bytes(), baseBody)
})
}
}
@ -431,7 +431,7 @@ func TestShouldCompressWhenSpecificContentType(t *testing.T) {
assert.Equal(t, gzipName, rw.Header().Get(contentEncodingHeader))
assert.Equal(t, acceptEncodingHeader, rw.Header().Get(varyHeader))
assert.NotEqualValues(t, rw.Body.Bytes(), baseBody)
assert.NotEqual(t, rw.Body.Bytes(), baseBody)
})
}
}
@ -492,7 +492,7 @@ func TestIntegrationShouldNotCompress(t *testing.T) {
body, err := io.ReadAll(resp.Body)
require.NoError(t, err)
assert.EqualValues(t, fakeCompressedBody, body)
assert.Equal(t, fakeCompressedBody, body)
})
}
}
@ -627,12 +627,12 @@ func TestMinResponseBodyBytes(t *testing.T) {
if test.expectedCompression {
assert.Equal(t, gzipName, rw.Header().Get(contentEncodingHeader))
assert.NotEqualValues(t, rw.Body.Bytes(), fakeBody)
assert.NotEqual(t, rw.Body.Bytes(), fakeBody)
return
}
assert.Empty(t, rw.Header().Get(contentEncodingHeader))
assert.EqualValues(t, rw.Body.Bytes(), fakeBody)
assert.Equal(t, rw.Body.Bytes(), fakeBody)
})
}
}
@ -738,7 +738,7 @@ func Test1xxResponses(t *testing.T) {
assert.Equal(t, test.encoding, res.Header.Get(contentEncodingHeader))
body, _ := io.ReadAll(res.Body)
assert.NotEqualValues(t, body, fakeBody)
assert.NotEqual(t, body, fakeBody)
})
}
}

View file

@ -49,6 +49,7 @@ func NewHeader(next http.Handler, cfg dynamic.Headers) (*Header, error) {
func (s *Header) ServeHTTP(rw http.ResponseWriter, req *http.Request) {
// Handle Cors headers and preflight if configured.
if isPreflight := s.processCorsHeaders(rw, req); isPreflight {
rw.Header().Set("Content-Length", "0")
rw.WriteHeader(http.StatusOK)
return
}

View file

@ -134,6 +134,7 @@ func TestNewHeader_CORSPreflights(t *testing.T) {
"Origin": {"https://foo.bar.org"},
},
expected: map[string][]string{
"Content-Length": {"0"},
"Access-Control-Allow-Origin": {"https://foo.bar.org"},
"Access-Control-Max-Age": {"600"},
"Access-Control-Allow-Methods": {"GET,OPTIONS,PUT"},
@ -152,6 +153,7 @@ func TestNewHeader_CORSPreflights(t *testing.T) {
"Origin": {"https://foo.bar.org"},
},
expected: map[string][]string{
"Content-Length": {"0"},
"Access-Control-Allow-Origin": {"*"},
"Access-Control-Max-Age": {"600"},
"Access-Control-Allow-Methods": {"GET,OPTIONS,PUT"},
@ -171,6 +173,7 @@ func TestNewHeader_CORSPreflights(t *testing.T) {
"Origin": {"https://foo.bar.org"},
},
expected: map[string][]string{
"Content-Length": {"0"},
"Access-Control-Allow-Origin": {"*"},
"Access-Control-Max-Age": {"600"},
"Access-Control-Allow-Methods": {"GET,OPTIONS,PUT"},
@ -191,6 +194,7 @@ func TestNewHeader_CORSPreflights(t *testing.T) {
"Origin": {"https://foo.bar.org"},
},
expected: map[string][]string{
"Content-Length": {"0"},
"Access-Control-Allow-Origin": {"*"},
"Access-Control-Max-Age": {"600"},
"Access-Control-Allow-Methods": {"GET,OPTIONS,PUT"},
@ -210,6 +214,7 @@ func TestNewHeader_CORSPreflights(t *testing.T) {
"Origin": {"https://foo.bar.org"},
},
expected: map[string][]string{
"Content-Length": {"0"},
"Access-Control-Allow-Origin": {"*"},
"Access-Control-Max-Age": {"600"},
"Access-Control-Allow-Methods": {"GET,OPTIONS,PUT"},

View file

@ -226,11 +226,11 @@ func getIssuerDNInfo(ctx context.Context, options *IssuerDistinguishedNameOption
content := &strings.Builder{}
// Manage non standard attributes
// Manage non-standard attributes
for _, name := range cs.Names {
// Domain Component - RFC 2247
if options.DomainComponent && attributeTypeNames[name.Type.String()] == "DC" {
content.WriteString(fmt.Sprintf("DC=%s%s", name.Value, subFieldSeparator))
_, _ = fmt.Fprintf(content, "DC=%s%s", name.Value, subFieldSeparator)
}
}
@ -272,7 +272,7 @@ func getSubjectDNInfo(ctx context.Context, options *SubjectDistinguishedNameOpti
for _, name := range cs.Names {
// Domain Component - RFC 2247
if options.DomainComponent && attributeTypeNames[name.Type.String()] == "DC" {
content.WriteString(fmt.Sprintf("DC=%s%s", name.Value, subFieldSeparator))
_, _ = fmt.Fprintf(content, "DC=%s%s", name.Value, subFieldSeparator)
}
}

View file

@ -282,11 +282,7 @@ func TestInMemoryRateLimit(t *testing.T) {
end := start.Add(test.loadDuration)
ticker := time.NewTicker(loadPeriod)
defer ticker.Stop()
for {
if time.Now().After(end) {
break
}
for !time.Now().After(end) {
req := testhelpers.MustNewRequest(http.MethodGet, "http://localhost", nil)
req.RemoteAddr = "127.0.0.1:1234"
w := httptest.NewRecorder()
@ -496,11 +492,7 @@ func TestRedisRateLimit(t *testing.T) {
end := start.Add(test.loadDuration)
ticker := time.NewTicker(loadPeriod)
defer ticker.Stop()
for {
if time.Now().After(end) {
break
}
for !time.Now().After(end) {
req := testhelpers.MustNewRequest(http.MethodGet, "http://localhost", nil)
req.RemoteAddr = "127.0.0." + strconv.Itoa(randPort) + ":" + strconv.Itoa(randPort)
w := httptest.NewRecorder()

View file

@ -453,7 +453,7 @@ func TestParseDomains(t *testing.T) {
require.NoError(t, err, "%s: Error while parsing domain.", test.expression)
}
assert.EqualValues(t, test.domain, domains, "%s: Error parsing domains from expression.", test.expression)
assert.Equal(t, test.domain, domains, "%s: Error parsing domains from expression.", test.expression)
})
}
}

View file

@ -570,7 +570,7 @@ func TestParseHostSNIV2(t *testing.T) {
require.NoError(t, err, "%s: Error while parsing domain.", test.expression)
}
assert.EqualValues(t, test.domain, domains, "%s: Error parsing domains from expression.", test.expression)
assert.Equal(t, test.domain, domains, "%s: Error parsing domains from expression.", test.expression)
})
}
}

View file

@ -382,7 +382,7 @@ func TestParseHostSNI(t *testing.T) {
require.NoError(t, err, "%s: Error while parsing domain.", test.expression)
}
assert.EqualValues(t, test.domain, domains, "%s: Error parsing domains from expression.", test.expression)
assert.Equal(t, test.domain, domains, "%s: Error parsing domains from expression.", test.expression)
})
}
}

View file

@ -182,7 +182,7 @@ func TestGetUncheckedCertificates(t *testing.T) {
}
domains := acmeProvider.getUncheckedDomains(context.Background(), test.domains, "default")
assert.Equal(t, len(test.expectedDomains), len(domains), "Unexpected domains.")
assert.Len(t, domains, len(test.expectedDomains), "Unexpected domains.")
})
}
}
@ -250,7 +250,7 @@ func TestProvider_sanitizeDomains(t *testing.T) {
if len(test.expectedErr) > 0 {
assert.EqualError(t, err, test.expectedErr, "Unexpected error.")
} else {
assert.Equal(t, len(test.expectedDomains), len(domains), "Unexpected domains.")
assert.Len(t, domains, len(test.expectedDomains), "Unexpected domains.")
}
})
}

View file

@ -241,7 +241,7 @@ func TestSwarmProvider_listServices(t *testing.T) {
serviceDockerData, err := p.listServices(context.Background(), dockerClient)
assert.NoError(t, err)
assert.Equal(t, len(test.expectedServices), len(serviceDockerData))
assert.Len(t, serviceDockerData, len(test.expectedServices))
for i, serviceName := range test.expectedServices {
if len(serviceDockerData) <= i {
require.Fail(t, "index", "invalid index %d", i)

View file

@ -598,8 +598,8 @@ func (c configBuilder) nameAndService(ctx context.Context, parentNamespace strin
return "", nil, fmt.Errorf("service %s/%s not in the parent resource namespace %s", service.Namespace, service.Name, parentNamespace)
}
switch {
case service.Kind == "" || service.Kind == "Service":
switch service.Kind {
case "", "Service":
serversLB, err := c.buildServersLB(namespace, service)
if err != nil {
return "", nil, err
@ -608,8 +608,10 @@ func (c configBuilder) nameAndService(ctx context.Context, parentNamespace strin
fullName := fullServiceName(svcCtx, namespace, service, service.Port)
return fullName, serversLB, nil
case service.Kind == "TraefikService":
case "TraefikService":
return fullServiceName(svcCtx, namespace, service, intstr.FromInt(0)), nil, nil
default:
return "", nil, fmt.Errorf("unsupported service kind %s", service.Kind)
}

View file

@ -352,7 +352,7 @@ func TestTransferEncodingChunked(t *testing.T) {
require.True(t, ok)
for i := range 3 {
_, err := rw.Write([]byte(fmt.Sprintf("chunk %d\n", i)))
_, err := fmt.Fprintf(rw, "chunk %d\n", i)
require.NoError(t, err)
flusher.Flush()

View file

@ -375,7 +375,7 @@ func Test_doOnStruct(t *testing.T) {
err := doOnStruct(val, tagExport, test.redactByDefault)
require.NoError(t, err)
assert.EqualValues(t, test.expected, test.base)
assert.Equal(t, test.expected, test.base)
})
}
}

View file

@ -610,7 +610,12 @@ func createHTTPServer(ctx context.Context, ln net.Listener, configuration *stati
return nil, err
}
handler = denyFragment(handler)
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 {
@ -630,6 +635,8 @@ func createHTTPServer(ctx context.Context, ln net.Listener, configuration *stati
})
}
handler = denyFragment(handler)
serverHTTP := &http.Server{
Handler: handler,
ErrorLog: stdlog.New(logs.NoLevel(log.Logger, zerolog.DebugLevel), "", 0),
@ -755,3 +762,20 @@ func denyFragment(h http.Handler) http.Handler {
h.ServeHTTP(rw, req)
})
}
// sanitizePath removes the "..", "." and duplicate slash segments from the URL.
// 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) {
r2 := new(http.Request)
*r2 = *req
// Cleans the URL raw path and path.
r2.URL = r2.URL.JoinPath()
// 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)
})
}

View file

@ -8,6 +8,7 @@ import (
"io"
"net"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
@ -382,3 +383,44 @@ func TestKeepAliveH2c(t *testing.T) {
// to change.
require.Contains(t, err.Error(), "use of closed network connection")
}
func TestSanitizePath(t *testing.T) {
tests := []struct {
path string
expected string
}{
{path: "/b", expected: "/b"},
{path: "/b/", expected: "/b/"},
{path: "/../../b/", expected: "/b/"},
{path: "/../../b", expected: "/b"},
{path: "/a/b/..", expected: "/a"},
{path: "/a/b/../", expected: "/a/"},
{path: "/a/../../b", expected: "/b"},
{path: "/..///b///", expected: "/b/"},
{path: "/a/../b", expected: "/b"},
{path: "/a/./b", expected: "/a/b"},
{path: "/a//b", expected: "/a/b"},
{path: "/a/../../b", expected: "/b"},
{path: "/a/../c/../b", expected: "/b"},
{path: "/a/../../../c/../b", expected: "/b"},
{path: "/a/../c/../../b", expected: "/b"},
{path: "/a/..//c/.././b", expected: "/b"},
}
for _, test := range tests {
t.Run("Testing case: "+test.path, func(t *testing.T) {
t.Parallel()
var callCount int
clean := sanitizePath(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
callCount++
assert.Equal(t, test.expected, r.URL.Path)
}))
request := httptest.NewRequest(http.MethodGet, "http://foo"+test.path, http.NoBody)
clean.ServeHTTP(httptest.NewRecorder(), request)
assert.Equal(t, 1, callCount)
})
}
}

View file

@ -327,7 +327,7 @@ func TestGetLoadBalancerServiceHandler(t *testing.T) {
assert.NotNil(t, handler)
req := testhelpers.MustNewRequest(http.MethodGet, "http://callme", nil)
assert.Equal(t, "", req.Header.Get("User-Agent"))
assert.Empty(t, req.Header.Get("User-Agent"))
if test.userAgent != "" {
req.Header.Set("User-Agent", test.userAgent)

View file

@ -41,7 +41,7 @@ func TestDomain_ToStrArray(t *testing.T) {
t.Parallel()
domains := test.domain.ToStrArray()
assert.EqualValues(t, test.expected, domains)
assert.Equal(t, test.expected, domains)
})
}
}

View file

@ -131,11 +131,7 @@ func (l *Listener) Shutdown(graceTimeout time.Duration) error {
}
start := time.Now()
end := start.Add(graceTimeout)
for {
if time.Now().After(end) {
break
}
for !time.Now().After(end) {
l.mu.RLock()
if len(l.conns) == 0 {
l.mu.RUnlock()