Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 24 additions & 2 deletions middleware/compress.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import (
"io"
"net"
"net/http"
"strconv"
"strings"
"sync"
)
Expand Down Expand Up @@ -239,9 +240,30 @@ func (c *Compressor) selectEncoder(h http.Header, w io.Writer) (io.Writer, strin

func matchAcceptEncoding(accepted []string, encoding string) bool {
for _, v := range accepted {
if strings.Contains(v, encoding) {
return true
// Extract the encoding name (before any ";q=" quality parameter)
// and trim surrounding whitespace.
name, params, _ := strings.Cut(v, ";")
name = strings.TrimSpace(name)

if name != encoding {
continue
}

// If a quality parameter is present and equals zero, the encoding
// is explicitly rejected (RFC 9110, Section 12.5.3).
if params != "" {
params = strings.TrimSpace(params)
if qval, ok := strings.CutPrefix(params, "q="); ok {
// Strip any trailing parameters after the quality value.
qval, _, _ = strings.Cut(qval, ";")
qval = strings.TrimSpace(qval)
if q, err := strconv.ParseFloat(qval, 32); err == nil && q == 0 {
continue
}
}
}
Comment on lines +254 to +264
Copy link

Copilot AI Mar 25, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

matchAcceptEncoding only checks q=... if the params string starts with q=. If the header includes additional params after q (e.g. gzip;q=0;ext=1) or an invalid qvalue that still parses (NaN/Inf) / is out of [0,1], this function will treat the encoding as acceptable and return true. Consider splitting params on ; and scanning for a q= parameter, and treating parse errors / NaN / out-of-range values as q=0 (i.e., not acceptable) to stay RFC-compliant and avoid accidental gzip selection.

Copilot uses AI. Check for mistakes.

return true
}
return false
}
Expand Down
119 changes: 119 additions & 0 deletions middleware/compress_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,125 @@ func TestCompressor(t *testing.T) {
}
}

func TestMatchAcceptEncoding(t *testing.T) {
tests := []struct {
name string
accepted []string
encoding string
want bool
}{
{
name: "exact match",
accepted: []string{"gzip"},
encoding: "gzip",
want: true,
},
{
name: "match with whitespace",
accepted: []string{" gzip"},
encoding: "gzip",
want: true,
},
{
name: "match with quality value",
accepted: []string{"gzip;q=1.0"},
encoding: "gzip",
want: true,
},
{
name: "no match",
accepted: []string{"deflate"},
encoding: "gzip",
want: false,
},
{
name: "encoding excluded with q=0",
accepted: []string{"gzip;q=0"},
encoding: "gzip",
want: false,
},
{
name: "should not substring match",
accepted: []string{"br"},
encoding: "b",
want: false,
},
{
name: "should not substring match prefix",
accepted: []string{"bgzip"},
encoding: "gzip",
want: false,
},
{
name: "q=0 in decimal form",
accepted: []string{"gzip;q=0.0"},
encoding: "gzip",
want: false,
},
{
name: "q=0 max precision",
accepted: []string{"gzip;q=0.000"},
encoding: "gzip",
want: false,
},
{
name: "smallest non-zero quality",
accepted: []string{"gzip;q=0.001"},
encoding: "gzip",
want: true,
},
{
name: "whitespace around semicolon",
accepted: []string{"gzip ; q=0"},
encoding: "gzip",
want: false,
},
{
name: "q=0 with trailing params",
accepted: []string{"gzip;q=0;ext=foo"},
encoding: "gzip",
want: false,
},
{
name: "explicit full quality",
accepted: []string{"gzip;q=1"},
encoding: "gzip",
want: true,
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := matchAcceptEncoding(tt.accepted, tt.encoding)
if got != tt.want {
t.Errorf("matchAcceptEncoding(%v, %q) = %v, want %v", tt.accepted, tt.encoding, got, tt.want)
}
})
}
}

func TestCompressorRespectsQualityZero(t *testing.T) {
// When a client sends Accept-Encoding: gzip;q=0, the server must not
// compress the response with gzip (q=0 means "not acceptable").
r := chi.NewRouter()
compressor := NewCompressor(5, "text/html")
r.Use(compressor.Handler)

r.Get("/", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/html")
w.Write([]byte("hello"))
})

ts := httptest.NewServer(r)
defer ts.Close()

// gzip;q=0 means "I do NOT accept gzip"
resp, _ := testRequestWithAcceptedEncodings(t, ts, "GET", "/", "gzip;q=0, deflate")
if got := resp.Header.Get("Content-Encoding"); got == "gzip" {
t.Errorf("server used gzip despite q=0; Content-Encoding = %q", got)
}
Comment on lines +224 to +228
Copy link

Copilot AI Mar 25, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

TestCompressorRespectsQualityZero currently only asserts that the response is not gzipped. This will still pass if compression is disabled entirely, which would miss regressions. Since the request header includes deflate and the compressor supports it, assert that Content-Encoding is deflate (or at least non-empty) to verify the intended fallback behavior end-to-end.

Suggested change
// gzip;q=0 means "I do NOT accept gzip"
resp, _ := testRequestWithAcceptedEncodings(t, ts, "GET", "/", "gzip;q=0, deflate")
if got := resp.Header.Get("Content-Encoding"); got == "gzip" {
t.Errorf("server used gzip despite q=0; Content-Encoding = %q", got)
}
// gzip;q=0 means "I do NOT accept gzip", but deflate is acceptable.
resp, _ := testRequestWithAcceptedEncodings(t, ts, "GET", "/", "gzip;q=0, deflate")
got := resp.Header.Get("Content-Encoding")
if got == "" {
t.Fatalf("expected a compressed response using a fallback encoding, got no Content-Encoding header")
}
if got == "gzip" {
t.Errorf("server used gzip despite q=0; Content-Encoding = %q", got)
}
if got != "deflate" {
t.Errorf("expected deflate fallback when gzip is disallowed; got Content-Encoding = %q", got)
}

Copilot uses AI. Check for mistakes.
}

func TestCompressorWildcards(t *testing.T) {
tests := []struct {
name string
Expand Down