diff --git a/middleware/compress.go b/middleware/compress.go index 7754d5db8..a8660756f 100644 --- a/middleware/compress.go +++ b/middleware/compress.go @@ -21,6 +21,54 @@ const ( gzipScheme = "gzip" ) +// acceptsGzip reports whether the given Accept-Encoding header value indicates +// that the client accepts the gzip coding. +// +// It parses the header according to RFC 9110 §12.5.3 instead of doing a naive +// substring match. This avoids two classes of bugs: +// - "gzip;q=0" explicitly means the client does NOT want gzip, yet a substring +// match would still enable compression. +// - tokens such as "x-gzip" or "supergzip" would substring-match "gzip" even +// though they are distinct codings. +// +// A "*" coding with a non-zero q-value is also treated as accepting gzip. +func acceptsGzip(acceptEncoding string) bool { + if acceptEncoding == "" { + return false + } + for _, part := range strings.Split(acceptEncoding, ",") { + coding, qvalue := parseCoding(part) + if coding != gzipScheme && coding != "*" { + continue + } + if qvalue == "0" || qvalue == "0.0" || qvalue == "0.00" || qvalue == "0.000" { + // q=0 means "not acceptable"; an explicit gzip rejection wins. + if coding == gzipScheme { + return false + } + continue + } + return true + } + return false +} + +// parseCoding splits a single Accept-Encoding element into its coding name +// (lower-cased) and its q-value parameter (empty string when absent). +func parseCoding(part string) (coding, qvalue string) { + coding = part + if idx := strings.IndexByte(part, ';'); idx >= 0 { + coding = part[:idx] + for _, param := range strings.Split(part[idx+1:], ";") { + param = strings.TrimSpace(param) + if k, v, ok := strings.Cut(param, "="); ok && strings.EqualFold(strings.TrimSpace(k), "q") { + qvalue = strings.TrimSpace(v) + } + } + } + return strings.ToLower(strings.TrimSpace(coding)), qvalue +} + // GzipConfig defines the config for Gzip middleware. type GzipConfig struct { // Skipper defines a function to skip middleware. @@ -91,7 +139,7 @@ func (config GzipConfig) ToMiddleware() (echo.MiddlewareFunc, error) { res := c.Response() res.Header().Add(echo.HeaderVary, echo.HeaderAcceptEncoding) - if strings.Contains(c.Request().Header.Get(echo.HeaderAcceptEncoding), gzipScheme) { + if acceptsGzip(c.Request().Header.Get(echo.HeaderAcceptEncoding)) { i := pool.Get() w, ok := i.(*gzip.Writer) if !ok { diff --git a/middleware/compress_test.go b/middleware/compress_test.go index 084ffc9c7..628095f00 100644 --- a/middleware/compress_test.go +++ b/middleware/compress_test.go @@ -396,3 +396,62 @@ func BenchmarkGzip(b *testing.B) { h(c) } } + +func TestGzip_AcceptEncodingQValue(t *testing.T) { + // RFC 9110 §12.5.3: "gzip;q=0" means the client does not accept gzip, and a + // token like "supergzip"/"x-gzip" must not be mistaken for "gzip". + isGzipped := func(acceptEncoding string) bool { + h := Gzip()(func(c *echo.Context) error { + c.Response().Write([]byte("test")) // For Content-Type sniffing + return nil + }) + e := echo.New() + req := httptest.NewRequest(http.MethodGet, "/", nil) + if acceptEncoding != "" { + req.Header.Set(echo.HeaderAcceptEncoding, acceptEncoding) + } + rec := httptest.NewRecorder() + c := e.NewContext(req, rec) + assert.NoError(t, h(c)) + return rec.Header().Get(echo.HeaderContentEncoding) == gzipScheme + } + + tests := []struct { + acceptEncoding string + wantGzip bool + }{ + {"gzip", true}, + {"gzip;q=1.0", true}, + {"gzip;q=0.5", true}, + {"deflate, gzip", true}, + {"deflate, gzip;q=0.8", true}, + {"*", true}, + {"gzip;q=0", false}, // explicit rejection + {"gzip;q=0.0", false}, // explicit rejection + {"deflate, gzip;q=0", false}, + {"identity", false}, + {"supergzip", false}, // must not substring-match + {"x-gzip", false}, // distinct coding, not "gzip" + {"deflate", false}, + {"", false}, + } + for _, tt := range tests { + assert.Equalf(t, tt.wantGzip, isGzipped(tt.acceptEncoding), + "Accept-Encoding %q: expected gzip=%v", tt.acceptEncoding, tt.wantGzip) + } +} + +func TestAcceptsGzip(t *testing.T) { + assert.True(t, acceptsGzip("gzip")) + assert.True(t, acceptsGzip(" GZIP ")) + assert.True(t, acceptsGzip("br, gzip;q=0.9")) + assert.True(t, acceptsGzip("*")) + assert.True(t, acceptsGzip("*;q=0.1")) + assert.False(t, acceptsGzip("")) + assert.False(t, acceptsGzip("gzip;q=0")) + assert.False(t, acceptsGzip("gzip; q=0.000")) + assert.False(t, acceptsGzip("identity")) + assert.False(t, acceptsGzip("supergzip")) + assert.False(t, acceptsGzip("x-gzip")) + assert.False(t, acceptsGzip("*;q=0")) +}