Skip to content

Commit 0e59a41

Browse files
committed
Mitigate BREACH attacks with random jitter and optional compression guard
"Heal-the-BREACH" philosophy: don't disable compression globally; instead, make it noisy by default. Mitigations: * Jitter for length hiding with randomized padding. * Optional compression disabling for sensitive responses. New options: * `GZIP_COMPRESSION_JITTER`. Default: 32 (bytes). The amount of random jitter (in bytes) to add to the compressed response size to mitigate BREACH attacks. Set to `0` to disable. * `GZIP_COMPRESSION_DISABLE_ON_AUTH`. Default: false. Whether to disable gzip compression for authenticated requests having `Cookie`/`Authorization`/`X-Csrf-Token` headers.
1 parent 26f6847 commit 0e59a41

13 files changed

+419
-35
lines changed

CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,7 @@
1+
## v0.1.17 / unreleased
2+
3+
* Mitigate BREACH attacks with random jitter and optional compression guard (#102)
4+
15
## v0.1.16 / 2025-10-19
26

37
* Build with Go 1.25 (#93)

README.md

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,8 @@ environment variables that you can set.
7979
| `CACHE_SIZE` | The size of the HTTP cache in bytes. | 64MB |
8080
| `MAX_CACHE_ITEM_SIZE` | The maximum size of a single item in the HTTP cache in bytes. | 1MB |
8181
| `GZIP_COMPRESSION_ENABLED` | Whether to enable gzip compression for static assets. Set to `0` or `false` to disable. | Enabled |
82+
| `GZIP_COMPRESSION_DISABLE_ON_AUTH` | If set to `true`, disable gzip compression for authenticated requests with `Cookie`, `Authorization`, or `X-Csrf-Token` headers. | `false` |
83+
| `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 |
8284
| `X_SENDFILE_ENABLED` | Whether to enable X-Sendfile support. Set to `0` or `false` to disable. | Enabled |
8385
| `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` |
8486
| `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` |
@@ -100,3 +102,14 @@ To prevent naming clashes with your application's own environment variables,
100102
Thruster's environment variables can optionally be prefixed with `THRUSTER_`.
101103
For example, `TLS_DOMAIN` can also be written as `THRUSTER_TLS_DOMAIN`. Whenever
102104
a prefixed variable is set, it will take precedence over the unprefixed version.
105+
106+
## Security
107+
108+
### BREACH Mitigation
109+
110+
Thruster includes built-in mitigation for the [BREACH attack](https://breachattack.com/), which allows attackers to extract secrets from compressed encrypted traffic.
111+
112+
1. **Random Jitter (Enabled by Default)**: Thruster adds a random amount of "jitter" (padding) to the size of compressed responses. This makes it significantly harder for attackers to infer the content based on the compressed size. The default jitter is 32 bytes, controlled by `GZIP_COMPRESSION_JITTER`.
113+
2. **Compression Guard (Optional)**: For higher security, you can disable compression entirely for authenticated requests (requests containing `Cookie`, `Authorization`, or `X-Csrf-Token` headers) by setting `GZIP_COMPRESSION_DISABLE_ON_AUTH=true`. This eliminates the side-channel entirely for sensitive traffic but may increase bandwidth usage.
114+
115+
By default, Thruster prioritizes performance while providing baseline protection via jitter. Operators with strict security requirements should consider enabling the Compression Guard.

go.mod

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,17 +3,17 @@ module github.com/basecamp/thruster
33
go 1.25.3
44

55
require (
6-
github.com/klauspost/compress v1.17.4
6+
github.com/klauspost/compress v1.18.1
77
github.com/stretchr/testify v1.8.4
88
golang.org/x/crypto v0.37.0
9+
golang.org/x/net v0.39.0
910
)
1011

1112
require (
1213
github.com/davecgh/go-spew v1.1.1 // indirect
1314
github.com/kr/text v0.2.0 // indirect
1415
github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e // indirect
1516
github.com/pmezard/go-difflib v1.0.0 // indirect
16-
golang.org/x/net v0.39.0 // indirect
1717
golang.org/x/text v0.24.0 // indirect
1818
gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f // indirect
1919
gopkg.in/yaml.v3 v3.0.1 // indirect

go.sum

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
22
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
33
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
4-
github.com/klauspost/compress v1.17.4 h1:Ej5ixsIri7BrIjBkRZLTo6ghwrEtHFk7ijlczPW4fZ4=
5-
github.com/klauspost/compress v1.17.4/go.mod h1:/dCuZOvVtNoHsyb+cuJD3itjs3NbnF6KH9zAO4BDxPM=
4+
github.com/klauspost/compress v1.18.1 h1:bcSGx7UbpBqMChDtsF28Lw6v/G94LPrrbMbdC3JH2co=
5+
github.com/klauspost/compress v1.18.1/go.mod h1:ZQFFVG+MdnR0P+l6wpXgIL4NTtwiKIdBnrBd8Nrxr+0=
66
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
77
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
88
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
package internal
2+
3+
import (
4+
"bufio"
5+
"net"
6+
"net/http"
7+
"strings"
8+
9+
"github.com/klauspost/compress/gzhttp"
10+
)
11+
12+
func NewCompressionGuardMiddleware(next http.Handler) http.Handler {
13+
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
14+
// Check for user-specific headers in the request
15+
if hasUserSpecificRequestHeaders(r) {
16+
w.Header().Set(gzhttp.HeaderNoCompression, "1")
17+
}
18+
19+
// Wrap the ResponseWriter to check for user-specific headers in the response
20+
wrappedWriter := &compressionGuardResponseWriter{ResponseWriter: w}
21+
next.ServeHTTP(wrappedWriter, r)
22+
})
23+
}
24+
25+
func hasUserSpecificRequestHeaders(r *http.Request) bool {
26+
return r.Header.Get("Cookie") != "" ||
27+
r.Header.Get("Authorization") != "" ||
28+
r.Header.Get("X-Csrf-Token") != ""
29+
}
30+
31+
type compressionGuardResponseWriter struct {
32+
http.ResponseWriter
33+
wroteHeader bool
34+
}
35+
36+
func (w *compressionGuardResponseWriter) WriteHeader(statusCode int) {
37+
if w.wroteHeader {
38+
return
39+
}
40+
w.wroteHeader = true
41+
42+
// Check for user-specific headers in the response
43+
if hasUserSpecificResponseHeaders(w.Header()) {
44+
w.Header().Set(gzhttp.HeaderNoCompression, "1")
45+
}
46+
47+
w.ResponseWriter.WriteHeader(statusCode)
48+
}
49+
50+
func (w *compressionGuardResponseWriter) Write(b []byte) (int, error) {
51+
if !w.wroteHeader {
52+
w.WriteHeader(http.StatusOK)
53+
}
54+
return w.ResponseWriter.Write(b)
55+
}
56+
57+
func hasUserSpecificResponseHeaders(h http.Header) bool {
58+
if h.Get("Set-Cookie") != "" {
59+
return true
60+
}
61+
62+
cacheControl := strings.ToLower(h.Get("Cache-Control"))
63+
for _, directive := range strings.Split(cacheControl, ",") {
64+
dir := strings.TrimSpace(directive)
65+
// Strip any value (e.g. private="Set-Cookie") before comparison.
66+
dirName := strings.SplitN(dir, "=", 2)[0]
67+
if dirName == "private" || dirName == "no-store" {
68+
return true
69+
}
70+
}
71+
72+
vary := h.Get("Vary")
73+
for _, token := range strings.Split(vary, ",") {
74+
if strings.EqualFold(strings.TrimSpace(token), "cookie") {
75+
return true
76+
}
77+
}
78+
79+
return false
80+
}
81+
82+
// Flush implements http.Flusher
83+
func (w *compressionGuardResponseWriter) Flush() {
84+
if flusher, ok := w.ResponseWriter.(http.Flusher); ok {
85+
flusher.Flush()
86+
}
87+
}
88+
89+
// Hijack implements http.Hijacker
90+
func (w *compressionGuardResponseWriter) Hijack() (net.Conn, *bufio.ReadWriter, error) {
91+
if hijacker, ok := w.ResponseWriter.(http.Hijacker); ok {
92+
return hijacker.Hijack()
93+
}
94+
return nil, nil, http.ErrNotSupported
95+
}
Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
package internal
2+
3+
import (
4+
"net/http"
5+
"net/http/httptest"
6+
"testing"
7+
8+
"github.com/klauspost/compress/gzhttp"
9+
"github.com/stretchr/testify/assert"
10+
)
11+
12+
func TestCompressionGuardMiddleware(t *testing.T) {
13+
tests := []struct {
14+
name string
15+
requestHeaders map[string]string
16+
responseHeader map[string]string
17+
wantNoCompress bool
18+
}{
19+
{
20+
name: "No auth headers",
21+
requestHeaders: map[string]string{},
22+
wantNoCompress: false,
23+
},
24+
{
25+
name: "Cookie header present",
26+
requestHeaders: map[string]string{"Cookie": "session=123"},
27+
wantNoCompress: true,
28+
},
29+
{
30+
name: "Authorization header present",
31+
requestHeaders: map[string]string{"Authorization": "Bearer token"},
32+
wantNoCompress: true,
33+
},
34+
{
35+
name: "X-Csrf-Token header present",
36+
requestHeaders: map[string]string{"X-Csrf-Token": "token"},
37+
wantNoCompress: true,
38+
},
39+
{
40+
name: "Set-Cookie response header",
41+
responseHeader: map[string]string{"Set-Cookie": "session=123"},
42+
wantNoCompress: true,
43+
},
44+
{
45+
name: "Cache-Control private response header",
46+
responseHeader: map[string]string{"Cache-Control": "private, max-age=3600"},
47+
wantNoCompress: true,
48+
},
49+
{
50+
name: "Cache-Control no-store response header",
51+
responseHeader: map[string]string{"Cache-Control": "no-store"},
52+
wantNoCompress: true,
53+
},
54+
{
55+
name: "Cache-Control private directive with value",
56+
responseHeader: map[string]string{"Cache-Control": `public, private="Set-Cookie"`},
57+
wantNoCompress: true,
58+
},
59+
{
60+
name: "Cache-Control token parsing avoids false positives",
61+
responseHeader: map[string]string{"Cache-Control": "public, my-private-setting=value"},
62+
wantNoCompress: false,
63+
},
64+
{
65+
name: "Vary Cookie response header",
66+
responseHeader: map[string]string{"Vary": "Cookie"},
67+
wantNoCompress: true,
68+
},
69+
{
70+
name: "Vary token parsing avoids false positives",
71+
responseHeader: map[string]string{"Vary": "Accept-Encoding, Cookie-Name"},
72+
wantNoCompress: false,
73+
},
74+
{
75+
name: "Case-insensitive header checks (response)",
76+
responseHeader: map[string]string{"cache-control": "PRIVATE"},
77+
wantNoCompress: true,
78+
},
79+
{
80+
name: "Case-insensitive header checks (request)",
81+
requestHeaders: map[string]string{"cookie": "session=123"},
82+
wantNoCompress: true,
83+
},
84+
}
85+
86+
for _, tt := range tests {
87+
t.Run(tt.name, func(t *testing.T) {
88+
handler := NewCompressionGuardMiddleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
89+
for k, v := range tt.responseHeader {
90+
w.Header().Set(k, v)
91+
}
92+
w.WriteHeader(http.StatusOK)
93+
}))
94+
95+
req := httptest.NewRequest("GET", "/", nil)
96+
for k, v := range tt.requestHeaders {
97+
req.Header.Set(k, v)
98+
}
99+
100+
rr := httptest.NewRecorder()
101+
handler.ServeHTTP(rr, req)
102+
103+
if tt.wantNoCompress {
104+
assert.Equal(t, "1", rr.Header().Get(gzhttp.HeaderNoCompression))
105+
} else {
106+
assert.Empty(t, rr.Header().Get(gzhttp.HeaderNoCompression))
107+
}
108+
})
109+
}
110+
}

internal/config.go

Lines changed: 17 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -37,18 +37,23 @@ const (
3737

3838
defaultLogLevel = slog.LevelInfo
3939
defaultLogRequests = true
40+
41+
defaultGzipCompressionDisableOnAuth = false
42+
defaultGzipCompressionJitter = 32
4043
)
4144

4245
type Config struct {
4346
TargetPort int
4447
UpstreamCommand string
4548
UpstreamArgs []string
4649

47-
CacheSizeBytes int
48-
MaxCacheItemSizeBytes int
49-
XSendfileEnabled bool
50-
GzipCompressionEnabled bool
51-
MaxRequestBody int
50+
CacheSizeBytes int
51+
MaxCacheItemSizeBytes int
52+
XSendfileEnabled bool
53+
GzipCompressionEnabled bool
54+
GzipCompressionDisableOnAuth bool
55+
GzipCompressionJitter int
56+
MaxRequestBody int
5257

5358
TLSDomains []string
5459
ACMEDirectoryURL string
@@ -86,11 +91,13 @@ func NewConfig() (*Config, error) {
8691
UpstreamCommand: os.Args[1],
8792
UpstreamArgs: os.Args[2:],
8893

89-
CacheSizeBytes: getEnvInt("CACHE_SIZE", defaultCacheSize),
90-
MaxCacheItemSizeBytes: getEnvInt("MAX_CACHE_ITEM_SIZE", defaultMaxCacheItemSizeBytes),
91-
XSendfileEnabled: getEnvBool("X_SENDFILE_ENABLED", true),
92-
GzipCompressionEnabled: getEnvBool("GZIP_COMPRESSION_ENABLED", true),
93-
MaxRequestBody: getEnvInt("MAX_REQUEST_BODY", defaultMaxRequestBody),
94+
CacheSizeBytes: getEnvInt("CACHE_SIZE", defaultCacheSize),
95+
MaxCacheItemSizeBytes: getEnvInt("MAX_CACHE_ITEM_SIZE", defaultMaxCacheItemSizeBytes),
96+
XSendfileEnabled: getEnvBool("X_SENDFILE_ENABLED", true),
97+
GzipCompressionEnabled: getEnvBool("GZIP_COMPRESSION_ENABLED", true),
98+
GzipCompressionDisableOnAuth: getEnvBool("GZIP_COMPRESSION_DISABLE_ON_AUTH", defaultGzipCompressionDisableOnAuth),
99+
GzipCompressionJitter: getEnvInt("GZIP_COMPRESSION_JITTER", defaultGzipCompressionJitter),
100+
MaxRequestBody: getEnvInt("MAX_REQUEST_BODY", defaultMaxRequestBody),
94101

95102
TLSDomains: getEnvStrings("TLS_DOMAIN", []string{}),
96103
ACMEDirectoryURL: getEnvString("ACME_DIRECTORY", defaultACMEDirectoryURL),

internal/config_test.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -119,6 +119,8 @@ func TestConfig_override_defaults_with_env_vars(t *testing.T) {
119119
usingEnvVar(t, "ACME_DIRECTORY", "https://acme-staging-v02.api.letsencrypt.org/directory")
120120
usingEnvVar(t, "LOG_REQUESTS", "false")
121121
usingEnvVar(t, "H2C_ENABLED", "true")
122+
usingEnvVar(t, "GZIP_COMPRESSION_DISABLE_ON_AUTH", "true")
123+
usingEnvVar(t, "GZIP_COMPRESSION_JITTER", "64")
122124

123125
c, err := NewConfig()
124126
require.NoError(t, err)
@@ -132,6 +134,8 @@ func TestConfig_override_defaults_with_env_vars(t *testing.T) {
132134
assert.Equal(t, "https://acme-staging-v02.api.letsencrypt.org/directory", c.ACMEDirectoryURL)
133135
assert.Equal(t, false, c.LogRequests)
134136
assert.Equal(t, true, c.H2CEnabled)
137+
assert.Equal(t, true, c.GzipCompressionDisableOnAuth)
138+
assert.Equal(t, 64, c.GzipCompressionJitter)
135139
}
136140

137141
func TestConfig_override_defaults_with_env_vars_using_prefix(t *testing.T) {

internal/handler.go

Lines changed: 40 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -9,15 +9,17 @@ import (
99
)
1010

1111
type HandlerOptions struct {
12-
badGatewayPage string
13-
cache Cache
14-
maxCacheableResponseBody int
15-
maxRequestBody int
16-
targetUrl *url.URL
17-
xSendfileEnabled bool
18-
gzipCompressionEnabled bool
19-
forwardHeaders bool
20-
logRequests bool
12+
badGatewayPage string
13+
cache Cache
14+
maxCacheableResponseBody int
15+
maxRequestBody int
16+
targetUrl *url.URL
17+
xSendfileEnabled bool
18+
gzipCompressionEnabled bool
19+
gzipCompressionDisableOnAuth bool
20+
gzipCompressionJitter int
21+
forwardHeaders bool
22+
logRequests bool
2123
}
2224

2325
func NewHandler(options HandlerOptions) http.Handler {
@@ -27,7 +29,35 @@ func NewHandler(options HandlerOptions) http.Handler {
2729
handler = NewRequestStartMiddleware(handler)
2830

2931
if options.gzipCompressionEnabled {
30-
handler = gzhttp.GzipHandler(handler)
32+
var wrapper func(http.Handler) http.HandlerFunc
33+
var err error
34+
35+
if options.gzipCompressionJitter > 0 {
36+
wrapper, err = gzhttp.NewWrapper(
37+
gzhttp.MinSize(1024),
38+
gzhttp.CompressionLevel(6),
39+
gzhttp.RandomJitter(options.gzipCompressionJitter, 0, false),
40+
)
41+
} else {
42+
wrapper, err = gzhttp.NewWrapper(
43+
gzhttp.MinSize(1024),
44+
gzhttp.CompressionLevel(6),
45+
)
46+
}
47+
48+
if err != nil {
49+
// If we cannot create the wrapper with the requested configuration (including jitter),
50+
// we must fail hard rather than silently downgrading security or performance.
51+
panic("failed to create gzip wrapper: " + err.Error())
52+
}
53+
54+
gzipHandler := wrapper(handler)
55+
56+
if options.gzipCompressionDisableOnAuth {
57+
handler = NewCompressionGuardMiddleware(gzipHandler)
58+
} else {
59+
handler = gzipHandler
60+
}
3161
}
3262

3363
if options.maxRequestBody > 0 {

0 commit comments

Comments
 (0)