From 2c31595783c74564c1299a5401dc6e606f379cc7 Mon Sep 17 00:00:00 2001 From: Brad Fitzpatrick Date: Thu, 3 Sep 2026 04:21:21 +0000 Subject: [PATCH] net/http/internal/http2: don't shrink HPACK decoder table before SETTINGS ack A server configured with a MaxDecoderHeaderTableSize below the initial 4096 bytes (RFC 7540, section 6.5.2) applied it to its HPACK decoder at connection start. But the server doesn't know when the client has received and applied a SETTINGS value until the client acknowledges the SETTINGS frame (RFC 7540, section 6.5.3), and the client's encoder signals a table size reduction with a dynamic table size update at the beginning of the first header block following the settings acknowledgment (RFC 7541, section 4.2). Until then, the client may keep using the initial 4096-byte table. A spec-compliant client (including Go's own http2 client) that sent a header block referencing a dynamic table entry in the first RTT was rejected with a connection-fatal COMPRESSION_ERROR. Instead, start the decoder at the initial table size and apply the configured smaller size once the client acks our SETTINGS. All header blocks after the ack were encoded by a client that has processed the setting, and a compliant encoder begins its next header block with a dynamic table size update. Memory during the window is bounded by the same 4096 bytes as today's default. Change-Id: Idecd25a1e0473c3cff314922ddc97ace14cb2e5e Reviewed-on: https://go-review.googlesource.com/c/go/+/827024 Reviewed-by: Nicholas Husin LUCI-TryBot-Result: golang-scoped@luci-project-accounts.iam.gserviceaccount.com Reviewed-by: Damien Neil Reviewed-by: Nicholas Husin Auto-Submit: Brad Fitzpatrick --- src/net/http/internal/http2/server.go | 26 ++++++- src/net/http/internal/http2/server_test.go | 82 ++++++++++++++++++++++ 2 files changed, 107 insertions(+), 1 deletion(-) diff --git a/src/net/http/internal/http2/server.go b/src/net/http/internal/http2/server.go index addfc1dd24ffa3..463ebba48add0e 100644 --- a/src/net/http/internal/http2/server.go +++ b/src/net/http/internal/http2/server.go @@ -321,7 +321,19 @@ func (s *Server) serveConn(c net.Conn, opts *ServeConnOpts, newf func(*serverCon if conf.CountError != nil { fr.countError = conf.CountError } - fr.ReadMetaHeaders = hpack.NewDecoder(uint32(conf.MaxDecoderHeaderTableSize), nil) + // A decoder table size below the initial 4096 (RFC 7540, Section 6.5.2) + // can't be applied immediately: the client may keep using the initial + // size until it processes our SETTINGS frame (RFC 7540, Section 6.5.3), + // and it signals the reduction with a dynamic table size update at the + // beginning of the first header block following the settings + // acknowledgment (RFC 7541, Section 4.2). Start at the initial size and + // lower it when the client acknowledges our SETTINGS. See processSettings. + decoderTableSize := uint32(conf.MaxDecoderHeaderTableSize) + if decoderTableSize < initialHeaderTableSize { + sc.pendingDecoderTableSize = decoderTableSize + decoderTableSize = initialHeaderTableSize + } + fr.ReadMetaHeaders = hpack.NewDecoder(decoderTableSize, nil) fr.MaxHeaderListSize = sc.maxHeaderListSize() fr.MaxHeaderValueCount = sc.hs.MaxHeaderValueCount() fr.SetMaxReadFrameSize(uint32(conf.MaxReadFrameSize)) @@ -443,6 +455,7 @@ type serverConn struct { sawFirstSettings bool // got the initial SETTINGS frame after the preface needToSendSettingsAck bool unackedSettings int // how many SETTINGS have we sent without ACKs? + pendingDecoderTableSize uint32 // if non-zero, HPACK decoder table size to apply on SETTINGS ack queuedControlFrames int // control frames in the writeSched queue clientMaxStreams uint32 // SETTINGS_MAX_CONCURRENT_STREAMS from client (our PUSH_PROMISE limit) advMaxStreams uint32 // our SETTINGS_MAX_CONCURRENT_STREAMS advertised the client @@ -1614,6 +1627,17 @@ func (sc *serverConn) processSettings(f *SettingsFrame) error { // hang up on them anyway. return sc.countError("ack_mystery", ConnectionError(ErrCodeProtocol)) } + if sc.pendingDecoderTableSize != 0 { + // The client has acknowledged our SETTINGS, so all header + // blocks it sends from now on were encoded with knowledge + // of our lower HEADER_TABLE_SIZE. It is now safe to apply + // the configured size to the decoder. The read goroutine + // is parked until readMore is called, so mutating the + // decoder here is race-free. + sc.framer.ReadMetaHeaders.SetAllowedMaxDynamicTableSize(sc.pendingDecoderTableSize) + sc.framer.ReadMetaHeaders.SetMaxDynamicTableSize(sc.pendingDecoderTableSize) + sc.pendingDecoderTableSize = 0 + } return nil } if f.NumSettings() > 100 || f.HasDuplicates() { diff --git a/src/net/http/internal/http2/server_test.go b/src/net/http/internal/http2/server_test.go index 727ab192ccf214..304badaa8f3500 100644 --- a/src/net/http/internal/http2/server_test.go +++ b/src/net/http/internal/http2/server_test.go @@ -2857,6 +2857,88 @@ func testServer_MaxDecoderHeaderTableSize(t *testing.T) { } } +func TestServer_MaxDecoderHeaderTableSize_DeferredUntilAck(t *testing.T) { + synctest.Test(t, func(t *testing.T) { + testServer_MaxDecoderHeaderTableSizeDeferred(t, true) + }) +} + +func TestServer_MaxDecoderHeaderTableSize_EnforcedAfterAck(t *testing.T) { + synctest.Test(t, func(t *testing.T) { + testServer_MaxDecoderHeaderTableSizeDeferred(t, false) + }) +} + +// testServer_MaxDecoderHeaderTableSizeDeferred tests that a server +// configured with a decoder header table size below the protocol-initial +// 4096 bytes does not apply it until the client acknowledges the server's +// SETTINGS frame. Until then, the client's encoder may legitimately +// reference dynamic table entries under the initial 4096-byte table size +// (RFC 7540, Section 6.5.3; RFC 7541, Section 4.2). +func testServer_MaxDecoderHeaderTableSizeDeferred(t *testing.T, sizeUpdateAfterAck bool) { + st := newServerTester(t, func(w http.ResponseWriter, r *http.Request) {}, func(h2 *http.HTTP2Config) { + h2.MaxDecoderHeaderTableSize = 1 + }) + defer st.Close() + + st.writePreface() + st.writeSettings() + st.sync() + readFrame[*SettingsFrame](t, st) + + // The server's initial WINDOW_UPDATE and its ack of our SETTINGS + // can arrive in either order. + st.wantUnorderedFrames( + func(f *SettingsFrame) bool { + if !f.IsAck() { + t.Fatalf("got second non-ack SETTINGS frame") + } + return true + }, + func(f *WindowUpdateFrame) bool { + return true + }, + ) + + // Send two requests whose header blocks use the dynamic table, before + // acknowledging the server's SETTINGS. The second request references + // entries added by the first. Our encoder is still permitted to use + // the initial 4096-byte table size, so both must succeed. + for _, id := range []uint32{1, 3} { + st.writeHeaders(HeadersFrameParam{ + StreamID: id, + BlockFragment: st.encodeHeader("x-custom-header", "long-enough-value-to-index"), + EndStream: true, + EndHeaders: true, + }) + st.wantHeaders(wantHeader{streamID: id, endStream: true}) + } + + // Acknowledge the server's SETTINGS. Header blocks we send from here + // on must respect the reduced table size. + st.writeSettingsAck() + st.sync() + + if sizeUpdateAfterAck { + // A compliant encoder begins its next header block with a + // dynamic table size update. The request must succeed. + st.hpackEnc.SetMaxDynamicTableSize(1) + } + st.writeHeaders(HeadersFrameParam{ + StreamID: 5, + BlockFragment: st.encodeHeader("x-custom-header", "long-enough-value-to-index"), + EndStream: true, + EndHeaders: true, + }) + if sizeUpdateAfterAck { + st.wantHeaders(wantHeader{streamID: 5, endStream: true}) + } else { + // A header block that keeps referencing the now-evicted + // 4096-byte table without a size update must be rejected. + st.wantGoAway(5, ErrCodeCompression) + } +} + func TestServer_MaxEncoderHeaderTableSize(t *testing.T) { synctest.Test(t, testServer_MaxEncoderHeaderTableSize) }