Merge branch v3.3 into master

This commit is contained in:
kevinpollet 2025-03-31 10:43:49 +02:00
commit ec38a0675f
No known key found for this signature in database
GPG key ID: 0C9A5DDD1B292453
33 changed files with 411 additions and 178 deletions

View file

@ -190,7 +190,7 @@ type Compress struct {
}
func (c *Compress) SetDefaults() {
c.Encodings = []string{"zstd", "br", "gzip"}
c.Encodings = []string{"gzip", "br", "zstd"}
}
// +k8s:deepcopy-gen=true

View file

@ -23,57 +23,44 @@ type Encoding struct {
Weight float64
}
func getCompressionEncoding(acceptEncoding []string, defaultEncoding string, supportedEncodings []string) string {
if defaultEncoding == "" {
if slices.Contains(supportedEncodings, brotliName) {
// Keeps the pre-existing default inside Traefik if brotli is a supported encoding.
defaultEncoding = brotliName
} else if len(supportedEncodings) > 0 {
// Otherwise use the first supported encoding.
defaultEncoding = supportedEncodings[0]
}
func (c *compress) getCompressionEncoding(acceptEncoding []string) string {
// RFC says: An Accept-Encoding header field with a field value that is empty implies that the user agent does not want any content coding in response.
// https://datatracker.ietf.org/doc/html/rfc9110#name-accept-encoding
if len(acceptEncoding) == 1 && acceptEncoding[0] == "" {
return identityName
}
encodings, hasWeight := parseAcceptEncoding(acceptEncoding, supportedEncodings)
acceptableEncodings := parseAcceptableEncodings(acceptEncoding, c.supportedEncodings)
if hasWeight {
if len(encodings) == 0 {
return identityName
}
encoding := encodings[0]
if encoding.Type == identityName && encoding.Weight == 0 {
return notAcceptable
}
if encoding.Type == wildcardName && encoding.Weight == 0 {
return notAcceptable
}
if encoding.Type == wildcardName {
return defaultEncoding
}
return encoding.Type
// An empty Accept-Encoding header field would have been handled earlier.
// If empty, it means no encoding is supported, we do not encode.
if len(acceptableEncodings) == 0 {
// TODO: return 415 status code instead of deactivating the compression, if the backend was not to compress as well.
return notAcceptable
}
for _, dt := range supportedEncodings {
if slices.ContainsFunc(encodings, func(e Encoding) bool { return e.Type == dt }) {
return dt
slices.SortFunc(acceptableEncodings, func(a, b Encoding) int {
if a.Weight == b.Weight {
// At same weight, we want to prioritize based on the encoding priority.
// the lower the index, the higher the priority.
return cmp.Compare(c.supportedEncodings[a.Type], c.supportedEncodings[b.Type])
}
return cmp.Compare(b.Weight, a.Weight)
})
if acceptableEncodings[0].Type == wildcardName {
if c.defaultEncoding == "" {
return c.encodings[0]
}
return c.defaultEncoding
}
if slices.ContainsFunc(encodings, func(e Encoding) bool { return e.Type == wildcardName }) {
return defaultEncoding
}
return identityName
return acceptableEncodings[0].Type
}
func parseAcceptEncoding(acceptEncoding, supportedEncodings []string) ([]Encoding, bool) {
func parseAcceptableEncodings(acceptEncoding []string, supportedEncodings map[string]int) []Encoding {
var encodings []Encoding
var hasWeight bool
for _, line := range acceptEncoding {
for _, item := range strings.Split(strings.ReplaceAll(line, " ", ""), ",") {
@ -82,9 +69,7 @@ func parseAcceptEncoding(acceptEncoding, supportedEncodings []string) ([]Encodin
continue
}
if !slices.Contains(supportedEncodings, parsed[0]) &&
parsed[0] != identityName &&
parsed[0] != wildcardName {
if _, ok := supportedEncodings[parsed[0]]; !ok {
continue
}
@ -94,8 +79,13 @@ func parseAcceptEncoding(acceptEncoding, supportedEncodings []string) ([]Encodin
if len(parsed) > 1 && strings.HasPrefix(parsed[1], "q=") {
w, _ := strconv.ParseFloat(strings.TrimPrefix(parsed[1], "q="), 64)
// If the weight is 0, the encoding is not acceptable.
// We can skip the encoding.
if w == 0 {
continue
}
weight = w
hasWeight = true
}
encodings = append(encodings, Encoding{
@ -105,9 +95,5 @@ func parseAcceptEncoding(acceptEncoding, supportedEncodings []string) ([]Encodin
}
}
slices.SortFunc(encodings, func(a, b Encoding) int {
return cmp.Compare(b.Weight, a.Weight)
})
return encodings, hasWeight
return encodings
}

View file

@ -1,9 +1,12 @@
package compress
import (
"context"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/traefik/traefik/v3/pkg/config/dynamic"
)
func Test_getCompressionEncoding(t *testing.T) {
@ -15,14 +18,19 @@ func Test_getCompressionEncoding(t *testing.T) {
expected string
}{
{
desc: "br > gzip (no weight)",
acceptEncoding: []string{"gzip, br"},
expected: brotliName,
desc: "Empty Accept-Encoding",
acceptEncoding: []string{""},
expected: identityName,
},
{
desc: "zstd > br > gzip (no weight)",
acceptEncoding: []string{"zstd, gzip, br"},
expected: zstdName,
desc: "gzip > br (no weight)",
acceptEncoding: []string{"gzip, br"},
expected: gzipName,
},
{
desc: "gzip > br > zstd (no weight)",
acceptEncoding: []string{"gzip, br, zstd"},
expected: gzipName,
},
{
desc: "known compression encoding (no weight)",
@ -32,24 +40,34 @@ func Test_getCompressionEncoding(t *testing.T) {
{
desc: "unknown compression encoding (no weight), no encoding",
acceptEncoding: []string{"compress, rar"},
expected: identityName,
expected: notAcceptable,
},
{
desc: "wildcard return the default compression encoding",
desc: "wildcard returns the default compression encoding",
acceptEncoding: []string{"*"},
expected: brotliName,
expected: gzipName,
},
{
desc: "wildcard return the custom default compression encoding",
desc: "wildcard returns the custom default compression encoding",
acceptEncoding: []string{"*"},
defaultEncoding: "foo",
expected: "foo",
defaultEncoding: brotliName,
expected: brotliName,
},
{
desc: "follows weight",
acceptEncoding: []string{"br;q=0.8, gzip;q=1.0, *;q=0.1"},
expected: gzipName,
},
{
desc: "identity with higher weight is preferred",
acceptEncoding: []string{"br;q=0.8, identity;q=1.0"},
expected: identityName,
},
{
desc: "identity with equal weight is not preferred",
acceptEncoding: []string{"br;q=0.8, identity;q=0.8"},
expected: brotliName,
},
{
desc: "ignore unknown compression encoding",
acceptEncoding: []string{"compress;q=1.0, gzip;q=0.5"},
@ -93,6 +111,33 @@ func Test_getCompressionEncoding(t *testing.T) {
supportedEncodings: []string{gzipName, brotliName},
expected: gzipName,
},
{
desc: "Zero weights, no compression",
acceptEncoding: []string{"br;q=0, gzip;q=0, zstd;q=0"},
expected: notAcceptable,
},
{
desc: "Zero weights, default encoding, no compression",
acceptEncoding: []string{"br;q=0, gzip;q=0, zstd;q=0"},
defaultEncoding: "br",
expected: notAcceptable,
},
{
desc: "Same weight, first supported encoding",
acceptEncoding: []string{"br;q=1.0, gzip;q=1.0, zstd;q=1.0"},
expected: gzipName,
},
{
desc: "Same weight, first supported encoding, order has no effect",
acceptEncoding: []string{"br;q=1.0, zstd;q=1.0, gzip;q=1.0"},
expected: gzipName,
},
{
desc: "Same weight, first supported encoding, defaultEncoding has no effect",
acceptEncoding: []string{"br;q=1.0, zstd;q=1.0, gzip;q=1.0"},
defaultEncoding: "br",
expected: gzipName,
},
}
for _, test := range testCases {
@ -103,7 +148,18 @@ func Test_getCompressionEncoding(t *testing.T) {
test.supportedEncodings = defaultSupportedEncodings
}
encoding := getCompressionEncoding(test.acceptEncoding, test.defaultEncoding, test.supportedEncodings)
conf := dynamic.Compress{
Encodings: test.supportedEncodings,
DefaultEncoding: test.defaultEncoding,
}
h, err := New(context.Background(), nil, conf, "test")
require.NoError(t, err)
c, ok := h.(*compress)
require.True(t, ok)
encoding := c.getCompressionEncoding(test.acceptEncoding)
assert.Equal(t, test.expected, encoding)
})
@ -147,7 +203,6 @@ func Test_parseAcceptEncoding(t *testing.T) {
{Type: zstdName, Weight: 1},
{Type: gzipName, Weight: 1},
{Type: brotliName, Weight: 1},
{Type: wildcardName, Weight: 0},
},
assertWeight: assert.True,
},
@ -157,7 +212,6 @@ func Test_parseAcceptEncoding(t *testing.T) {
supportedEncodings: []string{zstdName},
expected: []Encoding{
{Type: zstdName, Weight: 1},
{Type: wildcardName, Weight: 0},
},
assertWeight: assert.True,
},
@ -188,7 +242,6 @@ func Test_parseAcceptEncoding(t *testing.T) {
expected: []Encoding{
{Type: gzipName, Weight: 1},
{Type: identityName, Weight: 0.5},
{Type: wildcardName, Weight: 0},
},
assertWeight: assert.True,
},
@ -198,7 +251,6 @@ func Test_parseAcceptEncoding(t *testing.T) {
supportedEncodings: []string{"br"},
expected: []Encoding{
{Type: identityName, Weight: 0.5},
{Type: wildcardName, Weight: 0},
},
assertWeight: assert.True,
},
@ -212,10 +264,11 @@ func Test_parseAcceptEncoding(t *testing.T) {
test.supportedEncodings = defaultSupportedEncodings
}
aes, hasWeight := parseAcceptEncoding(test.values, test.supportedEncodings)
supportedEncodings := buildSupportedEncodings(test.supportedEncodings)
aes := parseAcceptableEncodings(test.values, supportedEncodings)
assert.Equal(t, test.expected, aes)
test.assertWeight(t, hasWeight)
})
}
}

View file

@ -22,7 +22,7 @@ const typeName = "Compress"
// See https://github.com/klauspost/compress/blob/9559b037e79ad673c71f6ef7c732c00949014cd2/gzhttp/compress.go#L47.
const defaultMinSize = 1024
var defaultSupportedEncodings = []string{zstdName, brotliName, gzipName}
var defaultSupportedEncodings = []string{gzipName, brotliName, zstdName}
// Compress is a middleware that allows to compress the response.
type compress struct {
@ -33,6 +33,8 @@ type compress struct {
minSize int
encodings []string
defaultEncoding string
// supportedEncodings is a map of supported encodings and their priority.
supportedEncodings map[string]int
brotliHandler http.Handler
gzipHandler http.Handler
@ -85,13 +87,14 @@ func New(ctx context.Context, next http.Handler, conf dynamic.Compress, name str
}
c := &compress{
next: next,
name: name,
excludes: excludes,
includes: includes,
minSize: minSize,
encodings: conf.Encodings,
defaultEncoding: conf.DefaultEncoding,
next: next,
name: name,
excludes: excludes,
includes: includes,
minSize: minSize,
encodings: conf.Encodings,
defaultEncoding: conf.DefaultEncoding,
supportedEncodings: buildSupportedEncodings(conf.Encodings),
}
var err error
@ -114,6 +117,19 @@ func New(ctx context.Context, next http.Handler, conf dynamic.Compress, name str
return c, nil
}
func buildSupportedEncodings(encodings []string) map[string]int {
supportedEncodings := map[string]int{
// the most permissive first.
wildcardName: -1,
// the less permissive last.
identityName: len(encodings),
}
for i, encoding := range encodings {
supportedEncodings[encoding] = i
}
return supportedEncodings
}
func (c *compress) ServeHTTP(rw http.ResponseWriter, req *http.Request) {
logger := middlewares.GetLogger(req.Context(), c.name, typeName)
@ -149,7 +165,7 @@ func (c *compress) ServeHTTP(rw http.ResponseWriter, req *http.Request) {
return
}
c.chooseHandler(getCompressionEncoding(acceptEncoding, c.defaultEncoding, c.encodings), rw, req)
c.chooseHandler(c.getCompressionEncoding(acceptEncoding), rw, req)
}
func (c *compress) chooseHandler(typ string, rw http.ResponseWriter, req *http.Request) {

View file

@ -39,9 +39,14 @@ func TestNegotiation(t *testing.T) {
expEncoding: "",
},
{
// In this test, the default encodings are defaulted to gzip, brotli, and zstd,
// which make gzip the default encoding, and will be selected.
// However, the klauspost/compress gzhttp handler does not compress when Accept-Encoding: * is set.
// Until klauspost/compress gzhttp package supports the asterisk,
// we will not support it when selecting the gzip encoding.
desc: "accept any header",
acceptEncHeader: "*",
expEncoding: brotliName,
expEncoding: "",
},
{
desc: "gzip accept header",
@ -66,7 +71,7 @@ func TestNegotiation(t *testing.T) {
{
desc: "multi accept header list, prefer br",
acceptEncHeader: "gzip, br",
expEncoding: brotliName,
expEncoding: gzipName,
},
{
desc: "zstd accept header",
@ -78,15 +83,20 @@ func TestNegotiation(t *testing.T) {
acceptEncHeader: "zstd;q=0.9, br;q=0.8, gzip;q=0.6",
expEncoding: zstdName,
},
{
desc: "multi accept header, prefer brotli",
acceptEncHeader: "gzip;q=0.8, br;q=1.0, zstd;q=0.7",
expEncoding: brotliName,
},
{
desc: "multi accept header, prefer gzip",
acceptEncHeader: "gzip;q=1.0, br;q=0.8, zstd;q=0.7",
expEncoding: gzipName,
},
{
desc: "multi accept header list, prefer zstd",
desc: "multi accept header list, prefer gzip",
acceptEncHeader: "gzip, br, zstd",
expEncoding: zstdName,
expEncoding: gzipName,
},
}
@ -190,6 +200,28 @@ func TestShouldNotCompressWhenNoAcceptEncodingHeader(t *testing.T) {
assert.EqualValues(t, rw.Body.Bytes(), fakeBody)
}
func TestEmptyAcceptEncoding(t *testing.T) {
req := testhelpers.MustNewRequest(http.MethodGet, "http://localhost", nil)
req.Header.Add(acceptEncodingHeader, "")
fakeBody := generateBytes(gzhttp.DefaultMinSize)
next := http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {
_, err := rw.Write(fakeBody)
if err != nil {
http.Error(rw, err.Error(), http.StatusInternalServerError)
}
})
handler, err := New(context.Background(), next, dynamic.Compress{Encodings: defaultSupportedEncodings}, "testing")
require.NoError(t, err)
rw := httptest.NewRecorder()
handler.ServeHTTP(rw, req)
assert.Empty(t, rw.Header().Get(contentEncodingHeader))
assert.Empty(t, rw.Header().Get(varyHeader))
assert.EqualValues(t, rw.Body.Bytes(), fakeBody)
}
func TestShouldNotCompressWhenIdentityAcceptEncodingHeader(t *testing.T) {
req := testhelpers.MustNewRequest(http.MethodGet, "http://localhost", nil)
req.Header.Set(acceptEncodingHeader, "identity")

View file

@ -233,8 +233,12 @@ func (r *responseWriter) Write(p []byte) (int, error) {
// Disable compression according to user wishes in excludedContentTypes or includedContentTypes.
if ct := r.rw.Header().Get(contentType); ct != "" {
mediaType, params, err := mime.ParseMediaType(ct)
// To align the behavior with the klauspost handler for Gzip,
// if the MIME type is not parsable the compression is disabled.
if err != nil {
return 0, fmt.Errorf("parsing content-type media type: %w", err)
r.compressionDisabled = true
r.rw.WriteHeader(r.statusCode)
return r.rw.Write(p)
}
if len(r.includedContentTypes) > 0 {

View file

@ -577,6 +577,11 @@ func Test_ExcludedContentTypes(t *testing.T) {
contentType: "",
expCompression: true,
},
{
desc: "MIME malformed",
contentType: "application/json;charset=UTF-8;charset=utf-8",
expCompression: false,
},
{
desc: "MIME match",
contentType: "application/json",
@ -687,6 +692,11 @@ func Test_IncludedContentTypes(t *testing.T) {
contentType: "",
expCompression: true,
},
{
desc: "MIME malformed",
contentType: "application/json;charset=UTF-8;charset=utf-8",
expCompression: false,
},
{
desc: "MIME match",
contentType: "application/json",

View file

@ -67,7 +67,7 @@ func clientIP(tree *matchersTree, clientIP ...string) error {
return nil
}
var hostOrIP = regexp.MustCompile(`^[[:alnum:]\.\-\:]+$`)
var hostOrIP = regexp.MustCompile(`^[[:word:]\.\-\:]+$`)
// hostSNI checks if the SNI Host of the connection match the matcher host.
func hostSNI(tree *matchersTree, hosts ...string) error {

View file

@ -133,6 +133,12 @@ func Test_HostSNI(t *testing.T) {
serverName: "foo.example.com",
match: true,
},
{
desc: "Matching hosts with subdomains with _",
rule: "HostSNI(`foo_bar.example.com`)",
serverName: "foo_bar.example.com",
match: true,
},
}
for _, test := range testCases {

View file

@ -690,6 +690,11 @@ func Test_HostSNIV2(t *testing.T) {
ruleHosts: []string{"foo.bar"},
serverName: "foo.bar",
},
{
desc: "Matching hosts with subdomains with _",
ruleHosts: []string{"foo_bar.example.com"},
serverName: "foo_bar.example.com",
},
{
desc: "Matching IPv4",
ruleHosts: []string{"127.0.0.1"},

View file

@ -2,6 +2,7 @@ package httputil
import (
"context"
"crypto/tls"
"errors"
"io"
stdlog "log"
@ -112,7 +113,13 @@ func ErrorHandlerWithContext(ctx context.Context, w http.ResponseWriter, err err
statusCode := ComputeStatusCode(err)
logger := log.Ctx(ctx)
logger.Debug().Err(err).Msgf("%d %s", statusCode, statusText(statusCode))
// Log the error with error level if it is a TLS error related to configuration.
if isTLSConfigError(err) {
logger.Error().Err(err).Msgf("%d %s", statusCode, statusText(statusCode))
} else {
logger.Debug().Err(err).Msgf("%d %s", statusCode, statusText(statusCode))
}
w.WriteHeader(statusCode)
if _, werr := w.Write([]byte(statusText(statusCode))); werr != nil {
@ -127,6 +134,22 @@ func statusText(statusCode int) string {
return http.StatusText(statusCode)
}
// isTLSConfigError returns true if the error is a TLS error which is related to configuration.
// We assume that if the error is a tls.RecordHeaderError or a tls.CertificateVerificationError,
// it is related to configuration, because the client should not send a TLS request to a non-TLS server,
// and the client configuration should allow to verify the server certificate.
func isTLSConfigError(err error) bool {
// tls.RecordHeaderError is returned when the client sends a TLS request to a non-TLS server.
var recordHeaderErr tls.RecordHeaderError
if errors.As(err, &recordHeaderErr) {
return true
}
// tls.CertificateVerificationError is returned when the server certificate cannot be verified.
var certVerificationErr *tls.CertificateVerificationError
return errors.As(err, &certVerificationErr)
}
// ComputeStatusCode computes the HTTP status code according to the given error.
func ComputeStatusCode(err error) int {
switch {

View file

@ -1,12 +1,15 @@
package httputil
import (
"crypto/tls"
"errors"
"net/http"
"net/http/httptest"
"net/url"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/traefik/traefik/v3/pkg/testhelpers"
)
@ -100,3 +103,46 @@ func Test_directorBuilder(t *testing.T) {
})
}
}
func Test_isTLSConfigError(t *testing.T) {
testCases := []struct {
desc string
err error
expected bool
}{
{
desc: "nil",
},
{
desc: "TLS ECHRejectionError",
err: &tls.ECHRejectionError{},
},
{
desc: "TLS AlertError",
err: tls.AlertError(0),
},
{
desc: "Random error",
err: errors.New("random error"),
},
{
desc: "TLS RecordHeaderError",
err: tls.RecordHeaderError{},
expected: true,
},
{
desc: "TLS CertificateVerificationError",
err: &tls.CertificateVerificationError{},
expected: true,
},
}
for _, test := range testCases {
t.Run(test.desc, func(t *testing.T) {
t.Parallel()
actual := isTLSConfigError(test.err)
require.Equal(t, test.expected, actual)
})
}
}