From ed07efbc57939afdf154afa80be35507d0a81d66 Mon Sep 17 00:00:00 2001 From: Ben Luddy Date: Tue, 15 Jul 2025 11:28:06 -0400 Subject: [PATCH 1/2] Configure JSON content type for generic webhook RESTClient. Authorization, token authentication, imagepolicy admission, and audit webhooks configure RESTClients that encode to JSON regardless of the ContentType of the provided rest.Config. Because this is opaque to the RESTClient, configuring a ContentType other than "application/json" results in requests with JSON-encoded bodies and a non-JSON media type in the Content-Type header. Webhook servers that respect the Content-Type request header will be unable to decode an object from the request body. Explicitly overriding the ContentType of the provided rest.Config fixes this issue and is consistent with how clients are constructed for conversion and admission webhooks. --- .../apiserver/pkg/util/webhook/webhook.go | 1 + .../pkg/util/webhook/webhook_test.go | 59 +++++++++++++++++++ 2 files changed, 60 insertions(+) diff --git a/staging/src/k8s.io/apiserver/pkg/util/webhook/webhook.go b/staging/src/k8s.io/apiserver/pkg/util/webhook/webhook.go index b03640ae8df..8552e91eb53 100644 --- a/staging/src/k8s.io/apiserver/pkg/util/webhook/webhook.go +++ b/staging/src/k8s.io/apiserver/pkg/util/webhook/webhook.go @@ -83,6 +83,7 @@ func NewGenericWebhook(scheme *runtime.Scheme, codecFactory serializer.CodecFact clientConfig := rest.CopyConfig(config) codec := codecFactory.LegacyCodec(groupVersions...) + clientConfig.ContentType = runtime.ContentTypeJSON clientConfig.ContentConfig.NegotiatedSerializer = serializer.NegotiatedSerializerWrapper(runtime.SerializerInfo{Serializer: codec}) clientConfig.Wrap(x509metrics.NewDeprecatedCertificateRoundTripperWrapperConstructor( diff --git a/staging/src/k8s.io/apiserver/pkg/util/webhook/webhook_test.go b/staging/src/k8s.io/apiserver/pkg/util/webhook/webhook_test.go index 068c6821e50..dba0e3ed6e4 100644 --- a/staging/src/k8s.io/apiserver/pkg/util/webhook/webhook_test.go +++ b/staging/src/k8s.io/apiserver/pkg/util/webhook/webhook_test.go @@ -23,6 +23,7 @@ import ( "encoding/json" "errors" "fmt" + "io" "net" "net/http" "net/http/httptest" @@ -33,11 +34,15 @@ import ( "strings" "testing" "time" + "unicode/utf8" apierrors "k8s.io/apimachinery/pkg/api/errors" "k8s.io/apimachinery/pkg/runtime" "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/apimachinery/pkg/runtime/serializer" "k8s.io/apimachinery/pkg/util/wait" + exampleinstall "k8s.io/apiserver/pkg/apis/example/install" + examplev1 "k8s.io/apiserver/pkg/apis/example/v1" "k8s.io/client-go/kubernetes/scheme" "k8s.io/client-go/rest" v1 "k8s.io/client-go/tools/clientcmd/api/v1" @@ -927,3 +932,57 @@ func getSingleCounterValueFromRegistry(t *testing.T, r metrics.Gatherer, name st return -1 } + +func TestRESTConfigContentType(t *testing.T) { + server, err := newTestServer(clientCert, clientKey, caCert, func(w http.ResponseWriter, r *http.Request) { + if got := r.Header.Get("Content-Type"); got != runtime.ContentTypeJSON { + t.Errorf("expected request content-type: want %q got %q", runtime.ContentTypeJSON, got) + } + body, err := io.ReadAll(r.Body) + if err != nil { + t.Errorf("failed to read request body: %v", err) + return + } + if err := json.Unmarshal(body, new(any)); err != nil { + switch { + case len(body) == 0: + t.Log("empty request body") + case utf8.Valid(body): + t.Logf("request body: %s", string(body)) + default: + t.Logf("request body: 0x%x", body) + } + t.Errorf("failed to unmarshal request body as json: %v", err) + } + }) + if err != nil { + t.Errorf("failed to create server: %v", err) + return + } + defer server.Close() + + config := &rest.Config{ + ContentConfig: rest.ContentConfig{ + ContentType: "foo/bar", + }, + Host: server.URL, + TLSClientConfig: rest.TLSClientConfig{ + CAData: caCert, + CertData: clientCert, + KeyData: clientKey, + }, + } + + scheme := runtime.NewScheme() + exampleinstall.Install(scheme) + codecs := serializer.NewCodecFactory(scheme) + groupVersions := []schema.GroupVersion{examplev1.SchemeGroupVersion} + wh, err := NewGenericWebhook(scheme, codecs, config, groupVersions, retryBackoff) + if err != nil { + t.Fatalf("failed to create the webhook: %v", err) + } + + if err := wh.RestClient.Post().Body(&examplev1.Pod{}).Do(context.TODO()).Error(); err != nil { + t.Fatalf("failed to complete request: %v", err) + } +} From 0b16f0ae3cbc97009d31c7874c6561ae31067e13 Mon Sep 17 00:00:00 2001 From: Ben Luddy Date: Thu, 14 Aug 2025 12:51:28 -0400 Subject: [PATCH 2/2] Test that auth{z,n} hook clients honor Kubelet's request encoding. Kubelets can make a high volume of SubjectAccessReview and TokenReview requests via authn and authz webhooks. Its default encoding, Protobuf, mitigates the serialization cost of these requests when compared to client-go's default encoding, JSON. This test provides some insurance against changes to webhook client construction that might cause the webhook clients to fail to respect Kubelet's configured content type. --- cmd/kubelet/app/auth_test.go | 208 +++++++++++++++++++++++++++++++++++ 1 file changed, 208 insertions(+) create mode 100644 cmd/kubelet/app/auth_test.go diff --git a/cmd/kubelet/app/auth_test.go b/cmd/kubelet/app/auth_test.go new file mode 100644 index 00000000000..06498486b9c --- /dev/null +++ b/cmd/kubelet/app/auth_test.go @@ -0,0 +1,208 @@ +/* +Copyright 2025 The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package app + +import ( + "bytes" + "context" + "io" + "net/http" + "net/http/httptest" + "testing" + + "k8s.io/apiserver/pkg/authorization/authorizer" + authenticationv1client "k8s.io/client-go/kubernetes/typed/authentication/v1" + authorizationv1client "k8s.io/client-go/kubernetes/typed/authorization/v1" + "k8s.io/client-go/rest" + kubeletconfig "k8s.io/kubernetes/pkg/kubelet/apis/config" +) + +func TestAuthzWebhookRequestEncoding(t *testing.T) { + testCases := []struct { + name string + ContentType string + ExpectContentType string + ExpectRequestBodyPrefix []byte + }{ + { + name: "json", + ContentType: "application/json", + ExpectContentType: "application/json", + ExpectRequestBodyPrefix: []byte(`{`), + }, + { + name: "empty", + ContentType: "", + ExpectContentType: "application/json", + ExpectRequestBodyPrefix: []byte(`{`), + }, + { + name: "protobuf", + ContentType: "application/vnd.kubernetes.protobuf", + ExpectContentType: "application/vnd.kubernetes.protobuf", + ExpectRequestBodyPrefix: []byte("\x6b\x38\x73\x00"), // k8s protobuf magic number + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + handlerInvoked := make(chan struct{}) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + defer close(handlerInvoked) + + if got := r.Header.Get("Content-Type"); got != tc.ExpectContentType { + t.Errorf("unexpected Content-Type: got %q, want %q", got, tc.ExpectContentType) + } + + body, err := io.ReadAll(r.Body) + if err != nil { + t.Fatalf("failed to read request body: %v", err) + } + if !bytes.HasPrefix(body, tc.ExpectRequestBodyPrefix) { + t.Errorf("request body should have prefix %q, but got %q", tc.ExpectRequestBodyPrefix, body) + } + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + if _, err := w.Write([]byte(`{"kind":"SubjectAccessReview","apiVersion":"authorization.k8s.io/v1","status":{"allowed":true}}`)); err != nil { + t.Fatalf("unexpected response write failure: %v", err) + } + })) + defer server.Close() + + cfg := &rest.Config{ + Host: server.URL, + ContentConfig: rest.ContentConfig{ + ContentType: tc.ContentType, + }, + } + + authzClient, err := authorizationv1client.NewForConfigAndClient(cfg, server.Client()) + if err != nil { + t.Fatalf("failed to create authorization client: %v", err) + } + + authz, err := BuildAuthz(authzClient, kubeletconfig.KubeletAuthorization{Mode: kubeletconfig.KubeletAuthorizationModeWebhook}) + if err != nil { + t.Fatalf("failed to build authorizer: %v", err) + } + + if _, _, err := authz.Authorize(context.Background(), &authorizer.AttributesRecord{}); err != nil { + t.Fatalf("Authorize failed: %v", err) + } + + select { + case <-handlerInvoked: + default: + t.Fatal("webhook handler not invoked") + } + }) + } +} + +func TestAuthnWebhookRequestEncoding(t *testing.T) { + testCases := []struct { + name string + ContentType string + ExpectContentType string + ExpectRequestBodyPrefix []byte + }{ + { + name: "json", + ContentType: "application/json", + ExpectContentType: "application/json", + ExpectRequestBodyPrefix: []byte(`{`), + }, + { + name: "empty", + ContentType: "", + ExpectContentType: "application/json", + ExpectRequestBodyPrefix: []byte(`{`), + }, + { + name: "protobuf", + ContentType: "application/vnd.kubernetes.protobuf", + ExpectContentType: "application/vnd.kubernetes.protobuf", + ExpectRequestBodyPrefix: []byte("\x6b\x38\x73\x00"), // k8s protobuf magic number + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + handlerInvoked := make(chan struct{}) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + defer close(handlerInvoked) + + if got := r.Header.Get("Content-Type"); got != tc.ExpectContentType { + t.Errorf("unexpected Content-Type: got %q, want %q", got, tc.ExpectContentType) + } + + body, err := io.ReadAll(r.Body) + if err != nil { + t.Fatalf("failed to read request body: %v", err) + } + if !bytes.HasPrefix(body, tc.ExpectRequestBodyPrefix) { + t.Errorf("request body should have prefix %q, but got %q", tc.ExpectRequestBodyPrefix, body) + } + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + if _, err := w.Write([]byte(`{"kind":"TokenReview","apiVersion":"authentication.k8s.io/v1","status":{"authenticated":true}}`)); err != nil { + t.Fatalf("unexpected response write failure: %v", err) + } + })) + defer server.Close() + + cfg := &rest.Config{ + Host: server.URL, + ContentConfig: rest.ContentConfig{ + ContentType: tc.ContentType, + }, + } + + authnClient, err := authenticationv1client.NewForConfigAndClient(cfg, server.Client()) + if err != nil { + t.Fatalf("failed to create authentication client: %v", err) + } + + authn, _, err := BuildAuthn(authnClient, kubeletconfig.KubeletAuthentication{ + Webhook: kubeletconfig.KubeletWebhookAuthentication{ + Enabled: true, + }, + }) + if err != nil { + t.Fatalf("failed to build authenticator: %v", err) + } + + request, err := http.NewRequestWithContext(context.TODO(), http.MethodGet, "/fooz", nil) + if err != nil { + t.Fatalf("failed to build test request: %v", err) + } + request.Header.Set("Authorization", "Bearer foo") + + if _, _, err := authn.AuthenticateRequest(request); err != nil { + t.Fatalf("AuthenticateToken failed: %v", err) + } + + select { + case <-handlerInvoked: + default: + t.Fatal("webhook handler not invoked") + } + }) + } +}