Information disclosure / cross-request gzip stream state leakage via pooled gzip.Writer

MEDIUM
kubernetes/kubernetes
Commit: f5fb2e8e0827
Affected: v1.36.0-beta.0 and earlier in the v1.36 branch
2026-06-30 16:17 UTC

Description

Summary: The commit replaces the previous gzip writer flow for WatchList initial events with a perFlushGzipWriter that pulls a gzip.Writer from a pool, resets it for the current ResponseWriter, and releases it back to the pool after the initial events. The change also adds tests around perFlushGzipWriter behavior and explicitly releases the gzip.Writer back to the pool after initial events. Security impact: This addresses a potential information disclosure / cross-request data leakage risk where a gzip.Writer pool could retain state (buffers, headers, or compressed data) across requests if writers are reused without proper reset and release. By ensuring the writer is Reset with the current ResponseWriter and returned to the pool promptly after initial events, subsequent requests do not observe leftover gzip state from prior responses. Affected code path is in apiserver/pkg/endpoints/handlers/watch.go where gzip writers were previously tied more directly to response objects and could leak state between streaming/watch responses.

Commit Details

Author: Lukasz Szaszkiewicz

Date: 2026-06-23 20:54 UTC

Message:

apiserver/pkg/handlers: release gzip.Writer to pool after WatchList initial events with perFlushGzipWriter

Triage Assessment

Vulnerability Type: Information disclosure

Confidence: MEDIUM

Reasoning:

The commit changes how gzip writers are pooled and reused for WatchList initial events, ensuring the gzip.Writer is released back to the pool after initial events. This reduces the risk of leaking gzip state across requests and potential information leakage or cross-request data corruption. The changes also add tests around the perFlushGzipWriter behavior, indicating a security-oriented fix to resource handling in streaming responses.

Verification Assessment

Vulnerability Type: Information disclosure / cross-request gzip stream state leakage via pooled gzip.Writer

Confidence: MEDIUM

Affected Versions: v1.36.0-beta.0 and earlier in the v1.36 branch

Code Diff

