Skip to content

Commit 463eb4c

Browse files
authored
Add SuffixETag() and DropETag() options to prevent ETag collisions on compressed responses (#740)
1 parent 781b247 commit 463eb4c

File tree

2 files changed

+141
-0
lines changed

2 files changed

+141
-0
lines changed

gzhttp/compress.go

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ const (
2929
acceptRanges = "Accept-Ranges"
3030
contentType = "Content-Type"
3131
contentLength = "Content-Length"
32+
eTag = "ETag"
3233
)
3334

3435
type codings map[string]float64
@@ -64,6 +65,8 @@ type GzipResponseWriter struct {
6465
ignore bool // If true, then we immediately passthru writes to the underlying ResponseWriter.
6566
keepAcceptRanges bool // Keep "Accept-Ranges" header.
6667
setContentType bool // Add content type, if missing and detected.
68+
suffixETag string // Suffix to add to ETag header if response is compressed.
69+
dropETag bool // Drop ETag header if response is compressed (supersedes suffixETag).
6770

6871
contentTypeFilter func(ct string) bool // Only compress if the response is one of these content-types. All are accepted if empty.
6972
}
@@ -168,6 +171,21 @@ func (w *GzipResponseWriter) startGzip() error {
168171
w.Header().Del(acceptRanges)
169172
}
170173

174+
// Suffix ETag.
175+
if w.suffixETag != "" && !w.dropETag && w.Header().Get(eTag) != "" {
176+
orig := w.Header().Get(eTag)
177+
insertPoint := strings.LastIndex(orig, `"`)
178+
if insertPoint == -1 {
179+
insertPoint = len(orig)
180+
}
181+
w.Header().Set(eTag, orig[:insertPoint]+w.suffixETag+orig[insertPoint:])
182+
}
183+
184+
// Delete ETag.
185+
if w.dropETag {
186+
w.Header().Del(eTag)
187+
}
188+
171189
// Write the header to gzip response.
172190
if w.code != 0 {
173191
w.ResponseWriter.WriteHeader(w.code)
@@ -370,6 +388,8 @@ func NewWrapper(opts ...option) (func(http.Handler) http.HandlerFunc, error) {
370388
minSize: c.minSize,
371389
contentTypeFilter: c.contentTypes,
372390
keepAcceptRanges: c.keepAcceptRanges,
391+
dropETag: c.dropETag,
392+
suffixETag: c.suffixETag,
373393
buf: gw.buf,
374394
setContentType: c.setContentType,
375395
}
@@ -433,6 +453,8 @@ type config struct {
433453
contentTypes func(ct string) bool
434454
keepAcceptRanges bool
435455
setContentType bool
456+
suffixETag string
457+
dropETag bool
436458
}
437459

438460
func (c *config) validate() error {
@@ -574,6 +596,35 @@ func ContentTypeFilter(compress func(ct string) bool) option {
574596
}
575597
}
576598

599+
// SuffixETag adds the specified suffix to the ETag header (if it exists) of
600+
// responses which are compressed.
601+
//
602+
// Per [RFC 7232 Section 2.3.3](https://www.rfc-editor.org/rfc/rfc7232#section-2.3.3),
603+
// the ETag of a compressed response must differ from it's uncompressed version.
604+
//
605+
// A suffix such as "-gzip" is sometimes used as a workaround for generating a
606+
// unique new ETag (see https://bz.apache.org/bugzilla/show_bug.cgi?id=39727).
607+
func SuffixETag(suffix string) option {
608+
return func(c *config) {
609+
c.suffixETag = suffix
610+
}
611+
}
612+
613+
// DropETag removes the ETag of responses which are compressed. If DropETag is
614+
// specified in conjunction with SuffixETag, this option will take precedence
615+
// and the ETag will be dropped.
616+
//
617+
// Per [RFC 7232 Section 2.3.3](https://www.rfc-editor.org/rfc/rfc7232#section-2.3.3),
618+
// the ETag of a compressed response must differ from it's uncompressed version.
619+
//
620+
// This workaround eliminates ETag conflicts between the compressed and
621+
// uncompressed versions by removing the ETag from the compressed version.
622+
func DropETag() option {
623+
return func(c *config) {
624+
c.dropETag = true
625+
}
626+
}
627+
577628
// acceptsGzip returns true if the given HTTP request indicates that it will
578629
// accept a gzipped response.
579630
func acceptsGzip(r *http.Request) bool {

gzhttp/compress_test.go

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -180,6 +180,96 @@ func TestGzipHandlerKeepAcceptRange(t *testing.T) {
180180
assertEqual(t, testBody, got)
181181
}
182182

183+
func TestGzipHandlerSuffixETag(t *testing.T) {
184+
wrapper, err := NewWrapper(SuffixETag("-gzip"))
185+
assertNil(t, err)
186+
187+
handlerWithETag := wrapper(
188+
http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
189+
w.Header().Set("ETag", `W/"1234"`)
190+
w.WriteHeader(http.StatusOK)
191+
w.Write([]byte(testBody))
192+
}))
193+
handlerWithoutETag := wrapper(
194+
http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
195+
w.WriteHeader(http.StatusOK)
196+
w.Write([]byte(testBody))
197+
}))
198+
199+
req, _ := http.NewRequest("GET", "/gzipped", nil)
200+
req.Header.Set("Accept-Encoding", "gzip")
201+
202+
respWithEtag := httptest.NewRecorder()
203+
respWithoutEtag := httptest.NewRecorder()
204+
handlerWithETag.ServeHTTP(respWithEtag, req)
205+
handlerWithoutETag.ServeHTTP(respWithoutEtag, req)
206+
207+
resWithEtag := respWithEtag.Result()
208+
assertEqual(t, 200, resWithEtag.StatusCode)
209+
assertEqual(t, "gzip", resWithEtag.Header.Get("Content-Encoding"))
210+
assertEqual(t, `W/"1234-gzip"`, resWithEtag.Header.Get("ETag"))
211+
zr, err := gzip.NewReader(resWithEtag.Body)
212+
assertNil(t, err)
213+
got, err := io.ReadAll(zr)
214+
assertNil(t, err)
215+
assertEqual(t, testBody, got)
216+
217+
resWithoutEtag := respWithoutEtag.Result()
218+
assertEqual(t, 200, resWithoutEtag.StatusCode)
219+
assertEqual(t, "gzip", resWithoutEtag.Header.Get("Content-Encoding"))
220+
assertEqual(t, "", resWithoutEtag.Header.Get("ETag"))
221+
zr, err = gzip.NewReader(resWithoutEtag.Body)
222+
assertNil(t, err)
223+
got, err = io.ReadAll(zr)
224+
assertNil(t, err)
225+
assertEqual(t, testBody, got)
226+
}
227+
228+
func TestGzipHandlerDropETag(t *testing.T) {
229+
wrapper, err := NewWrapper(DropETag())
230+
assertNil(t, err)
231+
232+
handlerCompressed := wrapper(
233+
http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
234+
w.Header().Set("ETag", `W/"1234"`)
235+
w.WriteHeader(http.StatusOK)
236+
w.Write([]byte(testBody))
237+
}))
238+
handlerUncompressed := wrapper(
239+
http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
240+
w.Header().Set("ETag", `W/"1234"`)
241+
w.Header().Set(HeaderNoCompression, "true")
242+
w.WriteHeader(http.StatusOK)
243+
w.Write([]byte(testBody))
244+
}))
245+
246+
req, _ := http.NewRequest("GET", "/gzipped", nil)
247+
req.Header.Set("Accept-Encoding", "gzip")
248+
249+
respCompressed := httptest.NewRecorder()
250+
respUncompressed := httptest.NewRecorder()
251+
handlerCompressed.ServeHTTP(respCompressed, req)
252+
handlerUncompressed.ServeHTTP(respUncompressed, req)
253+
254+
resCompressed := respCompressed.Result()
255+
assertEqual(t, 200, resCompressed.StatusCode)
256+
assertEqual(t, "gzip", resCompressed.Header.Get("Content-Encoding"))
257+
assertEqual(t, "", resCompressed.Header.Get("ETag"))
258+
zr, err := gzip.NewReader(resCompressed.Body)
259+
assertNil(t, err)
260+
got, err := io.ReadAll(zr)
261+
assertNil(t, err)
262+
assertEqual(t, testBody, got)
263+
264+
resUncompressed := respUncompressed.Result()
265+
assertEqual(t, 200, resUncompressed.StatusCode)
266+
assertEqual(t, "", resUncompressed.Header.Get("Content-Encoding"))
267+
assertEqual(t, `W/"1234"`, resUncompressed.Header.Get("ETag"))
268+
got, err = io.ReadAll(resUncompressed.Body)
269+
assertNil(t, err)
270+
assertEqual(t, testBody, got)
271+
}
272+
183273
func TestNewGzipLevelHandler(t *testing.T) {
184274
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
185275
w.WriteHeader(http.StatusOK)

0 commit comments

Comments
 (0)