diff --git a/k8s-operator/sessionrecording/spdy/frame.go b/k8s-operator/sessionrecording/spdy/frame.go index 3ca661e0b..ae103c7e0 100644 --- a/k8s-operator/sessionrecording/spdy/frame.go +++ b/k8s-operator/sessionrecording/spdy/frame.go @@ -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() diff --git a/k8s-operator/sessionrecording/spdy/frame_test.go b/k8s-operator/sessionrecording/spdy/frame_test.go index 1b7e54f4c..252666119 100644 --- a/k8s-operator/sessionrecording/spdy/frame_test.go +++ b/k8s-operator/sessionrecording/spdy/frame_test.go @@ -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) {