|
1 | 1 | package proxy |
2 | 2 |
|
3 | 3 | import ( |
| 4 | + "compress/gzip" |
4 | 5 | "context" |
5 | 6 | "io" |
6 | 7 | "net/http" |
@@ -451,6 +452,122 @@ func createKubernetesClient(t *testing.T, embeddedClient *http.Client, username |
451 | 452 | return clientset |
452 | 453 | } |
453 | 454 |
|
| 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 | + require.Equal(t, "gzip", resp.Header.Get("Content-Encoding"), "expecting the response to be gzipped") |
| 560 | + require.Empty(t, body, "body is also expected to be empty") |
| 561 | + |
| 562 | + // The proxy should relay the response successfully. |
| 563 | + // A 502 Bad Gateway here means FilterResp attempted to decode the gzip |
| 564 | + // bytes as JSON/protobuf without first decompressing them. The fix is to |
| 565 | + // detect Content-Encoding: gzip in FilterResp and decompress the body |
| 566 | + // before passing it to the codec decoder. |
| 567 | + require.Equal(t, http.StatusOK, resp.StatusCode, |
| 568 | + "proxy must handle gzip-encoded upstream responses; a 502 indicates the body was not decompressed before decoding") |
| 569 | +} |
| 570 | + |
454 | 571 | // headerAddingTransport wraps an http.RoundTripper to add authentication headers |
455 | 572 | type headerAddingTransport struct { |
456 | 573 | base http.RoundTripper |
|
0 commit comments