k8s-operator/sessionrecording/spdy: bound decompressed header size

parseHeaders reads attacker-controlled field lengths off the zlib
decompressor with no ceiling on the decompressed output, so a small SPDY
control frame can inflate ~1000x and exhaust memory. Cap the block with
io.LimitReader.

Updates #20510

Signed-off-by: basavaraj-sm05 <basavaraj@digiscrypt.com>
This commit is contained in:
basavaraj-sm05 committed 2026-07-28 16:51:19 +05:30
1 parent 82a381e54b
commit 2f39c8f526
2 files changed
+71

No files matched your search

@@ -22,6 +22,10 @@
SYN_PING ControlFrameType = 6 // https://www.ietf.org/archive/id/draft-mbelshe-httpbis-spdy-00.txt section 2.6.5
)
// maxHeaderBlockLength bounds the total decompressed size of a single SPDY
// Name/Value header block that parseHeaders will read from an untrusted peer.
const maxHeaderBlockLength = 1 << 20 // 1 MiB
// spdyFrame is a parsed SPDY frame as defined in
// https://www.ietf.org/archive/id/draft-mbelshe-httpbis-spdy-00.txt
// A SPDY frame can be either a control frame or a data frame.
@@ -178,6 +182,14 @@ func (sf *spdyFrame) parseHeaders(z *zlibReader, log *zap.SugaredLogger) (http.H
// also advances the provided reader past the headers block.
// See also https://www.ietf.org/archive/id/draft-mbelshe-httpbis-spdy-00.txt section 2.6.10
func parseHeaders(decompressor io.Reader, log *zap.SugaredLogger) (http.Header, error) {
// The Name/Value block is zlib compressed and its declared field lengths
// are attacker controlled. zlib can expand ~1000x, so without a ceiling a
// peer can turn a small control frame into gigabytes of decompressed data
// (the field lengths below feed io.CopyN and a uint32->int conversion). Cap
// the decompressed bytes we're willing to read for a single header block.
// Real 'kubectl exec/attach' blocks are a few hundred bytes; the websocket
// recorder applies an equivalent payload cap in ../ws/message.go.
decompressor = io.LimitReader(decompressor, maxHeaderBlockLength)
buf := bufPool.Get().(*bytes.Buffer)
defer bufPool.Put(buf)
buf.Reset()
@@ -204,6 +204,65 @@ func Test_spdyFrame_parseHeaders(t *testing.T) {
}
}
// Test_spdyFrame_parseHeaders_decompressionBomb verifies that a control frame
// whose zlib-compressed Name/Value block decompresses to more than
// maxHeaderBlockLength is rejected rather than expanded into memory. A tiny
// frame can otherwise declare a multi-GB value that zlib inflates ~1000x.
func Test_spdyFrame_parseHeaders_decompressionBomb(t *testing.T) {
zl, err := zap.NewDevelopment()
if err != nil {
t.Fatal(err)
}
// buildBlock builds a SYN_STREAM payload with a single header whose value
// is a valueLen-byte run of one byte (compresses tiny, decompresses large).
buildBlock := func(valueLen int) []byte {
buf := bytes.NewBuffer(nil)
writeControlFramePayloadBeforeHeaders(t, buf, SYN_STREAM, 1)
w, err := zlib.NewWriterLevelDict(buf, zlib.BestCompression, spdyTxtDictionary)
if err != nil {
t.Fatalf("error creating zlib writer: %v", err)
}
if err := binary.Write(w, binary.BigEndian, uint32(1)); err != nil {
t.Fatal(err)
}
if err := binary.Write(w, binary.BigEndian, uint32(1)); err != nil {
t.Fatal(err)
}
if _, err := w.Write([]byte("x")); err != nil {
t.Fatal(err)
}
if err := binary.Write(w, binary.BigEndian, uint32(valueLen)); err != nil {
t.Fatal(err)
}
if _, err := w.Write(bytes.Repeat([]byte("A"), valueLen)); err != nil {
t.Fatal(err)
}
w.Flush()
w.Close()
return buf.Bytes()
}
oversized := buildBlock(maxHeaderBlockLength + 1)
var z zlibReader
sf := &spdyFrame{Ctrl: true, Type: SYN_STREAM, Payload: oversized}
if _, err := sf.parseHeaders(&z, zl.Sugar()); err == nil {
t.Fatalf("parseHeaders accepted a header block that decompresses past the %d byte cap", maxHeaderBlockLength)
}
// A block that stays under the cap must still parse.
small := buildBlock(16)
var z2 zlibReader
sf2 := &spdyFrame{Ctrl: true, Type: SYN_STREAM, Payload: small}
got, err := sf2.parseHeaders(&z2, zl.Sugar())
if err != nil {
t.Fatalf("parseHeaders rejected a valid header block: %v", err)
}
if got.Get("x") != strings.Repeat("A", 16) {
t.Fatalf("unexpected header value: got %q", got.Get("x"))
}
}
// Test_spdyFrame_ParseRand calls spdyFrame.Parse with randomly generated bytes
// to test that it doesn't panic.
func Test_spdyFrame_ParseRand(t *testing.T) {