Skip to content

Commit 6d49d39

Browse files
committed
fix: properly handle gzip-encoded responses
1 parent 067637c commit 6d49d39

2 files changed

Lines changed: 136 additions & 1 deletion

File tree

pkg/authz/responsefilterer.go

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package authz
22

33
import (
44
"bytes"
5+
"compress/gzip"
56
"context"
67
"encoding/json"
78
"fmt"
@@ -233,7 +234,23 @@ func (rf *StandardResponseFilterer) FilterResp(resp *http.Response) error {
233234
return nil
234235
}
235236

236-
body, err := io.ReadAll(resp.Body)
237+
bodyStream := resp.Body
238+
239+
// NOTE: we need to manually check for gzipped encoding here
240+
// because we're proxying - the request/response context would
241+
// know about the encoding if we were the ones originating the
242+
// request, but since we aren't, we need to manually check this.
243+
// This is needed because the k8s API will automatically gzip responses
244+
// above 128kb by default.
245+
if resp.Header.Get("Content-Encoding") == "gzip" {
246+
bodyStream, err = gzip.NewReader(bodyStream)
247+
if err != nil {
248+
return err
249+
}
250+
defer bodyStream.Close()
251+
}
252+
253+
body, err := io.ReadAll(bodyStream)
237254
if err != nil {
238255
return err
239256
}

pkg/proxy/embedded_test.go

Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package proxy
22

33
import (
4+
"compress/gzip"
45
"context"
56
"io"
67
"net/http"
@@ -451,6 +452,123 @@ func createKubernetesClient(t *testing.T, embeddedClient *http.Client, username
451452
return clientset
452453
}
453454

455+
// TestGzippedUpstreamResponse tests that the proxy correctly handles gzip-encoded
456+
// responses from the upstream k8s API server. The k8s API server automatically
457+
// gzip-compresses responses that exceed ~128KB. When a k8s client (kubectl,
458+
// client-go) includes Accept-Encoding: gzip on its request, the proxy forwards
459+
// that header upstream. Go's HTTP transport only auto-decompresses a response
460+
// when the transport itself injected Accept-Encoding — because the header was
461+
// already present, the proxy's transport leaves the body compressed. FilterResp
462+
// must therefore decompress Content-Encoding: gzip bodies before attempting to
463+
// decode them as JSON or protobuf.
464+
func TestGzippedUpstreamResponse(t *testing.T) {
465+
defer require.NoError(t, logsv1.ResetForTest(utilfeature.DefaultFeatureGate))
466+
467+
ctx, cancel := context.WithCancel(t.Context())
468+
t.Cleanup(cancel)
469+
470+
// Isolate the REST-mapper discovery cache so test artefacts don't leak.
471+
t.Setenv("KUBECACHEDIR", t.TempDir())
472+
473+
opts := NewOptions(WithEmbeddedProxy, WithEmbeddedSpiceDBEndpoint)
474+
opts.Authentication.Embedded.Enabled = true
475+
476+
opts.RestConfigFunc = func() (*rest.Config, http.RoundTripper, error) {
477+
// A TLS test server is required because the reverse proxy's Director
478+
// always rewrites the upstream scheme to "https://".
479+
mockServer := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
480+
switch r.URL.Path {
481+
case "/api":
482+
// Core API group discovery.
483+
w.Header().Set("Content-Type", "application/json")
484+
_, _ = w.Write([]byte(`{"kind":"APIVersions","apiVersion":"v1","versions":["v1"],"serverAddressByClientCIDRs":[{"clientCIDR":"0.0.0.0/0","serverAddress":"localhost"}]}`))
485+
case "/apis":
486+
// API group list discovery.
487+
w.Header().Set("Content-Type", "application/json")
488+
_, _ = w.Write([]byte(`{"kind":"APIGroupList","apiVersion":"v1","groups":[]}`))
489+
case "/api/v1":
490+
// Core v1 resource list — tells the REST mapper about Namespace.
491+
w.Header().Set("Content-Type", "application/json")
492+
_, _ = w.Write([]byte(`{"kind":"APIResourceList","apiVersion":"v1","groupVersion":"v1","resources":[{"name":"namespaces","singularName":"namespace","namespaced":false,"kind":"Namespace","verbs":["create","delete","get","list","patch","update","watch"]}]}`))
493+
case "/api/v1/namespaces":
494+
// Simulate the k8s API server's automatic gzip compression for
495+
// responses exceeding ~128KB. The body here is small, but the
496+
// Content-Encoding: gzip header exercises the same FilterResp
497+
// code path as a real large response.
498+
w.Header().Set("Content-Type", "application/json")
499+
w.Header().Set("Content-Encoding", "gzip")
500+
gz := gzip.NewWriter(w)
501+
_, _ = gz.Write([]byte(`{"kind":"NamespaceList","apiVersion":"v1","metadata":{"resourceVersion":"1000"},"items":[]}`))
502+
_ = gz.Close()
503+
default:
504+
w.Header().Set("Content-Type", "application/json")
505+
_, _ = w.Write([]byte(`{"kind":"Status","status":"Success"}`))
506+
}
507+
}))
508+
t.Cleanup(mockServer.Close)
509+
510+
// Return the TLS server's own client transport so the reverse proxy
511+
// trusts the self-signed certificate. The transport's
512+
// DisableCompression=false default means it *would* auto-decompress if
513+
// it had injected Accept-Encoding: gzip itself — but because the
514+
// incoming client request already carries that header (see below), the
515+
// transport forwards it unchanged and skips auto-decompression.
516+
return &rest.Config{
517+
Host: mockServer.URL,
518+
// Insecure=true lets the REST mapper's discovery client connect to
519+
// the mock TLS server without certificate errors.
520+
TLSClientConfig: rest.TLSClientConfig{Insecure: true},
521+
}, mockServer.Client().Transport, nil
522+
}
523+
524+
opts.Matcher = rules.MatcherFunc(func(match *request.RequestInfo) []*rules.RunnableRule {
525+
return []*rules.RunnableRule{{
526+
Checks: []rules.RelationshipExpr{},
527+
}}
528+
})
529+
530+
completedConfig, err := opts.Complete(ctx)
531+
require.NoError(t, err)
532+
533+
proxySrv, err := NewServer(ctx, completedConfig)
534+
require.NoError(t, err)
535+
536+
client := proxySrv.GetEmbeddedClient(
537+
WithUser("test-user"),
538+
WithGroups("test-group"),
539+
)
540+
require.NotNil(t, client)
541+
542+
// Include Accept-Encoding: gzip on the request, exactly as kubectl and
543+
// client-go do. The proxy copies this header to its upstream request; the
544+
// upstream transport then sees the header was already present and does NOT
545+
// auto-decompress the gzip response it receives. FilterResp therefore sees
546+
// the raw compressed bytes when it reads resp.Body.
547+
req, err := http.NewRequestWithContext(ctx, "GET", EmbeddedProxyHost+"/api/v1/namespaces", nil)
548+
require.NoError(t, err)
549+
req.Header.Set("Accept-Encoding", "gzip")
550+
551+
resp, err := client.Do(req)
552+
require.NoError(t, err)
553+
t.Cleanup(func() {
554+
_ = resp.Body.Close()
555+
})
556+
557+
body, err := io.ReadAll(resp.Body)
558+
require.NoError(t, err)
559+
t.Logf("Response status: %d", resp.StatusCode)
560+
t.Logf("Response Content-Encoding: %q", resp.Header.Get("Content-Encoding"))
561+
t.Logf("Response body: %s", body)
562+
563+
// The proxy should relay the response successfully.
564+
// A 502 Bad Gateway here means FilterResp attempted to decode the gzip
565+
// bytes as JSON/protobuf without first decompressing them. The fix is to
566+
// detect Content-Encoding: gzip in FilterResp and decompress the body
567+
// before passing it to the codec decoder.
568+
require.Equal(t, http.StatusOK, resp.StatusCode,
569+
"proxy must handle gzip-encoded upstream responses; a 502 indicates the body was not decompressed before decoding")
570+
}
571+
454572
// headerAddingTransport wraps an http.RoundTripper to add authentication headers
455573
type headerAddingTransport struct {
456574
base http.RoundTripper

0 commit comments

Comments
 (0)