Skip to content
Closed
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
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` |
Expand Down
38 changes: 37 additions & 1 deletion internal/compression_handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,24 +2,32 @@ 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

if jitter > 0 {
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),
)
}

Expand All @@ -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
}
}
118 changes: 114 additions & 4 deletions internal/compression_handler_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand All @@ -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")
Expand All @@ -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")
Expand All @@ -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")
Expand All @@ -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"))
})
}
30 changes: 16 additions & 14 deletions internal/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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),
Expand Down
3 changes: 3 additions & 0 deletions internal/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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)
Expand All @@ -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) {
Expand Down
25 changes: 13 additions & 12 deletions internal/handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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 {
Expand Down
23 changes: 12 additions & 11 deletions internal/service.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down