Skip to content

Commit 9e2912b

Browse files
authored
fix(body): avoid double gzip compression in response middleware (#124)
* fix: avoid double gzip compression in response middleware - Prevent double gzip compression when upstream response is already gzip-compressed or has a different Content-Encoding - Remove gzip headers and pass through unmodified when encountering existing compression - Add two tests verifying that middleware does not perform double compression with gzip-encoded responses and Prometheus metrics - Ensure decompression works correctly and responses are not gzip-encoded twice fix #47 Signed-off-by: appleboy <[email protected]> * test: improve test readability and consistency - Split long assertion and error message lines for improved readability in tests - Replace hardcoded "gzip" string with the gzipEncoding variable for consistency in tests - Remove trailing blank line from handler.go Signed-off-by: appleboy <[email protected]> * refactor: refactor gzip encoding handling to use centralized constant - Move the gzip encoding value to gzip.go and use the constant instead of a hardcoded string - Remove the redundant gzipEncoding constant from handler_test.go Signed-off-by: appleboy <[email protected]> --------- Signed-off-by: appleboy <[email protected]>
1 parent ff4cafa commit 9e2912b

File tree

4 files changed

+155
-4
lines changed

4 files changed

+155
-4
lines changed

gzip.go

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ const (
1616
DefaultCompression = gzip.DefaultCompression
1717
NoCompression = gzip.NoCompression
1818
HuffmanOnly = gzip.HuffmanOnly
19+
gzipEncoding = "gzip"
1920
)
2021

2122
func Gzip(level int, options ...Option) gin.HandlerFunc {
@@ -48,6 +49,21 @@ func (g *gzipWriter) Write(data []byte) (int, error) {
4849
return g.ResponseWriter.Write(data)
4950
}
5051

52+
// Check if response is already gzip-compressed by looking at Content-Encoding header
53+
// If upstream handler already set gzip encoding, pass through without double compression
54+
if contentEncoding := g.Header().Get("Content-Encoding"); contentEncoding != "" && contentEncoding != gzipEncoding {
55+
// Different encoding, remove our gzip headers and pass through
56+
g.removeGzipHeaders()
57+
return g.ResponseWriter.Write(data)
58+
} else if contentEncoding == "gzip" {
59+
// Already gzip encoded by upstream, check if this looks like gzip data
60+
if len(data) >= 2 && data[0] == 0x1f && data[1] == 0x8b {
61+
// This is already gzip data, remove our headers and pass through
62+
g.removeGzipHeaders()
63+
return g.ResponseWriter.Write(data)
64+
}
65+
}
66+
5167
return g.writer.Write(data)
5268
}
5369

gzip_test.go

Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -422,3 +422,140 @@ func TestResponseWriterHijack(t *testing.T) {
422422
router.ServeHTTP(hijackable, req)
423423
assert.True(t, hijackable.Hijacked)
424424
}
425+
426+
func TestDoubleGzipCompression(t *testing.T) {
427+
// Create a test server that returns gzip-compressed content
428+
compressedServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
429+
// Compress the response body
430+
buf := &bytes.Buffer{}
431+
gz := gzip.NewWriter(buf)
432+
_, err := gz.Write([]byte(testReverseResponse))
433+
require.NoError(t, err)
434+
require.NoError(t, gz.Close())
435+
436+
// Set gzip headers to simulate already compressed content
437+
w.Header().Set(headerContentEncoding, "gzip")
438+
w.Header().Set("Content-Length", strconv.Itoa(buf.Len()))
439+
w.WriteHeader(200)
440+
_, err = w.Write(buf.Bytes())
441+
require.NoError(t, err)
442+
}))
443+
defer compressedServer.Close()
444+
445+
// Parse the server URL for the reverse proxy
446+
target, err := url.Parse(compressedServer.URL)
447+
require.NoError(t, err)
448+
rp := httputil.NewSingleHostReverseProxy(target)
449+
450+
// Create gin router with gzip middleware
451+
router := gin.New()
452+
router.Use(Gzip(DefaultCompression))
453+
router.Any("/proxy", func(c *gin.Context) {
454+
rp.ServeHTTP(c.Writer, c.Request)
455+
})
456+
457+
// Make request through the proxy
458+
req, _ := http.NewRequestWithContext(context.Background(), "GET", "/proxy", nil)
459+
req.Header.Add(headerAcceptEncoding, "gzip")
460+
461+
w := newCloseNotifyingRecorder()
462+
router.ServeHTTP(w, req)
463+
464+
assert.Equal(t, 200, w.Code)
465+
466+
// Check if response is compressed - should still be gzip since upstream provided gzip
467+
// But it should NOT be double compressed
468+
responseBody := w.Body.Bytes()
469+
470+
// First check if the response body looks like gzip (starts with gzip magic number)
471+
if len(responseBody) >= 2 && responseBody[0] == 0x1f && responseBody[1] == 0x8b {
472+
// Response is gzip compressed, try to decompress once
473+
gr, err := gzip.NewReader(bytes.NewReader(responseBody))
474+
assert.NoError(t, err, "Response should be decompressible with single gzip decompression")
475+
defer gr.Close()
476+
477+
body, err := io.ReadAll(gr)
478+
assert.NoError(t, err)
479+
assert.Equal(t, testReverseResponse, string(body),
480+
"Response should match original content after single decompression")
481+
482+
// Ensure no double compression - decompressed content should not be gzip
483+
if len(body) >= 2 && body[0] == 0x1f && body[1] == 0x8b {
484+
t.Error("Response appears to be double-compressed - " +
485+
"single decompression revealed another gzip stream")
486+
}
487+
} else {
488+
// Response is not gzip compressed, check if content matches
489+
assert.Equal(t, testReverseResponse, w.Body.String(), "Uncompressed response should match original content")
490+
}
491+
}
492+
493+
func TestPrometheusMetricsDoubleCompression(t *testing.T) {
494+
// Simulate Prometheus metrics server that returns gzip-compressed metrics
495+
prometheusData := `# HELP http_requests_total Total number of HTTP requests
496+
# TYPE http_requests_total counter
497+
http_requests_total{method="get",status="200"} 1027 1395066363000
498+
http_requests_total{method="get",status="400"} 3 1395066363000`
499+
500+
prometheusServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
501+
// Prometheus server compresses its own response
502+
buf := &bytes.Buffer{}
503+
gz := gzip.NewWriter(buf)
504+
_, err := gz.Write([]byte(prometheusData))
505+
require.NoError(t, err)
506+
require.NoError(t, gz.Close())
507+
508+
w.Header().Set(headerContentEncoding, "gzip")
509+
w.Header().Set("Content-Type", "text/plain; version=0.0.4; charset=utf-8")
510+
w.Header().Set("Content-Length", strconv.Itoa(buf.Len()))
511+
w.WriteHeader(200)
512+
_, err = w.Write(buf.Bytes())
513+
require.NoError(t, err)
514+
}))
515+
defer prometheusServer.Close()
516+
517+
// Create reverse proxy to Prometheus server
518+
target, err := url.Parse(prometheusServer.URL)
519+
require.NoError(t, err)
520+
rp := httputil.NewSingleHostReverseProxy(target)
521+
522+
// Create gin router with gzip middleware (like what would happen in a gateway)
523+
router := gin.New()
524+
router.Use(Gzip(DefaultCompression))
525+
router.Any("/metrics", func(c *gin.Context) {
526+
rp.ServeHTTP(c.Writer, c.Request)
527+
})
528+
529+
// Simulate Prometheus scraper request
530+
req, _ := http.NewRequestWithContext(context.Background(), "GET", "/metrics", nil)
531+
req.Header.Add(headerAcceptEncoding, "gzip")
532+
req.Header.Add("User-Agent", "Prometheus/2.37.0")
533+
534+
w := newCloseNotifyingRecorder()
535+
router.ServeHTTP(w, req)
536+
537+
assert.Equal(t, 200, w.Code)
538+
539+
// Check if response is gzip compressed and handle accordingly
540+
responseBody := w.Body.Bytes()
541+
542+
// First check if the response body looks like gzip (starts with gzip magic number)
543+
if len(responseBody) >= 2 && responseBody[0] == 0x1f && responseBody[1] == 0x8b {
544+
// Response is gzip compressed, try to decompress once
545+
gr, err := gzip.NewReader(bytes.NewReader(responseBody))
546+
assert.NoError(t, err, "Prometheus should be able to decompress the metrics response")
547+
defer gr.Close()
548+
549+
body, err := io.ReadAll(gr)
550+
assert.NoError(t, err)
551+
assert.Equal(t, prometheusData, string(body), "Metrics content should be correct after decompression")
552+
553+
// Verify no double compression - decompressed content should not be gzip
554+
if len(body) >= 2 && body[0] == 0x1f && body[1] == 0x8b {
555+
t.Error("Metrics response appears to be double-compressed - Prometheus scraping would fail")
556+
}
557+
} else {
558+
// Response is not gzip compressed, check if content matches
559+
assert.Equal(t, prometheusData, w.Body.String(), "Uncompressed metrics should match original content")
560+
}
561+
}

handler_test.go

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,8 +13,6 @@ import (
1313
"github.com/stretchr/testify/assert"
1414
)
1515

16-
const gzipEncoding = "gzip"
17-
1816
func TestHandleGzip(t *testing.T) {
1917
gin.SetMode(gin.TestMode)
2018

static_test.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -179,9 +179,9 @@ func TestStaticFileGzipHeadersBug(t *testing.T) {
179179
// - Content-Encoding header will be empty instead of "gzip"
180180
// - Vary header will be empty instead of "Accept-Encoding"
181181
// - Content will not be compressed
182-
if w.Header().Get(headerContentEncoding) != "gzip" {
182+
if w.Header().Get(headerContentEncoding) != gzipEncoding {
183183
t.Errorf("BUG REPRODUCED: Static file is not being gzip compressed. Content-Encoding: %q, expected: %q",
184-
w.Header().Get(headerContentEncoding), "gzip")
184+
w.Header().Get(headerContentEncoding), gzipEncoding)
185185
}
186186

187187
if w.Header().Get(headerVary) != headerAcceptEncoding {

0 commit comments

Comments
 (0)