diff --git a/README.md b/README.md index 4167010..7236ba5 100644 --- a/README.md +++ b/README.md @@ -81,6 +81,7 @@ environment variables that you can set. | `GZIP_COMPRESSION_ENABLED` | Whether to enable gzip compression for responses. Set to `0` or `false` to disable. | Enabled | | `GZIP_COMPRESSION_DISABLE_ON_AUTH` | If set to `true`, disable gzip compression for authenticated requests with `Cookie`, `Authorization`, or `X-Csrf-Token` headers. | `false` | | `GZIP_COMPRESSION_JITTER` | The amount of random jitter (in bytes) to add to the compressed response size to mitigate BREACH attacks. Set to `0` to disable. | 32 | +| `GZIP_COMPRESSION_EXCEPT_CONTENT_TYPES` | Comma-separated list of content types to exclude from gzip compression, in addition to the built-in exclusions. Matched by prefix, so `image/` excludes all image types while `image/png` excludes only PNG. Useful for already-compressed types (e.g. `image/png`, `image/webp`, `image/avif`) where compression wastes CPU and drops `Content-Length`. | None | | `X_SENDFILE_ENABLED` | Whether to enable X-Sendfile support. Set to `0` or `false` to disable. | Enabled | | `MAX_REQUEST_BODY` | The maximum size of a request body in bytes. Requests larger than this size will be refused; `0` means no maximum size is enforced. | `0` | | `STORAGE_PATH` | The path to store Thruster's internal state. Provisioned TLS certificates will be stored here, so that they will not need to be requested every time your application is started. | `./storage/thruster` | diff --git a/internal/compression_handler.go b/internal/compression_handler.go index e0dfb16..1864e41 100644 --- a/internal/compression_handler.go +++ b/internal/compression_handler.go @@ -2,11 +2,17 @@ package internal import ( "net/http" + "strings" "github.com/klauspost/compress/gzhttp" ) -func NewCompressionHandler(jitter int, disableOnAuth bool, next http.Handler) http.Handler { +func NewCompressionHandler(jitter int, disableOnAuth bool, exceptContentTypes []string, next http.Handler) http.Handler { + contentTypeFilter := gzhttp.DefaultContentTypeFilter + if len(exceptContentTypes) > 0 { + contentTypeFilter = newExceptContentTypeFilter(exceptContentTypes) + } + var wrapper func(http.Handler) http.HandlerFunc var err error @@ -14,12 +20,14 @@ func NewCompressionHandler(jitter int, disableOnAuth bool, next http.Handler) ht wrapper, err = gzhttp.NewWrapper( gzhttp.MinSize(1024), gzhttp.CompressionLevel(6), + gzhttp.ContentTypeFilter(contentTypeFilter), gzhttp.RandomJitter(jitter, 0, false), ) } else { wrapper, err = gzhttp.NewWrapper( gzhttp.MinSize(1024), gzhttp.CompressionLevel(6), + gzhttp.ContentTypeFilter(contentTypeFilter), ) } @@ -35,3 +43,31 @@ func NewCompressionHandler(jitter int, disableOnAuth bool, next http.Handler) ht return handler } + +// newExceptContentTypeFilter extends gzhttp's default filter so that any +// content type matching one of the configured prefixes is left uncompressed. +// Everything else keeps the default behaviour, so an empty list is a no-op. +func newExceptContentTypeFilter(exceptContentTypes []string) func(string) bool { + prefixes := make([]string, 0, len(exceptContentTypes)) + for _, contentType := range exceptContentTypes { + contentType = strings.TrimSpace(strings.ToLower(contentType)) + if contentType != "" { + prefixes = append(prefixes, contentType) + } + } + + return func(contentType string) bool { + if !gzhttp.DefaultContentTypeFilter(contentType) { + return false + } + + normalized := strings.TrimSpace(strings.ToLower(contentType)) + for _, prefix := range prefixes { + if strings.HasPrefix(normalized, prefix) { + return false + } + } + + return true + } +} diff --git a/internal/compression_handler_test.go b/internal/compression_handler_test.go index 4717897..81ed339 100644 --- a/internal/compression_handler_test.go +++ b/internal/compression_handler_test.go @@ -22,7 +22,7 @@ func TestCompressionHandler(t *testing.T) { }) t.Run("compresses responses", func(t *testing.T) { - handler := NewCompressionHandler(0, false, upstream) + handler := NewCompressionHandler(0, false, nil, upstream) req := httptest.NewRequest("GET", "/", nil) req.Header.Set("Accept-Encoding", "gzip") @@ -41,7 +41,7 @@ func TestCompressionHandler(t *testing.T) { }) t.Run("applies jitter when configured", func(t *testing.T) { - handler := NewCompressionHandler(32, false, upstream) + handler := NewCompressionHandler(32, false, nil, upstream) req := httptest.NewRequest("GET", "/", nil) req.Header.Set("Accept-Encoding", "gzip") @@ -59,7 +59,7 @@ func TestCompressionHandler(t *testing.T) { }) t.Run("wraps with guard when disableOnAuth is true", func(t *testing.T) { - handler := NewCompressionHandler(0, true, upstream) + handler := NewCompressionHandler(0, true, nil, upstream) req := httptest.NewRequest("GET", "/", nil) req.Header.Set("Accept-Encoding", "gzip") @@ -74,7 +74,7 @@ func TestCompressionHandler(t *testing.T) { }) t.Run("compresses authenticated requests when disableOnAuth is false", func(t *testing.T) { - handler := NewCompressionHandler(0, false, upstream) + handler := NewCompressionHandler(0, false, nil, upstream) req := httptest.NewRequest("GET", "/", nil) req.Header.Set("Accept-Encoding", "gzip") @@ -86,3 +86,113 @@ func TestCompressionHandler(t *testing.T) { assert.Equal(t, "gzip", rr.Header().Get("Content-Encoding")) }) } + +func TestCompressionHandler_exceptContentTypes(t *testing.T) { + largeBody := strings.Repeat("A", 2000) + + upstreamWithType := func(contentType string) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", contentType) + _, err := w.Write([]byte(largeBody)) + require.NoError(t, err) + }) + } + + request := func() *http.Request { + req := httptest.NewRequest("GET", "/", nil) + req.Header.Set("Accept-Encoding", "gzip") + return req + } + + t.Run("does not compress an excluded content type", func(t *testing.T) { + handler := NewCompressionHandler(0, false, []string{"image/png"}, upstreamWithType("image/png")) + + rr := httptest.NewRecorder() + handler.ServeHTTP(rr, request()) + + assert.Empty(t, rr.Header().Get("Content-Encoding")) + assert.Equal(t, largeBody, rr.Body.String()) + }) + + t.Run("still compresses other content types", func(t *testing.T) { + handler := NewCompressionHandler(0, false, []string{"image/png"}, upstreamWithType("text/plain")) + + rr := httptest.NewRecorder() + handler.ServeHTTP(rr, request()) + + assert.Equal(t, "gzip", rr.Header().Get("Content-Encoding")) + }) + + t.Run("matches by prefix", func(t *testing.T) { + handler := NewCompressionHandler(0, false, []string{"image/"}, upstreamWithType("image/webp")) + + rr := httptest.NewRecorder() + handler.ServeHTTP(rr, request()) + + assert.Empty(t, rr.Header().Get("Content-Encoding")) + assert.Equal(t, largeBody, rr.Body.String()) + }) + + t.Run("matches a non-first configured prefix", func(t *testing.T) { + handler := NewCompressionHandler(0, false, []string{"image/png", "image/webp"}, upstreamWithType("image/webp")) + + rr := httptest.NewRecorder() + handler.ServeHTTP(rr, request()) + + assert.Empty(t, rr.Header().Get("Content-Encoding")) + }) + + t.Run("matches case-insensitively against a mixed-case configured value", func(t *testing.T) { + handler := NewCompressionHandler(0, false, []string{"IMAGE/PNG"}, upstreamWithType("image/png")) + + rr := httptest.NewRecorder() + handler.ServeHTTP(rr, request()) + + assert.Empty(t, rr.Header().Get("Content-Encoding")) + }) + + t.Run("ignores blank entries instead of excluding everything", func(t *testing.T) { + handler := NewCompressionHandler(0, false, []string{"", " "}, upstreamWithType("text/plain")) + + rr := httptest.NewRecorder() + handler.ServeHTTP(rr, request()) + + assert.Equal(t, "gzip", rr.Header().Get("Content-Encoding")) + }) + + t.Run("still applies the built-in exclusions", func(t *testing.T) { + handler := NewCompressionHandler(0, false, []string{"text/html"}, upstreamWithType("image/jpeg")) + + rr := httptest.NewRecorder() + handler.ServeHTTP(rr, request()) + + assert.Empty(t, rr.Header().Get("Content-Encoding")) + }) + + t.Run("keeps the default behaviour for an empty slice", func(t *testing.T) { + handler := NewCompressionHandler(0, false, []string{}, upstreamWithType("image/png")) + + rr := httptest.NewRecorder() + handler.ServeHTTP(rr, request()) + + assert.Equal(t, "gzip", rr.Header().Get("Content-Encoding")) + }) + + t.Run("matches a content type carrying parameters", func(t *testing.T) { + handler := NewCompressionHandler(0, false, []string{"image/svg+xml"}, upstreamWithType("image/svg+xml; charset=utf-8")) + + rr := httptest.NewRecorder() + handler.ServeHTTP(rr, request()) + + assert.Empty(t, rr.Header().Get("Content-Encoding")) + }) + + t.Run("keeps the default behaviour when no exceptions are configured", func(t *testing.T) { + handler := NewCompressionHandler(0, false, nil, upstreamWithType("image/png")) + + rr := httptest.NewRecorder() + handler.ServeHTTP(rr, request()) + + assert.Equal(t, "gzip", rr.Header().Get("Content-Encoding")) + }) +} diff --git a/internal/config.go b/internal/config.go index fd2887d..202e6f2 100644 --- a/internal/config.go +++ b/internal/config.go @@ -47,13 +47,14 @@ type Config struct { UpstreamCommand string UpstreamArgs []string - CacheSizeBytes int - MaxCacheItemSizeBytes int - XSendfileEnabled bool - GzipCompressionEnabled bool - GzipCompressionDisableOnAuth bool - GzipCompressionJitter int - MaxRequestBody int + CacheSizeBytes int + MaxCacheItemSizeBytes int + XSendfileEnabled bool + GzipCompressionEnabled bool + GzipCompressionDisableOnAuth bool + GzipCompressionJitter int + GzipCompressionExceptContentTypes []string + MaxRequestBody int TLSDomains []string ACMEDirectoryURL string @@ -91,13 +92,14 @@ func NewConfig() (*Config, error) { UpstreamCommand: os.Args[1], UpstreamArgs: os.Args[2:], - CacheSizeBytes: getEnvInt("CACHE_SIZE", defaultCacheSize), - MaxCacheItemSizeBytes: getEnvInt("MAX_CACHE_ITEM_SIZE", defaultMaxCacheItemSizeBytes), - XSendfileEnabled: getEnvBool("X_SENDFILE_ENABLED", true), - GzipCompressionEnabled: getEnvBool("GZIP_COMPRESSION_ENABLED", true), - GzipCompressionDisableOnAuth: getEnvBool("GZIP_COMPRESSION_DISABLE_ON_AUTH", defaultGzipCompressionDisableOnAuth), - GzipCompressionJitter: getEnvInt("GZIP_COMPRESSION_JITTER", defaultGzipCompressionJitter), - MaxRequestBody: getEnvInt("MAX_REQUEST_BODY", defaultMaxRequestBody), + CacheSizeBytes: getEnvInt("CACHE_SIZE", defaultCacheSize), + MaxCacheItemSizeBytes: getEnvInt("MAX_CACHE_ITEM_SIZE", defaultMaxCacheItemSizeBytes), + XSendfileEnabled: getEnvBool("X_SENDFILE_ENABLED", true), + GzipCompressionEnabled: getEnvBool("GZIP_COMPRESSION_ENABLED", true), + GzipCompressionDisableOnAuth: getEnvBool("GZIP_COMPRESSION_DISABLE_ON_AUTH", defaultGzipCompressionDisableOnAuth), + GzipCompressionJitter: getEnvInt("GZIP_COMPRESSION_JITTER", defaultGzipCompressionJitter), + GzipCompressionExceptContentTypes: getEnvStrings("GZIP_COMPRESSION_EXCEPT_CONTENT_TYPES", []string{}), + MaxRequestBody: getEnvInt("MAX_REQUEST_BODY", defaultMaxRequestBody), TLSDomains: getEnvStrings("TLS_DOMAIN", []string{}), ACMEDirectoryURL: getEnvString("ACME_DIRECTORY", defaultACMEDirectoryURL), diff --git a/internal/config_test.go b/internal/config_test.go index 5dc1d9b..a7eb4cb 100644 --- a/internal/config_test.go +++ b/internal/config_test.go @@ -106,6 +106,7 @@ func TestConfig_defaults(t *testing.T) { assert.Equal(t, defaultCacheSize, c.CacheSizeBytes) assert.Equal(t, slog.LevelInfo, c.LogLevel) assert.Equal(t, false, c.H2CEnabled) + assert.Equal(t, []string{}, c.GzipCompressionExceptContentTypes) } func TestConfig_override_defaults_with_env_vars(t *testing.T) { @@ -121,6 +122,7 @@ func TestConfig_override_defaults_with_env_vars(t *testing.T) { usingEnvVar(t, "H2C_ENABLED", "true") usingEnvVar(t, "GZIP_COMPRESSION_DISABLE_ON_AUTH", "true") usingEnvVar(t, "GZIP_COMPRESSION_JITTER", "64") + usingEnvVar(t, "GZIP_COMPRESSION_EXCEPT_CONTENT_TYPES", "image/png, image/webp") c, err := NewConfig() require.NoError(t, err) @@ -136,6 +138,7 @@ func TestConfig_override_defaults_with_env_vars(t *testing.T) { assert.Equal(t, true, c.H2CEnabled) assert.Equal(t, true, c.GzipCompressionDisableOnAuth) assert.Equal(t, 64, c.GzipCompressionJitter) + assert.Equal(t, []string{"image/png", "image/webp"}, c.GzipCompressionExceptContentTypes) } func TestConfig_override_defaults_with_env_vars_using_prefix(t *testing.T) { diff --git a/internal/handler.go b/internal/handler.go index 1758b46..e7826ee 100644 --- a/internal/handler.go +++ b/internal/handler.go @@ -7,17 +7,18 @@ import ( ) type HandlerOptions struct { - badGatewayPage string - cache Cache - maxCacheableResponseBody int - maxRequestBody int - targetUrl *url.URL - xSendfileEnabled bool - gzipCompressionEnabled bool - gzipCompressionDisableOnAuth bool - gzipCompressionJitter int - forwardHeaders bool - logRequests bool + badGatewayPage string + cache Cache + maxCacheableResponseBody int + maxRequestBody int + targetUrl *url.URL + xSendfileEnabled bool + gzipCompressionEnabled bool + gzipCompressionDisableOnAuth bool + gzipCompressionJitter int + gzipCompressionExceptContentTypes []string + forwardHeaders bool + logRequests bool } func NewHandler(options HandlerOptions) http.Handler { @@ -27,7 +28,7 @@ func NewHandler(options HandlerOptions) http.Handler { handler = NewRequestStartHandler(handler) if options.gzipCompressionEnabled { - handler = NewCompressionHandler(options.gzipCompressionJitter, options.gzipCompressionDisableOnAuth, handler) + handler = NewCompressionHandler(options.gzipCompressionJitter, options.gzipCompressionDisableOnAuth, options.gzipCompressionExceptContentTypes, handler) } if options.maxRequestBody > 0 { diff --git a/internal/service.go b/internal/service.go index c34ba73..a4cb97b 100644 --- a/internal/service.go +++ b/internal/service.go @@ -19,17 +19,18 @@ func NewService(config *Config) *Service { func (s *Service) Run() int { handlerOptions := HandlerOptions{ - cache: s.cache(), - targetUrl: s.targetUrl(), - xSendfileEnabled: s.config.XSendfileEnabled, - gzipCompressionEnabled: s.config.GzipCompressionEnabled, - maxCacheableResponseBody: s.config.MaxCacheItemSizeBytes, - maxRequestBody: s.config.MaxRequestBody, - badGatewayPage: s.config.BadGatewayPage, - forwardHeaders: s.config.ForwardHeaders, - logRequests: s.config.LogRequests, - gzipCompressionDisableOnAuth: s.config.GzipCompressionDisableOnAuth, - gzipCompressionJitter: s.config.GzipCompressionJitter, + cache: s.cache(), + targetUrl: s.targetUrl(), + xSendfileEnabled: s.config.XSendfileEnabled, + gzipCompressionEnabled: s.config.GzipCompressionEnabled, + maxCacheableResponseBody: s.config.MaxCacheItemSizeBytes, + maxRequestBody: s.config.MaxRequestBody, + badGatewayPage: s.config.BadGatewayPage, + forwardHeaders: s.config.ForwardHeaders, + logRequests: s.config.LogRequests, + gzipCompressionDisableOnAuth: s.config.GzipCompressionDisableOnAuth, + gzipCompressionJitter: s.config.GzipCompressionJitter, + gzipCompressionExceptContentTypes: s.config.GzipCompressionExceptContentTypes, } handler := NewHandler(handlerOptions)