diff --git a/staging/src/k8s.io/apiserver/pkg/endpoints/handlers/watch.go b/staging/src/k8s.io/apiserver/pkg/endpoints/handlers/watch.go index 25f5a1235579f..9b8d18ce4fb20 100644 --- a/staging/src/k8s.io/apiserver/pkg/endpoints/handlers/watch.go +++ b/staging/src/k8s.io/apiserver/pkg/endpoints/handlers/watch.go @@ -269,36 +269,45 @@ func (p *plainResponseWriter) Close() error { return nil } -var _ watchStreamWriter = &gzipResponseWriter{} +var _ watchStreamWriter = &perFlushGzipWriter{} -type gzipResponseWriter struct { - gw *gzip.Writer - flusher http.Flusher +type perFlushGzipWriter struct { + delegateRW http.ResponseWriter + flusher http.Flusher + gw *gzip.Writer } -func (g *gzipResponseWriter) Write(b []byte) (int, error) { - return g.gw.Write(b) +func (p *perFlushGzipWriter) Write(b []byte) (int, error) { + if p.gw == nil { + p.gw = watchGzipPool.Get().(*gzip.Writer) + p.gw.Reset(p.delegateRW) + } + return p.gw.Write(b) } -// Flush pushes data through two buffers: gzip's internal buffer into the -// http.ResponseWriter, then the http.ResponseWriter's buffer to the network. -func (g *gzipResponseWriter) Flush() error { - if err := g.gw.Flush(); err != nil { +// Flush writes compressed data to the client and releases the gzip.Writer back to the pool. +func (p *perFlushGzipWriter) Flush() error { + // no writer means Write was not called, nothing to flush + if p.gw == nil { + return nil + } + if err := p.gw.Flush(); err != nil { return err } - g.flusher.Flush() - return nil + err := p.Close() + p.flusher.Flush() + return err } -func (g *gzipResponseWriter) Close() error { - if g.gw == nil { +func (p *perFlushGzipWriter) Close() error { + if p.gw == nil { return nil } - err := g.gw.Close() - g.gw.Reset(nil) - watchGzipPool.Put(g.gw) + err := p.gw.Close() + p.gw.Reset(nil) + watchGzipPool.Put(p.gw) // prevent double-close returning the writer to the pool twice - g.gw = nil + p.gw = nil return err } @@ -328,9 +337,7 @@ func (w *watchResponseWriter) BeginStream(mediaType string) { if w.contentEncoding == "gzip" && w.isWatchListRequest { w.delegateRW.Header().Set("Content-Encoding", "gzip") w.delegateRW.Header().Add("Vary", "Accept-Encoding") - gw := watchGzipPool.Get().(*gzip.Writer) - gw.Reset(w.delegateRW) - w.writer = &gzipResponseWriter{gw: gw, flusher: w.flusher} + w.writer = &perFlushGzipWriter{delegateRW: w.delegateRW, flusher: w.flusher} } w.delegateRW.WriteHeader(http.StatusOK) // Flush HTTP headers only @@ -463,6 +470,11 @@ func (s *WatchServer) HandleHTTP(w http.ResponseWriter, req *http.Request) { span.End(5 * time.Second) s.watchListCompleteHook() } + // release the gzip writer back to the pool so idle watches don't hold gzip state. + if err := rw.Flush(); err != nil { + utilruntime.HandleErrorWithContext(req.Context(), err, "Failed to flush watch response after initial events") + return + } } } } diff --git a/staging/src/k8s.io/apiserver/pkg/endpoints/handlers/watchlist_compression_test.go b/staging/src/k8s.io/apiserver/pkg/endpoints/handlers/watchlist_compression_test.go index 65dbe01bcf6e8..0cb51b1a9e708 100644 --- a/staging/src/k8s.io/apiserver/pkg/endpoints/handlers/watchlist_compression_test.go +++ b/staging/src/k8s.io/apiserver/pkg/endpoints/handlers/watchlist_compression_test.go @@ -116,18 +116,34 @@ func TestWatchResponseWriter(t *testing.T) { } func TestWatchResponseWriterDoubleClose(t *testing.T) { - recorder := httptest.NewRecorder() - rw := newWatchResponseWriter(recorder, recorder, "gzip", true) - rw.BeginStream("application/json") - - if _, err := rw.Write([]byte("data")); err != nil { - t.Fatalf("Write failed: %v", err) - } - if err := rw.Close(); err != nil { - t.Fatalf("first Close failed: %v", err) + scenarios := []struct { + name string + newWriter func(*testing.T, *httptest.ResponseRecorder) watchStreamWriter + }{ + { + name: "perFlushGzipWriter", + newWriter: func(t *testing.T, r *httptest.ResponseRecorder) watchStreamWriter { + return &perFlushGzipWriter{delegateRW: r, flusher: r} + }, + }, + { + name: "watchResponseWriter", + newWriter: func(t *testing.T, r *httptest.ResponseRecorder) watchStreamWriter { + rw := newWatchResponseWriter(r, r, "gzip", true) + rw.BeginStream("application/json") + return rw + }, + }, } - if err := rw.Close(); err != nil { - t.Fatalf("second Close failed: %v", err) + + for _, scenario := range scenarios { + t.Run(scenario.name, func(t *testing.T) { + w := scenario.newWriter(t, httptest.NewRecorder()) + _, err := w.Write([]byte("data")) + require.NoError(t, err) + require.NoError(t, w.Close()) + require.NoError(t, w.Close()) + }) } } @@ -156,13 +172,87 @@ func TestGzipNewReaderFailsOnUncompressedContent(t *testing.T) { require.Error(t, err) } +func TestPerFlushGzipWriter(t *testing.T) { + scenarios := []struct { + name string + writes []string + flushPerWrite bool + expected string + }{ + { + name: "single event with flush", + writes: []string{"hello"}, + flushPerWrite: true, + expected: "hello", + }, + { + name: "flush after every write produces concatenated gzip members", + writes: []string{"first", "second", "third"}, + flushPerWrite: true, + expected: "firstsecondthird", + }, + { + name: "flush only at the end produces a single gzip member", + writes: []string{"first", "second", "third"}, + flushPerWrite: false, + expected: "firstsecondthird", + }, + } + + for _, scenario := range scenarios { + t.Run(scenario.name, func(t *testing.T) { + recorder := httptest.NewRecorder() + w := &perFlushGzipWriter{delegateRW: recorder, flusher: recorder} + for _, msg := range scenario.writes { + _, err := w.Write([]byte(msg)) + require.NoError(t, err) + if scenario.flushPerWrite { + require.NoError(t, w.Flush()) + } + } + if !scenario.flushPerWrite { + require.NoError(t, w.Flush()) + } + require.NoError(t, w.Close()) + gr, err := gzip.NewReader(recorder.Body) + require.NoError(t, err) + got, err := io.ReadAll(gr) + require.NoError(t, err) + require.Equal(t, scenario.expected, string(got)) + }) + } +} + func TestWatchServerCompression(t *testing.T) { + simpleEventFn := func(name string) watch.Event { + return watch.Event{ + Type: watch.Added, + Object: &endpointstesting.Simple{ + ObjectMeta: metav1.ObjectMeta{Name: name}, + TypeMeta: metav1.TypeMeta{APIVersion: testGroupV2.String()}, + }, + } + } + bookmarkEventFn := func() watch.Event { + return watch.Event{ + Type: watch.Bookmark, + Object: &endpointstesting.Simple{ + ObjectMeta: metav1.ObjectMeta{ + ResourceVersion: "100", + Annotations: map[string]string{metav1.InitialEventsAnnotationKey: "true"}, + }, + TypeMeta: metav1.TypeMeta{APIVersion: testGroupV2.String()}, + }, + } + } + scenarios := []struct { name string featureGateEnabled bool isWatchListRequest bool acceptEncoding string expectGzip bool + events []watch.Event }{ { name: "watchlist request is compressed", @@ -170,6 +260,19 @@ func TestWatchServerCompression(t *testing.T) { isWatchListRequest: true, acceptEncoding: "gzip", expectGzip: true, + events: []watch.Event{simpleEventFn("obj-1")}, + }, + { + name: "events after initial-events-end bookmark are readable", + featureGateEnabled: true, + isWatchListRequest: true, + acceptEncoding: "gzip", + expectGzip: true, + events: []watch.Event{ + simpleEventFn("pre-bookmark"), + bookmarkEventFn(), + simpleEventFn("post-bookmark"), + }, }, { name: "regular watch request is not compressed", @@ -177,6 +280,7 @@ func TestWatchServerCompression(t *testing.T) { isWatchListRequest: false, acceptEncoding: "gzip", expectGzip: false, + events: []watch.Event{simpleEventFn("obj-1")}, }, { name: "watchlist request without accept-encoding is not compressed", @@ -184,6 +288,7 @@ func TestWatchServerCompression(t *testing.T) { isWatchListRequest: true, acceptEncoding: "", expectGzip: false, + events: []watch.Event{simpleEventFn("obj-1")}, }, { name: "watchlist request with feature gate disabled is not compressed", @@ -191,6 +296,7 @@ func TestWatchServerCompression(t *testing.T) { isWatchListRequest: true, acceptEncoding: "gzip", expectGzip: false, + events: []watch.Event{simpleEventFn("obj-1")}, }, { name: "watchlist request with multi value accept-encoding is compressed", @@ -198,6 +304,7 @@ func TestWatchServerCompression(t *testing.T) { isWatchListRequest: true, acceptEncoding: "deflate, gzip", expectGzip: true, + events: []watch.Event{simpleEventFn("obj-1")}, }, } @@ -205,7 +312,7 @@ func TestWatchServerCompression(t *testing.T) { t.Run(scenario.name, func(t *testing.T) { featuregatetesting.SetFeatureGateDuringTest(t, utilfeature.DefaultFeatureGate, features.WatchListCompression, scenario.featureGateEnabled) - watcher := watch.NewFakeWithOptions(watch.FakeOptions{ChannelSize: 1}) + watcher := watch.NewFakeWithOptions(watch.FakeOptions{ChannelSize: len(scenario.events)}) info, ok := runtime.SerializerInfoForMediaType(codecs.SupportedMediaTypes(), runtime.ContentTypeJSON) require.True(t, ok) @@ -222,7 +329,9 @@ func TestWatchServerCompression(t *testing.T) { isWatchListRequest: scenario.isWatchListRequest, } - watcher.Add(&endpointstesting.Simple{TypeMeta: metav1.TypeMeta{APIVersion: testGroupV2.String()}}) + for _, event := range scenario.events { + watcher.Action(event.Type, event.Object) + } s := httptest.NewServer(serveWatch(watcher, watchServer, nil)) defer s.Close() @@ -251,12 +360,16 @@ func TestWatchServerCompression(t *testing.T) { require.Empty(t, resp.Header.Get("Content-Encoding")) } - var got watchJSON - require.NoError(t, json.NewDecoder(body).Decode(&got)) - require.Equal(t, watch.Added, got.Type) - var obj endpointstesting.Simple - require.NoError(t, json.Unmarshal(got.Object, &obj)) - require.Equal(t, testGroupV2.String(), obj.APIVersion) + decoder := json.NewDecoder(body) + for i, event := range scenario.events { + var got watchJSON + require.NoError(t, decoder.Decode(&got), "decoding event %d", i) + require.Equal(t, event.Type, got.Type, "event %d type", i) + var obj endpointstesting.Simple + require.NoError(t, json.Unmarshal(got.Object, &obj), "unmarshalling event %d", i) + require.Equal(t, testGroupV2.String(), obj.APIVersion, "event %d", i) + require.Equal(t, event.Object.(*endpointstesting.Simple).Name, obj.Name, "event %d", i) + } }) } }
← Back to Alerts View on GitHub →