Skip to content

Commit 0f734cf

Browse files
authored
gzhttp: Add SHA256 as paranoid option (#769)
``` Benchmark2kJitter-32 67309 17580 ns/op 116.50 MB/s 3478 B/op 17 allocs/op Benchmark2kJitterParanoid-32 54398 21564 ns/op 94.97 MB/s 3438 B/op 16 allocs ``` ### Paranoid? The padding size is determined by the remainder of a CRC32 of the content. Since the payload contains elements unknown to the attacker, there is no reason to believe they can derive any information from this remainder, or predict it. However, for those that feel uncomfortable with a CRC32 being used for this can enable "paranoid" mode which will use SHA256 for determining the padding. The hashing itself is about 2 orders of magnitude slower, but in overall terms will maybe only reduce speed by 10%. Paranoid mode has no effect if buffer is < 0 (non-content aware padding).
1 parent 0ba0010 commit 0f734cf

File tree

3 files changed

+277
-18
lines changed

3 files changed

+277
-18
lines changed

gzhttp/README.md

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -235,21 +235,35 @@ but if you do, or you are in doubt, you can apply mitigations.
235235
// This should cover the sensitive part of your response.
236236
// This can be used to obfuscate the exact compressed size.
237237
// Specifying 0 will use a buffer size of 64KB.
238+
// 'paranoid' will use a slower hashing function, that MAY provide more safety.
238239
// If a negative buffer is given, the amount of jitter will not be content dependent.
239240
// This provides *less* security than applying content based jitter.
240-
func RandomJitter(n, buffer int) option {
241+
func RandomJitter(n, buffer int, paranoid bool) option
241242
...
242243
```
243244

244245
The jitter is added as a "Comment" field. This field has a 1 byte overhead, so actual extra size will be 2 -> n+1 (inclusive).
245246

246-
A good option would be to apply 32 random bytes, with default 64KB buffer: `gzhttp.RandomJitter(32, 0)`.
247+
A good option would be to apply 32 random bytes, with default 64KB buffer: `gzhttp.RandomJitter(32, 0, false)`.
247248

248249
Note that flushing the data forces the padding to be applied, which means that only data before the flush is considered for content aware padding.
249250

250251
The *padding* in the comment is the text `Padding-Padding-Padding-Padding-Pad....`
251252

252-
The *length* is `1 + sha256(payload) MOD n`, or just random from `crypto/rand` if buffer < 0.
253+
The *length* is `1 + crc32c(payload) MOD n` or `1 + sha256(payload) MOD n` (paranoid), or just random from `crypto/rand` if buffer < 0.
254+
255+
### Paranoid?
256+
257+
The padding size is determined by the remainder of a CRC32 of the content.
258+
259+
Since the payload contains elements unknown to the attacker, there is no reason to believe they can derive any information
260+
from this remainder, or predict it.
261+
262+
However, for those that feel uncomfortable with a CRC32 being used for this can enable "paranoid" mode which will use SHA256 for determining the padding.
263+
264+
The hashing itself is about 2 orders of magnitude slower, but in overall terms will maybe only reduce speed by 10%.
265+
266+
Paranoid mode has no effect if buffer is < 0 (non-content aware padding).
253267

254268
### Examples
255269

gzhttp/compress.go

Lines changed: 35 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,10 @@ import (
88
"encoding/binary"
99
"errors"
1010
"fmt"
11+
"hash/crc32"
1112
"io"
1213
"math"
14+
"math/bits"
1315
"mime"
1416
"net"
1517
"net/http"
@@ -73,6 +75,7 @@ type GzipResponseWriter struct {
7375
setContentType bool // Add content type, if missing and detected.
7476
suffixETag string // Suffix to add to ETag header if response is compressed.
7577
dropETag bool // Drop ETag header if response is compressed (supersedes suffixETag).
78+
sha256Jitter bool // Use sha256 for jitter.
7679
randomJitter []byte // Add random bytes to output as header field.
7780
jitterBuffer int // Maximum buffer to accumulate before doing jitter.
7881

@@ -167,6 +170,8 @@ func (w *GzipResponseWriter) Write(b []byte) (int, error) {
167170
return len(b), nil
168171
}
169172

173+
var castagnoliTable = crc32.MakeTable(crc32.Castagnoli)
174+
170175
// startGzip initializes a GZIP writer and writes the buffer.
171176
func (w *GzipResponseWriter) startGzip(remain []byte) error {
172177
// Set the GZIP header.
@@ -216,18 +221,32 @@ func (w *GzipResponseWriter) startGzip(remain []byte) error {
216221
if len(w.randomJitter) > 0 {
217222
var jitRNG uint32
218223
if w.jitterBuffer > 0 {
219-
h := sha256.New()
220-
h.Write(w.buf)
221-
// Use only up to "w.jitterBuffer", otherwise the output depends on write sizes.
222-
if len(remain) > 0 && len(w.buf) < w.jitterBuffer {
223-
remain := remain
224-
if len(remain)+len(w.buf) > w.jitterBuffer {
225-
remain = remain[:w.jitterBuffer-len(w.buf)]
224+
if w.sha256Jitter {
225+
h := sha256.New()
226+
h.Write(w.buf)
227+
// Use only up to "w.jitterBuffer", otherwise the output depends on write sizes.
228+
if len(remain) > 0 && len(w.buf) < w.jitterBuffer {
229+
remain := remain
230+
if len(remain)+len(w.buf) > w.jitterBuffer {
231+
remain = remain[:w.jitterBuffer-len(w.buf)]
232+
}
233+
h.Write(remain)
234+
}
235+
var tmp [sha256.BlockSize]byte
236+
jitRNG = binary.LittleEndian.Uint32(h.Sum(tmp[:0]))
237+
} else {
238+
h := crc32.New(castagnoliTable)
239+
h.Write(w.buf)
240+
// Use only up to "w.jitterBuffer", otherwise the output depends on write sizes.
241+
if len(remain) > 0 && len(w.buf) < w.jitterBuffer {
242+
remain := remain
243+
if len(remain)+len(w.buf) > w.jitterBuffer {
244+
remain = remain[:w.jitterBuffer-len(w.buf)]
245+
}
246+
h.Write(remain)
226247
}
227-
h.Write(remain)
248+
jitRNG = bits.RotateLeft32(h.Sum32(), 19) ^ 0xab0755de
228249
}
229-
var tmp [sha256.BlockSize]byte
230-
jitRNG = binary.LittleEndian.Uint32(h.Sum(tmp[:0]))
231250
} else {
232251
// Get from rand.Reader
233252
var tmp [4]byte
@@ -441,6 +460,7 @@ func NewWrapper(opts ...option) (func(http.Handler) http.HandlerFunc, error) {
441460
setContentType: c.setContentType,
442461
randomJitter: c.randomJitter,
443462
jitterBuffer: c.jitterBuffer,
463+
sha256Jitter: c.sha256Jitter,
444464
}
445465
if len(gw.buf) > 0 {
446466
gw.buf = gw.buf[:0]
@@ -507,6 +527,7 @@ type config struct {
507527
dropETag bool
508528
jitterBuffer int
509529
randomJitter []byte
530+
sha256Jitter bool
510531
}
511532

512533
func (c *config) validate() error {
@@ -692,11 +713,14 @@ func DropETag() option {
692713
// This should cover the sensitive part of your response.
693714
// This can be used to obfuscate the exact compressed size.
694715
// Specifying 0 will use a buffer size of 64KB.
716+
// 'paranoid' will use a slower hashing function, that MAY provide more safety.
717+
// See README.md for more information.
695718
// If a negative buffer is given, the amount of jitter will not be content dependent.
696719
// This provides *less* security than applying content based jitter.
697-
func RandomJitter(n, buffer int) option {
720+
func RandomJitter(n, buffer int, paranoid bool) option {
698721
return func(c *config) {
699722
if n > 0 {
723+
c.sha256Jitter = paranoid
700724
c.randomJitter = bytes.Repeat([]byte("Padding-"), 1+(n/8))
701725
c.randomJitter = c.randomJitter[:(n + 1)]
702726
c.jitterBuffer = buffer

gzhttp/compress_test.go

Lines changed: 225 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -989,7 +989,7 @@ func TestRandomJitter(t *testing.T) {
989989
t.Fatal(err)
990990
}
991991

992-
wrapper, err := NewWrapper(RandomJitter(256, 1024), MinSize(10))
992+
wrapper, err := NewWrapper(RandomJitter(256, 1024, false), MinSize(10))
993993
if err != nil {
994994
t.Fatal(err)
995995
}
@@ -1162,7 +1162,223 @@ func TestRandomJitter(t *testing.T) {
11621162
}
11631163

11641164
// Test non-content aware jitter
1165-
wrapper, err = NewWrapper(RandomJitter(256, -1), MinSize(10))
1165+
wrapper, err = NewWrapper(RandomJitter(256, -1, false), MinSize(10))
1166+
if err != nil {
1167+
t.Fatal(err)
1168+
}
1169+
handler = wrapper(writePayload)
1170+
changed = false
1171+
for i := 0; i < 10; i++ {
1172+
w = httptest.NewRecorder()
1173+
handler.ServeHTTP(w, r)
1174+
result = w.Result()
1175+
b2, err := io.ReadAll(result.Body)
1176+
if err != nil {
1177+
t.Fatal(err)
1178+
}
1179+
if i > 0 {
1180+
changed = changed || len(b2) != len(b)
1181+
}
1182+
t.Logf("attempt %d length: %d. padding: %d.", i, len(b2), len(b2)-len(refBody))
1183+
if len(b2) <= len(refBody) {
1184+
t.Errorf("no padding applied,")
1185+
}
1186+
1187+
// Do not mutate...
1188+
// Update last payload.
1189+
b = b2
1190+
}
1191+
if !changed {
1192+
t.Errorf("no change after 9 attempts")
1193+
}
1194+
}
1195+
1196+
func TestRandomJitterParanoid(t *testing.T) {
1197+
r := httptest.NewRequest("GET", "/", nil)
1198+
r.Header.Set("Accept-Encoding", "gzip")
1199+
1200+
// 4KB input, incompressible to avoid compression variations.
1201+
rng := rand.New(rand.NewSource(0))
1202+
payload := make([]byte, 4096)
1203+
_, err := io.ReadFull(rng, payload)
1204+
if err != nil {
1205+
t.Fatal(err)
1206+
}
1207+
1208+
wrapper, err := NewWrapper(RandomJitter(256, 1024, true), MinSize(10))
1209+
if err != nil {
1210+
t.Fatal(err)
1211+
}
1212+
writePayload := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
1213+
w.Write(payload)
1214+
})
1215+
referenceHandler := GzipHandler(writePayload)
1216+
w := httptest.NewRecorder()
1217+
referenceHandler.ServeHTTP(w, r)
1218+
result := w.Result()
1219+
refBody, err := io.ReadAll(result.Body)
1220+
if err != nil {
1221+
t.Fatal(err)
1222+
}
1223+
t.Logf("Unmodified length: %d", len(refBody))
1224+
1225+
handler := wrapper(writePayload)
1226+
w = httptest.NewRecorder()
1227+
handler.ServeHTTP(w, r)
1228+
1229+
result = w.Result()
1230+
b, err := io.ReadAll(result.Body)
1231+
if err != nil {
1232+
t.Fatal(err)
1233+
}
1234+
1235+
if len(refBody) == len(b) {
1236+
t.Fatal("padding was not applied")
1237+
}
1238+
1239+
if err != nil {
1240+
t.Fatal(err)
1241+
}
1242+
changed := false
1243+
for i := 0; i < 10; i++ {
1244+
w = httptest.NewRecorder()
1245+
handler.ServeHTTP(w, r)
1246+
result = w.Result()
1247+
b2, err := io.ReadAll(result.Body)
1248+
if err != nil {
1249+
t.Fatal(err)
1250+
}
1251+
changed = changed || len(b2) != len(b)
1252+
t.Logf("attempt %d length: %d. padding: %d.", i, len(b2), len(b2)-len(refBody))
1253+
if len(b2) <= len(refBody) {
1254+
t.Errorf("no padding applied,")
1255+
}
1256+
if i == 0 && changed {
1257+
t.Error("length changed without payload change", len(b), "->", len(b2))
1258+
}
1259+
// Mutate...
1260+
payload[0]++
1261+
b = b2
1262+
}
1263+
if !changed {
1264+
t.Errorf("no change after 9 attempts")
1265+
}
1266+
1267+
// Write one byte at the time to test buffer flushing.
1268+
handler = wrapper(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
1269+
for i := range payload {
1270+
w.Write([]byte{payload[i]})
1271+
}
1272+
}))
1273+
1274+
for i := 0; i < 10; i++ {
1275+
w = httptest.NewRecorder()
1276+
handler.ServeHTTP(w, r)
1277+
result = w.Result()
1278+
b2, err := io.ReadAll(result.Body)
1279+
if err != nil {
1280+
t.Fatal(err)
1281+
}
1282+
1283+
t.Logf("attempt %d length: %d. padding: %d.", i, len(b2), len(b2)-len(refBody))
1284+
if len(b2) <= len(refBody) {
1285+
t.Errorf("no padding applied,")
1286+
}
1287+
if i > 0 && len(b2) != len(b) {
1288+
t.Error("length changed without payload change", len(b), "->", len(b2))
1289+
}
1290+
// Mutate, buf after the buffer...
1291+
payload[2048]++
1292+
b = b2
1293+
}
1294+
1295+
// Write less than buffer
1296+
handler = wrapper(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
1297+
w.Write(payload[:512])
1298+
}))
1299+
changed = false
1300+
for i := 0; i < 10; i++ {
1301+
w = httptest.NewRecorder()
1302+
handler.ServeHTTP(w, r)
1303+
result = w.Result()
1304+
b2, err := io.ReadAll(result.Body)
1305+
if err != nil {
1306+
t.Fatal(err)
1307+
}
1308+
if i > 0 {
1309+
changed = changed || len(b2) != len(b)
1310+
}
1311+
t.Logf("attempt %d length: %d. padding: %d.", i, len(b2), len(b2)-512)
1312+
if len(b2) <= 512 {
1313+
t.Errorf("no padding applied,")
1314+
}
1315+
// Mutate...
1316+
payload[500]++
1317+
b = b2
1318+
}
1319+
if !changed {
1320+
t.Errorf("no change after 9 attempts")
1321+
}
1322+
1323+
// Write less than buffer, with flush in between.
1324+
// Checksum should be of all before flush.
1325+
handler = wrapper(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
1326+
w.Write(payload[:256])
1327+
w.(http.Flusher).Flush()
1328+
w.Write(payload[256:512])
1329+
}))
1330+
1331+
changed = false
1332+
for i := 0; i < 10; i++ {
1333+
w = httptest.NewRecorder()
1334+
handler.ServeHTTP(w, r)
1335+
result = w.Result()
1336+
b2, err := io.ReadAll(result.Body)
1337+
if err != nil {
1338+
t.Fatal(err)
1339+
}
1340+
if i > 0 {
1341+
changed = changed || len(b2) != len(b)
1342+
}
1343+
t.Logf("attempt %d length: %d. padding: %d.", i, len(b2), len(b2)-512)
1344+
if len(b2) <= 512 {
1345+
t.Errorf("no padding applied,")
1346+
}
1347+
// Mutate...
1348+
payload[200]++
1349+
b = b2
1350+
}
1351+
if !changed {
1352+
t.Errorf("no change after 9 attempts")
1353+
}
1354+
1355+
// Mutate *after* the flush.
1356+
// Should no longer affect length.
1357+
for i := 0; i < 10; i++ {
1358+
w = httptest.NewRecorder()
1359+
handler.ServeHTTP(w, r)
1360+
result = w.Result()
1361+
b2, err := io.ReadAll(result.Body)
1362+
if err != nil {
1363+
t.Fatal(err)
1364+
}
1365+
if i > 0 {
1366+
changed = len(b2) != len(b)
1367+
if changed {
1368+
t.Errorf("mutating after flush seems to have affected output")
1369+
}
1370+
}
1371+
t.Logf("attempt %d length: %d. padding: %d.", i, len(b2), len(b2)-512)
1372+
if len(b2) <= 512 {
1373+
t.Errorf("no padding applied,")
1374+
}
1375+
// Mutate...
1376+
payload[400]++
1377+
b = b2
1378+
}
1379+
1380+
// Test non-content aware jitter
1381+
wrapper, err = NewWrapper(RandomJitter(256, -1, true), MinSize(10))
11661382
if err != nil {
11671383
t.Fatal(err)
11681384
}
@@ -1477,10 +1693,15 @@ func BenchmarkGzipBestSpeedHandler_P100k(b *testing.B) {
14771693
}
14781694

14791695
func Benchmark2kJitter(b *testing.B) {
1480-
benchmark(b, false, 2048, CompressionLevel(gzip.BestSpeed), RandomJitter(32, 0))
1696+
benchmark(b, false, 2048, CompressionLevel(gzip.BestSpeed), RandomJitter(32, 0, false))
14811697
}
1698+
1699+
func Benchmark2kJitterParanoid(b *testing.B) {
1700+
benchmark(b, false, 2048, CompressionLevel(gzip.BestSpeed), RandomJitter(32, 0, true))
1701+
}
1702+
14821703
func Benchmark2kJitterRNG(b *testing.B) {
1483-
benchmark(b, false, 2048, CompressionLevel(gzip.BestSpeed), RandomJitter(32, -1))
1704+
benchmark(b, false, 2048, CompressionLevel(gzip.BestSpeed), RandomJitter(32, -1, false))
14841705
}
14851706

14861707
// --------------------------------------------------------------------

0 commit comments

Comments
 (0)