From 6dbe3ae5bd0cf684d51a7a1f41dbe12a52db8423 Mon Sep 17 00:00:00 2001 From: Amit Mishra Date: Mon, 6 Jul 2026 23:13:54 +0530 Subject: [PATCH] fix: use exact token matching and respect q=0 in matchAcceptEncoding The previous implementation used strings.Contains to match encoding tokens in the Accept-Encoding header. This caused two problems: 1. Substring false positives: "br" incorrectly matched the encoding "b", and "bgzip" incorrectly matched "gzip". 2. q=0 was ignored: per RFC 9110 section 12.5.3, a quality value of 0 means the encoding is explicitly not acceptable. The old code matched "gzip;q=0" as if gzip were acceptable, causing the middleware to compress with an encoding the client explicitly rejected. Fix: rewrite matchAcceptEncoding to split each header token on ";" to separate the encoding name from its quality parameters, trim whitespace, perform an exact comparison of the encoding token, and reject the match when the quality value is 0. Add a table-driven test covering exact matching, substring false positives, q=0 rejection, non-zero quality values, multi-element lists, empty input, and whitespace trimming. Fixes #1069 --- middleware/compress.go | 28 ++++++++++- middleware/compress_test.go | 99 +++++++++++++++++++++++++++++++++++++ 2 files changed, 126 insertions(+), 1 deletion(-) diff --git a/middleware/compress.go b/middleware/compress.go index 2c963c5d..4d867bab 100644 --- a/middleware/compress.go +++ b/middleware/compress.go @@ -239,11 +239,37 @@ func (c *Compressor) selectEncoder(h http.Header, w io.Writer) (io.Writer, strin return nil, "", func() {} } +// matchAcceptEncoding reports whether encoding appears in the accepted list +// with a non-zero quality value. +// +// Each element of accepted may contain an optional quality parameter, e.g. +// "gzip;q=0.8" or "gzip;q=0". Per RFC 9110 section 12.5.3, a quality value +// of 0 means the encoding is explicitly not acceptable and must be rejected. +// Matching is done by exact token comparison (not substring) so that, for +// example, "br" does not accidentally match the encoding "b". func matchAcceptEncoding(accepted []string, encoding string) bool { for _, v := range accepted { - if strings.Contains(v, encoding) { + v = strings.TrimSpace(v) + // Split off any parameters (e.g. ";q=0.5"). + parts := strings.SplitN(v, ";", 2) + token := strings.TrimSpace(parts[0]) + if token != encoding { + continue + } + // Encoding token matches. Check the quality value if present. + if len(parts) == 1 { + // No quality parameter; default quality is 1 (acceptable). return true } + // Parse the quality parameter. + param := strings.TrimSpace(parts[1]) + if strings.HasPrefix(param, "q=") { + q := strings.TrimPrefix(param, "q=") + if q == "0" || q == "0.0" || q == "0.00" || q == "0.000" { + return false + } + } + return true } return false } diff --git a/middleware/compress_test.go b/middleware/compress_test.go index b0461315..29b0dd5e 100644 --- a/middleware/compress_test.go +++ b/middleware/compress_test.go @@ -215,3 +215,102 @@ func decodeResponseBody(t *testing.T, resp *http.Response) string { return string(respBody) } + +func TestMatchAcceptEncoding(t *testing.T) { + tests := []struct { + name string + accepted []string + encoding string + want bool + }{ + // Exact match + { + name: "exact match gzip", + accepted: []string{"gzip"}, + encoding: "gzip", + want: true, + }, + // Substring false positives that previously returned true + { + name: "br does not match b", + accepted: []string{"br"}, + encoding: "b", + want: false, + }, + { + name: "bgzip does not match gzip", + accepted: []string{"bgzip"}, + encoding: "gzip", + want: false, + }, + // q=0 must be rejected (client explicitly refuses the encoding) + { + name: "gzip;q=0 is not acceptable", + accepted: []string{"gzip;q=0"}, + encoding: "gzip", + want: false, + }, + { + name: "gzip;q=0.0 is not acceptable", + accepted: []string{"gzip;q=0.0"}, + encoding: "gzip", + want: false, + }, + // Non-zero quality values are acceptable + { + name: "gzip;q=1 is acceptable", + accepted: []string{"gzip;q=1"}, + encoding: "gzip", + want: true, + }, + { + name: "gzip;q=0.5 is acceptable", + accepted: []string{"gzip;q=0.5"}, + encoding: "gzip", + want: true, + }, + // Multiple accepted encodings + { + name: "encoding in list", + accepted: []string{"deflate", "gzip", "br"}, + encoding: "gzip", + want: true, + }, + { + name: "encoding not in list", + accepted: []string{"deflate", "br"}, + encoding: "gzip", + want: false, + }, + // Empty list + { + name: "empty accepted list", + accepted: []string{""}, + encoding: "gzip", + want: false, + }, + // Whitespace trimming + { + name: "leading/trailing whitespace around token", + accepted: []string{" gzip "}, + encoding: "gzip", + want: true, + }, + { + name: "whitespace around quality param", + accepted: []string{"gzip ; q=0"}, + encoding: "gzip", + want: false, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + got := matchAcceptEncoding(tc.accepted, tc.encoding) + if got != tc.want { + t.Errorf("matchAcceptEncoding(%v, %q) = %v, want %v", + tc.accepted, tc.encoding, got, tc.want) + } + }) + } +}