diff --git a/e2e/proxy_test.go b/e2e/proxy_test.go index 9a7411d..6a08ae0 100644 --- a/e2e/proxy_test.go +++ b/e2e/proxy_test.go @@ -1199,82 +1199,59 @@ var _ = Describe("Proxy", func() { }) }) - When("creating and getting a large configmap through the proxy", func() { - // This test validates both gzip failure paths: - // 1. The workflow engine path (WriteToKube in activity.go): a large CREATE - // response is gzip-encoded by kube; without the fix, WriteToKube would - // forward Accept-Encoding from the client, preventing the REST transport - // from auto-decompressing, and the client receives raw gzip bytes. - // 2. The reverse proxy path (Director in server.go): a large GET response - // is gzip-encoded by kube; without the fix, FilterResp receives gzip - // bytes and fails to decode them as JSON. - BeforeEach(func() { - createConfigMap := proxyrule.Config{Spec: proxyrule.Spec{ - Locking: proxyrule.OptimisticLockMode, - Matches: []proxyrule.Match{{ - GroupVersion: "v1", - Resource: "configmaps", - Verbs: []string{"create"}, - }}, - Update: proxyrule.Update{ - CreateRelationships: []proxyrule.StringOrTemplate{{ - // testresource view = viewer + creator, so this grants - // the creator permission to view the configmap below. - Template: "testresource:{{namespacedName}}#creator@user:{{user.name}}", + When("getting a large configmap through the proxy", func() { + It("succeeds even when the upstream response is gzip-encoded", func(ctx context.Context) { + // Allow configmap gets unconditionally (no SpiceDB check needed). + // The point of this test is to verify the proxy correctly handles + // gzip-encoded responses from the kube API server. + // + // The reverse proxy path (Director + FilterResp in server.go) is tested here. + // The workflow engine path (WriteToKube in activity.go) is tested in the + // TestWriteToKubeDoesNotForwardAcceptEncoding unit test in the distributedtx package. + // + // Without the Accept-Encoding fix in the proxy's Director, FilterResp + // would receive a gzip-compressed body and fail to decode it as JSON. + configMapRule := proxyrule.Config{ + Spec: proxyrule.Spec{ + Matches: []proxyrule.Match{{ + GroupVersion: "v1", + Resource: "configmaps", + Verbs: []string{"get"}, }}, + // No Checks: unconditional allow for matched requests. }, - }} - - getConfigMap := proxyrule.Config{Spec: proxyrule.Spec{ - Matches: []proxyrule.Match{{ - GroupVersion: "v1", - Resource: "configmaps", - Verbs: []string{"get"}, - }}, - Checks: []proxyrule.StringOrTemplate{{ - Template: "testresource:{{namespacedName}}#view@user:{{user.name}}", - }}, - }} - - matcher, err := rules.NewMapMatcher([]proxyrule.Config{ - createNamespace(), - createConfigMap, - getConfigMap, - }) + } + matcher, err := rules.NewMapMatcher([]proxyrule.Config{configMapRule}) Expect(err).To(Succeed()) *proxySrv.Matcher = matcher - }) - It("handles gzip-encoded responses from the workflow engine and reverse proxy", func(ctx context.Context) { - // Create namespace via proxy (goes through workflow engine). - Expect(CreateNamespace(ctx, paulClient, paulNamespace)).To(Succeed()) - - // 300KB is large enough to trigger kube's gzip encoding (threshold ~128KB). - const dataSize = 300 * 1024 + // Create the namespace and configmap directly via adminClient (bypasses + // the proxy entirely). The proxy is only involved in the GET below. + Expect(CreateNamespace(ctx, adminClient, paulNamespace)).To(Succeed()) + const dataSize = 300 * 1024 // 300KB — large enough to trigger kube's gzip encoding cmName := names.SimpleNameGenerator.GenerateName("large-cm-") - - // CREATE the large configmap through the proxy. - // This goes through the workflow engine (WriteToKube in activity.go). - // kube gzip-encodes the ~300KB 201 Created response; without the fix - // in WriteToKube, the client receives raw gzip bytes and fails to decode. - _, err := paulClient.CoreV1().ConfigMaps(paulNamespace).Create(ctx, &corev1.ConfigMap{ - ObjectMeta: metav1.ObjectMeta{ - Name: cmName, - Namespace: paulNamespace, - }, - Data: map[string]string{ - "payload": strings.Repeat("x", dataSize), - }, - }, metav1.CreateOptions{}) + _, err = adminClient.CoreV1().ConfigMaps(paulNamespace).Create(ctx, + &corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{ + Name: cmName, + Namespace: paulNamespace, + }, + Data: map[string]string{ + "payload": strings.Repeat("x", dataSize), + }, + }, metav1.CreateOptions{}) Expect(err).To(Succeed()) // GET the large configmap through the proxy. - // This goes through the reverse proxy (Director + FilterResp in server.go). - // kube gzip-encodes the ~300KB response; without the fix in Director, - // FilterResp receives gzip bytes and cannot decode them as JSON. + // kube will gzip-encode the ~300KB response because paulClient sends + // Accept-Encoding: gzip. The proxy strips this header in its Director + // so its own transport takes ownership of gzip negotiation and + // auto-decompresses before FilterResp runs — ensuring FilterResp always + // sees plain JSON regardless of response size. cm, err := paulClient.CoreV1().ConfigMaps(paulNamespace).Get(ctx, cmName, metav1.GetOptions{}) Expect(err).To(Succeed()) Expect(cm.Name).To(Equal(cmName)) + // Verify the full payload came back intact (not truncated or corrupted). Expect(cm.Data["payload"]).To(HaveLen(dataSize)) }) }) diff --git a/pkg/authz/distributedtx/activity_test.go b/pkg/authz/distributedtx/activity_test.go index 6502134..9718614 100644 --- a/pkg/authz/distributedtx/activity_test.go +++ b/pkg/authz/distributedtx/activity_test.go @@ -108,6 +108,50 @@ func TestCheckKubeResourceError(t *testing.T) { require.Error(t, err) } +func TestWriteToKubeDoesNotForwardAcceptEncoding(t *testing.T) { + // Verify that Accept-Encoding is stripped from forwarded headers. + // If Accept-Encoding is forwarded to kube, kube may gzip-encode its response, + // but the REST transport won't auto-decompress (it only decompresses when it + // added Accept-Encoding itself, setting requestedGzip=true internally). + // Forwarding Accept-Encoding would cause res.Raw() to return compressed bytes. + var capturedHeader http.Header + roundtripper := func(req *http.Request) (*http.Response, error) { + capturedHeader = req.Header.Clone() + header := http.Header{} + header.Set("Content-Type", runtime.ContentTypeJSON) + return &http.Response{ + Header: header, + StatusCode: http.StatusCreated, + Body: io.NopCloser(bytes.NewReader([]byte(`{"hi":"bye"}`))), + }, nil + } + client := &fake.RESTClient{ + Client: fake.CreateHTTPClient(roundtripper), + NegotiatedSerializer: &serializer.CodecFactory{}, + } + ah := ActivityHandler{KubeClient: client} + + resp, err := ah.WriteToKube(t.Context(), &KubeReqInput{ + RequestInfo: &request.RequestInfo{Path: "my_way", Namespace: "ns", Verb: "post"}, + RequestURI: "/my_way", + ObjectMeta: &metav1.ObjectMeta{Name: "my_object_meta"}, + Body: []byte(`{"hi":"bye"}`), + Header: http.Header{ + "Accept-Encoding": []string{"gzip"}, + "Content-Type": []string{"application/json"}, + }, + }) + + require.NoError(t, err) + require.NotNil(t, capturedHeader) + require.Empty(t, capturedHeader.Get("Accept-Encoding"), + "Accept-Encoding must not be forwarded: if kube gzip-encodes the response, "+ + "the REST transport won't decompress it because it didn't set the header itself") + require.Equal(t, "application/json", capturedHeader.Get("Content-Type"), + "non-encoding headers should still be forwarded") + require.JSONEq(t, `{"hi":"bye"}`, string(resp.Body)) +} + func TestIdempotencyKey(t *testing.T) { payload := &v1.WriteRelationshipsRequest{} key, err := idempotencyKeyForPayload(payload, "test-key")