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") + } + }) + } +} 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) + } +}