From cc95cb8542b735110343b687f021988f6475f880 Mon Sep 17 00:00:00 2001 From: evilgensec Date: Thu, 7 May 2026 12:07:19 +0545 Subject: [PATCH 1/6] rpc_util: limit decompression size in legacy gzipDecompressor to prevent OOM The legacy gzipDecompressor.Do (used when the server is configured with the deprecated grpc.RPCDecompressor option) calls io.ReadAll(z) with no bound, materializing the entire decompressed payload in memory before decompress() can check len(uncompressed) > maxReceiveMessageSize. A client can send a highly compressed gRPC frame (e.g. 1 KiB of gzip that expands to 1 GiB) and force the server to allocate and fill a gigabyte buffer before the size limit fires. The default server path (encoding.Compressor) already uses io.LimitReader(limit+1) to prevent this; the deprecated path did not. Fix: wrap the reader passed to dc.Do with io.LimitReader(maxReceiveMessageSize+1) so that decompression stops at the limit boundary. The existing len(uncompressed) > maxReceiveMessageSize check then fires as before, but no more than maxReceiveMessageSize+1 bytes are ever buffered. --- rpc_util.go | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/rpc_util.go b/rpc_util.go index ec6af710490f..b095adb2d03d 100644 --- a/rpc_util.go +++ b/rpc_util.go @@ -971,7 +971,12 @@ func recvAndDecompress(p *parser, s recvCompressor, dc Decompressor, maxReceiveM func decompress(compressor encoding.Compressor, d mem.BufferSlice, dc Decompressor, maxReceiveMessageSize int, pool mem.BufferPool) (mem.BufferSlice, error) { if dc != nil { r := d.Reader() - uncompressed, err := dc.Do(r) + // Limit decompression to maxReceiveMessageSize+1 bytes so that the + // size check below fires before io.ReadAll buffers the full payload. + // Without this limit a client can send a tiny compressed message that + // expands to many gigabytes, exhausting server memory before the size + // check is reached. + uncompressed, err := dc.Do(io.LimitReader(r, int64(maxReceiveMessageSize)+1)) if err != nil { r.Close() // ensure buffers are reused return nil, status.Errorf(codes.Internal, "grpc: failed to decompress the received message: %v", err) From ee97a0a68a3a35b16e9d435169bf50dc162b738a Mon Sep 17 00:00:00 2001 From: evilgensec Date: Thu, 7 May 2026 20:56:08 +0545 Subject: [PATCH 2/6] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- rpc_util.go | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/rpc_util.go b/rpc_util.go index b095adb2d03d..5b7f3f803667 100644 --- a/rpc_util.go +++ b/rpc_util.go @@ -976,7 +976,11 @@ func decompress(compressor encoding.Compressor, d mem.BufferSlice, dc Decompress // Without this limit a client can send a tiny compressed message that // expands to many gigabytes, exhausting server memory before the size // check is reached. - uncompressed, err := dc.Do(io.LimitReader(r, int64(maxReceiveMessageSize)+1)) + reader := io.Reader(r) + if limit := int64(maxReceiveMessageSize); limit < math.MaxInt64 { + reader = io.LimitReader(r, limit+1) + } + uncompressed, err := dc.Do(reader) if err != nil { r.Close() // ensure buffers are reused return nil, status.Errorf(codes.Internal, "grpc: failed to decompress the received message: %v", err) From 50ab2516b3d110489f0a933309d074eadaa77c60 Mon Sep 17 00:00:00 2001 From: evilgensec Date: Thu, 21 May 2026 13:27:10 +0545 Subject: [PATCH 3/6] rpc_util: limit decompressed size inside legacy gzipDecompressor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per review feedback, the previous patch wrapped the *compressed* reader with io.LimitReader, which only constrained input bytes — that check already exists in parser.recvMsg via MaxRecvMsgSize. It did not bound the decompressed payload, so a zip-bomb frame could still expand unbounded inside io.ReadAll. Move the LimitReader so it wraps the gzip.Reader output instead of the input. Since Decompressor.Do has no max-size parameter, add an internal doWithMaxSize helper on *gzipDecompressor and type-assert at the decompress() call site to invoke it. Third-party Decompressor implementations keep the existing Do behavior. Add TestDecompress_LegacyGzipBomb and TestDecompress_LegacyGzipUnderLimit covering the over-limit and under-limit cases on the legacy path. --- rpc_util.go | 38 ++++++++++++++++++++++++++++---------- rpc_util_test.go | 38 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 66 insertions(+), 10 deletions(-) diff --git a/rpc_util.go b/rpc_util.go index 5b7f3f803667..43835d7845a0 100644 --- a/rpc_util.go +++ b/rpc_util.go @@ -128,6 +128,16 @@ func NewGZIPDecompressor() Decompressor { } func (d *gzipDecompressor) Do(r io.Reader) ([]byte, error) { + return d.doWithMaxSize(r, math.MaxInt64) +} + +// doWithMaxSize behaves like Do but caps the size of the decompressed +// payload at maxMessageSize+1 bytes. The Decompressor interface does not +// allow extra parameters, so callers inside the package type-assert to +// *gzipDecompressor to invoke this method directly. The +1 byte makes it +// possible for the caller to detect that the limit was exceeded and +// return ResourceExhausted instead of materializing an unbounded payload. +func (d *gzipDecompressor) doWithMaxSize(r io.Reader, maxMessageSize int64) ([]byte, error) { var z *gzip.Reader switch maybeZ := d.pool.Get().(type) { case nil: @@ -148,7 +158,11 @@ func (d *gzipDecompressor) Do(r io.Reader) ([]byte, error) { z.Close() d.pool.Put(z) }() - return io.ReadAll(z) + var src io.Reader = z + if maxMessageSize < math.MaxInt64 { + src = io.LimitReader(z, maxMessageSize+1) + } + return io.ReadAll(src) } func (d *gzipDecompressor) Type() string { @@ -971,16 +985,20 @@ func recvAndDecompress(p *parser, s recvCompressor, dc Decompressor, maxReceiveM func decompress(compressor encoding.Compressor, d mem.BufferSlice, dc Decompressor, maxReceiveMessageSize int, pool mem.BufferPool) (mem.BufferSlice, error) { if dc != nil { r := d.Reader() - // Limit decompression to maxReceiveMessageSize+1 bytes so that the - // size check below fires before io.ReadAll buffers the full payload. - // Without this limit a client can send a tiny compressed message that - // expands to many gigabytes, exhausting server memory before the size - // check is reached. - reader := io.Reader(r) - if limit := int64(maxReceiveMessageSize); limit < math.MaxInt64 { - reader = io.LimitReader(r, limit+1) + // For the built-in gzip decompressor, bound the decompressed output + // at maxReceiveMessageSize+1 so that a small but highly compressed + // payload (a "zip bomb") cannot expand to gigabytes in memory before + // the post-decompression size check below has a chance to fire. The + // Decompressor interface does not accept an extra size parameter, + // so we type-assert to invoke a size-aware helper. Third-party + // Decompressor implementations keep the original Do behavior. + var uncompressed []byte + var err error + if gd, ok := dc.(*gzipDecompressor); ok { + uncompressed, err = gd.doWithMaxSize(r, int64(maxReceiveMessageSize)) + } else { + uncompressed, err = dc.Do(r) } - uncompressed, err := dc.Do(reader) if err != nil { r.Close() // ensure buffers are reused return nil, status.Errorf(codes.Internal, "grpc: failed to decompress the received message: %v", err) diff --git a/rpc_util_test.go b/rpc_util_test.go index 79628d1be1d1..b8a41c94e4c3 100644 --- a/rpc_util_test.go +++ b/rpc_util_test.go @@ -536,6 +536,44 @@ func (s) TestDecompress(t *testing.T) { } } +// TestDecompress_LegacyGzipBomb verifies that the legacy gzipDecompressor +// path bounds the decompressed payload at maxReceiveMessageSize+1 bytes +// instead of buffering the full expansion in memory. The input here is a +// small compressed payload that expands to many times the receive limit; +// without the bound, decompress() would materialize the entire expansion +// before the size check fires. +func (s) TestDecompress_LegacyGzipBomb(t *testing.T) { + // 1 MiB of zeros gzips down to a few KiB at most. + payload := make([]byte, 1<<20) + input := compressWithDeterministicError(t, payload) + + const maxRecv = 1024 + out, err := decompress(nil, input, NewGZIPDecompressor(), maxRecv, mem.DefaultBufferPool()) + if out != nil { + t.Errorf("decompress() returned non-nil output for over-limit payload") + } + wantErr := status.Errorf(codes.ResourceExhausted, "grpc: message after decompression larger than max (%d vs. %d)", maxRecv+1, maxRecv) + if !cmp.Equal(err, wantErr, cmpopts.EquateErrors()) { + t.Fatalf("decompress() err = %v, want %v", err, wantErr) + } +} + +// TestDecompress_LegacyGzipUnderLimit verifies that legitimate traffic +// through the legacy gzipDecompressor path still decompresses correctly +// when the payload fits inside maxReceiveMessageSize. +func (s) TestDecompress_LegacyGzipUnderLimit(t *testing.T) { + payload := []byte("hello legacy decompressor") + input := compressWithDeterministicError(t, payload) + + out, err := decompress(nil, input, NewGZIPDecompressor(), len(payload), mem.DefaultBufferPool()) + if err != nil { + t.Fatalf("decompress() err = %v, want nil", err) + } + if !bytes.Equal(out.Materialize(), payload) { + t.Fatalf("decompress() output = %q, want %q", out.Materialize(), payload) + } +} + type mockCompressor struct { // Written to by the io.Reader on every call to Read. ch chan<- struct{} From a644fa6ca76658e905656e64271b39dd45f0a9c3 Mon Sep 17 00:00:00 2001 From: evilgensec Date: Thu, 21 May 2026 14:17:36 +0545 Subject: [PATCH 4/6] rpc_util: address review nits on legacy decompressor tests - Check only status.Code in TestDecompress_LegacyGzipBomb instead of comparing the full error string, so the test isn't tied to the exact message format. - Rename compressWithDeterministicError to mustCompress; the previous name suggested behavior the helper does not have (it just compresses and fails the test on error). Internal error messages updated to match. --- rpc_util_test.go | 30 +++++++++++++++--------------- 1 file changed, 15 insertions(+), 15 deletions(-) diff --git a/rpc_util_test.go b/rpc_util_test.go index b8a41c94e4c3..74ebc7229c7a 100644 --- a/rpc_util_test.go +++ b/rpc_util_test.go @@ -419,16 +419,17 @@ func BenchmarkGZIPCompressor1MiB(b *testing.B) { bmCompressor(b, 1024*1024, NewGZIPCompressor()) } -// compressWithDeterministicError compresses the input data and returns a BufferSlice. -func compressWithDeterministicError(t *testing.T, input []byte) mem.BufferSlice { +// mustCompress gzip-compresses input and returns it as a BufferSlice, +// failing the test if compression fails. +func mustCompress(t *testing.T, input []byte) mem.BufferSlice { t.Helper() var buf bytes.Buffer gz := gzip.NewWriter(&buf) if _, err := gz.Write(input); err != nil { - t.Fatalf("compressInput() failed to write data: %v", err) + t.Fatalf("mustCompress() failed to write data: %v", err) } if err := gz.Close(); err != nil { - t.Fatalf("compressInput() failed to close gzip writer: %v", err) + t.Fatalf("mustCompress() failed to close gzip writer: %v", err) } compressedData := buf.Bytes() return mem.BufferSlice{mem.NewBuffer(&compressedData, nil)} @@ -475,7 +476,7 @@ func (s) TestDecompress(t *testing.T) { }{ { name: "Decompresses successfully with sufficient buffer size", - input: compressWithDeterministicError(t, []byte("decompressed data")), + input: mustCompress(t, []byte("decompressed data")), dc: nil, maxReceiveMessageSize: 50, want: []byte("decompressed data"), @@ -483,7 +484,7 @@ func (s) TestDecompress(t *testing.T) { }, { name: "Fails due to exceeding maxReceiveMessageSize", - input: compressWithDeterministicError(t, []byte("message that is too large")), + input: mustCompress(t, []byte("message that is too large")), dc: nil, maxReceiveMessageSize: len("message that is too large") - 1, want: nil, @@ -491,7 +492,7 @@ func (s) TestDecompress(t *testing.T) { }, { name: "Decompresses to exactly maxReceiveMessageSize", - input: compressWithDeterministicError(t, []byte("exact size message")), + input: mustCompress(t, []byte("exact size message")), dc: nil, maxReceiveMessageSize: len("exact size message"), want: []byte("exact size message"), @@ -499,7 +500,7 @@ func (s) TestDecompress(t *testing.T) { }, { name: "Decompresses successfully with maxReceiveMessageSize MaxInt", - input: compressWithDeterministicError(t, []byte("large message")), + input: mustCompress(t, []byte("large message")), dc: nil, maxReceiveMessageSize: math.MaxInt, want: []byte("large message"), @@ -507,7 +508,7 @@ func (s) TestDecompress(t *testing.T) { }, { name: "Fails with decompression error due to invalid format", - input: compressWithDeterministicError(t, []byte("invalid compressed data")), + input: mustCompress(t, []byte("invalid compressed data")), dc: invalidFormatDecompressor, maxReceiveMessageSize: 50, want: nil, @@ -515,7 +516,7 @@ func (s) TestDecompress(t *testing.T) { }, { name: "Fails with resourceExhausted error when decompressed message exceeds maxReceiveMessageSize", - input: compressWithDeterministicError(t, []byte("large compressed data")), + input: mustCompress(t, []byte("large compressed data")), dc: validDecompressor, maxReceiveMessageSize: 20, want: nil, @@ -545,16 +546,15 @@ func (s) TestDecompress(t *testing.T) { func (s) TestDecompress_LegacyGzipBomb(t *testing.T) { // 1 MiB of zeros gzips down to a few KiB at most. payload := make([]byte, 1<<20) - input := compressWithDeterministicError(t, payload) + input := mustCompress(t, payload) const maxRecv = 1024 out, err := decompress(nil, input, NewGZIPDecompressor(), maxRecv, mem.DefaultBufferPool()) if out != nil { t.Errorf("decompress() returned non-nil output for over-limit payload") } - wantErr := status.Errorf(codes.ResourceExhausted, "grpc: message after decompression larger than max (%d vs. %d)", maxRecv+1, maxRecv) - if !cmp.Equal(err, wantErr, cmpopts.EquateErrors()) { - t.Fatalf("decompress() err = %v, want %v", err, wantErr) + if got, want := status.Code(err), codes.ResourceExhausted; got != want { + t.Fatalf("decompress() err code = %v, want %v (err = %v)", got, want, err) } } @@ -563,7 +563,7 @@ func (s) TestDecompress_LegacyGzipBomb(t *testing.T) { // when the payload fits inside maxReceiveMessageSize. func (s) TestDecompress_LegacyGzipUnderLimit(t *testing.T) { payload := []byte("hello legacy decompressor") - input := compressWithDeterministicError(t, payload) + input := mustCompress(t, payload) out, err := decompress(nil, input, NewGZIPDecompressor(), len(payload), mem.DefaultBufferPool()) if err != nil { From 4741525ec21daf3474a644c9e976cd92cf224af6 Mon Sep 17 00:00:00 2001 From: evilgensec Date: Thu, 21 May 2026 14:25:41 +0545 Subject: [PATCH 5/6] rpc_util: rename remaining compressWithDeterministicError caller A new caller of the helper was added on master while this branch was open, breaking the build after the rename. Update the merged-in TestDecompress_ClosesReader to use mustCompress. --- rpc_util_test.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/rpc_util_test.go b/rpc_util_test.go index 46b32b13f7cc..fd859445003c 100644 --- a/rpc_util_test.go +++ b/rpc_util_test.go @@ -675,7 +675,7 @@ func (s) TestDecompress_ClosesReader(t *testing.T) { ch := make(chan struct{}) compressor := &fakeCloseCompressor{ch: ch} - in := compressWithDeterministicError(t, []byte("some data")) + in := mustCompress(t, []byte("some data")) out, err := decompress(compressor, in, nil, tc.maxReceiveMessageSize, mem.DefaultBufferPool()) if status.Code(err) != tc.wantCode { t.Fatalf("decompress() failed with error code %v, want %v", status.Code(err), tc.wantCode) From b38765f842ed23c2039b2be9ef391a16b18348ed Mon Sep 17 00:00:00 2001 From: evilgensec Date: Thu, 21 May 2026 20:17:07 +0545 Subject: [PATCH 6/6] rpc_util: fold legacy gzip bomb cases into TestDecompress table Move the two standalone TestDecompress_LegacyGzip* tests into the TestDecompress table so the bomb case asserts the exact error message, which pins decompress() to having read at most maxReceiveMessageSize+1 bytes. --- rpc_util_test.go | 57 +++++++++++++++++------------------------------- 1 file changed, 20 insertions(+), 37 deletions(-) diff --git a/rpc_util_test.go b/rpc_util_test.go index fd859445003c..08a4a33feeaf 100644 --- a/rpc_util_test.go +++ b/rpc_util_test.go @@ -522,6 +522,26 @@ func (s) TestDecompress(t *testing.T) { want: nil, wantErr: status.Errorf(codes.ResourceExhausted, "grpc: message after decompression larger than max (%d vs. %d)", 25, 20), }, + { + // Bombs the legacy gzipDecompressor with 1 MiB of zeros (which + // gzips down to a few KiB). doWithMaxSize must cap the read at + // maxRecv+1 bytes; the error reports exactly that size so we + // know only maxRecv+1 bytes were materialised. + name: "Legacy gzipDecompressor bounds decompressed bomb to maxReceiveMessageSize+1", + input: mustCompress(t, make([]byte, 1<<20)), + dc: NewGZIPDecompressor(), + maxReceiveMessageSize: 1024, + want: nil, + wantErr: status.Errorf(codes.ResourceExhausted, "grpc: message after decompression larger than max (%d vs. %d)", 1025, 1024), + }, + { + name: "Legacy gzipDecompressor decompresses successfully when payload fits", + input: mustCompress(t, []byte("hello legacy decompressor")), + dc: NewGZIPDecompressor(), + maxReceiveMessageSize: len("hello legacy decompressor"), + want: []byte("hello legacy decompressor"), + wantErr: nil, + }, } for _, tc := range testCases { @@ -537,43 +557,6 @@ func (s) TestDecompress(t *testing.T) { } } -// TestDecompress_LegacyGzipBomb verifies that the legacy gzipDecompressor -// path bounds the decompressed payload at maxReceiveMessageSize+1 bytes -// instead of buffering the full expansion in memory. The input here is a -// small compressed payload that expands to many times the receive limit; -// without the bound, decompress() would materialize the entire expansion -// before the size check fires. -func (s) TestDecompress_LegacyGzipBomb(t *testing.T) { - // 1 MiB of zeros gzips down to a few KiB at most. - payload := make([]byte, 1<<20) - input := mustCompress(t, payload) - - const maxRecv = 1024 - out, err := decompress(nil, input, NewGZIPDecompressor(), maxRecv, mem.DefaultBufferPool()) - if out != nil { - t.Errorf("decompress() returned non-nil output for over-limit payload") - } - if got, want := status.Code(err), codes.ResourceExhausted; got != want { - t.Fatalf("decompress() err code = %v, want %v (err = %v)", got, want, err) - } -} - -// TestDecompress_LegacyGzipUnderLimit verifies that legitimate traffic -// through the legacy gzipDecompressor path still decompresses correctly -// when the payload fits inside maxReceiveMessageSize. -func (s) TestDecompress_LegacyGzipUnderLimit(t *testing.T) { - payload := []byte("hello legacy decompressor") - input := mustCompress(t, payload) - - out, err := decompress(nil, input, NewGZIPDecompressor(), len(payload), mem.DefaultBufferPool()) - if err != nil { - t.Fatalf("decompress() err = %v, want nil", err) - } - if !bytes.Equal(out.Materialize(), payload) { - t.Fatalf("decompress() output = %q, want %q", out.Materialize(), payload) - } -} - type mockCompressor struct { // Written to by the io.Reader on every call to Read. ch chan<- struct{}