diff --git a/gzip.go b/gzip.go index 957fc92..bbcd6b5 100644 --- a/gzip.go +++ b/gzip.go @@ -85,7 +85,8 @@ type GzipResponseWriter struct { buf []byte // Holds the first part of the write before reaching the minSize or the end of the write. ignore bool // If true, then we immediately passthru writes to the underlying ResponseWriter. - contentTypes []parsedContentType // Only compress if the response is one of these content-types. All are accepted if empty. + contentTypes []parsedContentType // Only compress if the response is one of these content-types. All are accepted if empty. + acceptsIdentity bool // If false, then request explicitly rejected non-encoded requests. } type GzipResponseWriterWithCloseNotify struct { @@ -118,19 +119,21 @@ func (w *GzipResponseWriter) Write(b []byte) (int, error) { ce = w.Header().Get(contentEncoding) ) // Only continue if they didn't already choose an encoding or a known unhandled content length or type. - if ce == "" && (cl == 0 || cl >= w.minSize) && (ct == "" || handleContentType(w.contentTypes, ct)) { + if ce == "" && (cl == 0 || cl >= w.minSize || !w.acceptsIdentity) && (ct == "" || handleContentType(w.contentTypes, ct)) { // If the current buffer is less than minSize and a Content-Length isn't set, then wait until we have more data. - if len(w.buf) < w.minSize && cl == 0 { + if len(w.buf) < w.minSize && cl == 0 && w.acceptsIdentity { return len(b), nil } // If the Content-Length is larger than minSize or the current buffer is larger than minSize, then continue. - if cl >= w.minSize || len(w.buf) >= w.minSize { + if cl >= w.minSize || len(w.buf) >= w.minSize || !w.acceptsIdentity { // If a Content-Type wasn't specified, infer it from the current buffer. if ct == "" { ct = http.DetectContentType(w.buf) w.Header().Set(contentType, ct) } // If the Content-Type is acceptable to GZIP, initialize the GZIP writer. + // Note that we're ignoring the `acceptsIdentity` here, because we'd have to return a 406 Not Acceptable + // in that case but we still might be wrapped by another handler that handles a different encoding. if handleContentType(w.contentTypes, ct) { if err := w.startGzip(); err != nil { return 0, err @@ -322,12 +325,13 @@ func GzipHandlerWithOpts(opts ...option) (func(http.Handler) http.Handler, error return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Add(vary, acceptEncoding) - if acceptsGzip(r) { + if acceptsGzip, acceptsIdentity := requestAcceptance(r); acceptsGzip { gw := &GzipResponseWriter{ - ResponseWriter: w, - index: index, - minSize: c.minSize, - contentTypes: c.contentTypes, + ResponseWriter: w, + index: index, + minSize: c.minSize, + contentTypes: c.contentTypes, + acceptsIdentity: acceptsIdentity, } defer gw.Close() @@ -445,11 +449,25 @@ func GzipHandler(h http.Handler) http.Handler { return wrapper(h) } -// acceptsGzip returns true if the given HTTP request indicates that it will -// accept a gzipped response. -func acceptsGzip(r *http.Request) bool { +// requestAcceptance checks whether a given HTTP request indicates that it will +// accept a gzipped response and whether it's going to reject an non-encoded response. +// +// acceptsGzip is true if the given HTTP request indicates that it will +// accept a gzipped response and/or an identity request. +// acceptsIdentity is if the given HTTP request didn't explicitly exclude identity encoding. +// I.e., either "identity;q=0" or "*;q=0" without a more specific entry for "identity". +// See https://datatracker.ietf.org/doc/html/rfc7231#section-5.3.4 +func requestAcceptance(r *http.Request) (acceptsGzip bool, acceptsIdentity bool) { acceptedEncodings, _ := parseEncodings(r.Header.Get(acceptEncoding)) - return acceptedEncodings["gzip"] > 0.0 + + identity, iset := acceptedEncodings["identity"] + wildcard, wset := acceptedEncodings["*"] + rejectsIdentity := (iset && identity == 0) || (!iset && wset && wildcard == 0) + + gzip, gzset := acceptedEncodings["gzip"] + acceptsGzip = gzip > 0 || (!gzset && wildcard > 0) + + return acceptsGzip, !rejectsIdentity } // returns true if we've been configured to compress the specific content type. diff --git a/gzip_test.go b/gzip_test.go index bed7f52..d579032 100644 --- a/gzip_test.go +++ b/gzip_test.go @@ -30,6 +30,7 @@ func TestParseEncodings(t *testing.T) { "*": {"*": 1.0}, "compress;q=0.5, gzip;q=1.0": {"compress": 0.5, "gzip": 1.0}, "gzip;q=1.0, identity; q=0.5, *;q=0": {"gzip": 1.0, "identity": 0.5, "*": 0.0}, + "gzip;q=1.0, identity;q=0": {"gzip": 1.0, "identity": 0.0}, // More random stuff "AAA;q=1": {"aaa": 1.0}, @@ -42,6 +43,33 @@ func TestParseEncodings(t *testing.T) { } } +func TestRequestAcceptance(t *testing.T) { + type ret struct { + acceptsGzip bool + acceptsIdentity bool + } + + for header, expected := range map[string]ret{ + "gzip": {true, true}, + "gzip;q=1": {true, true}, + "gzip;q=1, identity;q=0": {true, false}, + "gzip;q=1, identity;q=0, *;q=0.5": {true, false}, + "foo;q=1, gzip;q=0.5, *;q=0": {true, false}, + "identity;q=0": {false, false}, + "identity;q=0, *;q=0.5": {true, false}, + } { + t.Run(header, func(t *testing.T) { + req, err := http.NewRequest(http.MethodGet, "http://localhost", nil) + req.Header.Set(acceptEncoding, header) + assert.NoError(t, err) + + acceptsGzip, acceptsIdentity := requestAcceptance(req) + assert.Equal(t, expected.acceptsGzip, acceptsGzip, "acceptsGzip differs") + assert.Equal(t, expected.acceptsIdentity, acceptsIdentity, "acceptsIdentity differs") + }) + } +} + func TestGzipHandler(t *testing.T) { // This just exists to provide something for GzipHandler to wrap. handler := newTestHandler(testBody) @@ -96,7 +124,21 @@ func TestGzipHandlerSmallBodyNoCompression(t *testing.T) { assert.Equal(t, "", res.Header.Get("Content-Encoding")) assert.Equal(t, "Accept-Encoding", res.Header.Get("Vary")) assert.Equal(t, smallTestBody, resp.Body.String()) +} + +func TestGzipHandlerSmallButDoesNotAcceptIdentity(t *testing.T) { + handler := newTestHandler(smallTestBody) + + req, _ := http.NewRequest("GET", "/whatever", nil) + req.Header.Set("Accept-Encoding", "gzip;q=1, identity;q=0") + resp := httptest.NewRecorder() + handler.ServeHTTP(resp, req) + res := resp.Result() + // We explicitly stated that we will reject an uncompressed response. + + assert.Equal(t, 200, res.StatusCode) + assert.Equal(t, "gzip", res.Header.Get(contentEncoding)) } func TestGzipHandlerAlreadyCompressed(t *testing.T) {