Merge pull request #126015 from micahhausler/kubelet-cert-validation
Enhance node admission to validate kubelet CSR's CN
This commit is contained in:
		| @@ -216,6 +216,13 @@ const ( | |||||||
| 	// Disable in-tree functionality in kubelet to authenticate to cloud provider container registries for image pull credentials. | 	// Disable in-tree functionality in kubelet to authenticate to cloud provider container registries for image pull credentials. | ||||||
| 	DisableKubeletCloudCredentialProviders featuregate.Feature = "DisableKubeletCloudCredentialProviders" | 	DisableKubeletCloudCredentialProviders featuregate.Feature = "DisableKubeletCloudCredentialProviders" | ||||||
|  |  | ||||||
|  | 	// owner: @micahhausler | ||||||
|  | 	// Deprecated: v1.31 | ||||||
|  | 	// | ||||||
|  | 	// Disable Node Admission plugin validation of CSRs for kubelet signers where CN=system:node:$nodeName. | ||||||
|  | 	// Remove in v1.33 | ||||||
|  | 	DisableKubeletCSRAdmissionValidation featuregate.Feature = "DisableKubeletCSRAdmissionValidation" | ||||||
|  |  | ||||||
| 	// owner: @HirazawaUi | 	// owner: @HirazawaUi | ||||||
| 	// kep: http://kep.k8s.io/4004 | 	// kep: http://kep.k8s.io/4004 | ||||||
| 	// alpha: v1.29 | 	// alpha: v1.29 | ||||||
| @@ -1326,6 +1333,8 @@ var defaultKubernetesFeatureGates = map[featuregate.Feature]featuregate.FeatureS | |||||||
| 	// ... | 	// ... | ||||||
| 	HPAScaleToZero: {Default: false, PreRelease: featuregate.Alpha}, | 	HPAScaleToZero: {Default: false, PreRelease: featuregate.Alpha}, | ||||||
|  |  | ||||||
|  | 	DisableKubeletCSRAdmissionValidation: {Default: false, PreRelease: featuregate.Deprecated}, // remove in 1.33 | ||||||
|  |  | ||||||
| 	StorageNamespaceIndex: {Default: true, PreRelease: featuregate.Beta}, | 	StorageNamespaceIndex: {Default: true, PreRelease: featuregate.Beta}, | ||||||
|  |  | ||||||
| 	RecursiveReadOnlyMounts: {Default: true, PreRelease: featuregate.Beta}, | 	RecursiveReadOnlyMounts: {Default: true, PreRelease: featuregate.Beta}, | ||||||
|   | |||||||
| @@ -38,6 +38,7 @@ import ( | |||||||
| 	kubeletapis "k8s.io/kubelet/pkg/apis" | 	kubeletapis "k8s.io/kubelet/pkg/apis" | ||||||
| 	podutil "k8s.io/kubernetes/pkg/api/pod" | 	podutil "k8s.io/kubernetes/pkg/api/pod" | ||||||
| 	authenticationapi "k8s.io/kubernetes/pkg/apis/authentication" | 	authenticationapi "k8s.io/kubernetes/pkg/apis/authentication" | ||||||
|  | 	certapi "k8s.io/kubernetes/pkg/apis/certificates" | ||||||
| 	coordapi "k8s.io/kubernetes/pkg/apis/coordination" | 	coordapi "k8s.io/kubernetes/pkg/apis/coordination" | ||||||
| 	api "k8s.io/kubernetes/pkg/apis/core" | 	api "k8s.io/kubernetes/pkg/apis/core" | ||||||
| 	"k8s.io/kubernetes/pkg/apis/policy" | 	"k8s.io/kubernetes/pkg/apis/policy" | ||||||
| @@ -73,8 +74,9 @@ type Plugin struct { | |||||||
| 	podsGetter     corev1lister.PodLister | 	podsGetter     corev1lister.PodLister | ||||||
| 	nodesGetter    corev1lister.NodeLister | 	nodesGetter    corev1lister.NodeLister | ||||||
|  |  | ||||||
| 	expansionRecoveryEnabled         bool | 	expansionRecoveryEnabled              bool | ||||||
| 	dynamicResourceAllocationEnabled bool | 	dynamicResourceAllocationEnabled      bool | ||||||
|  | 	kubeletCSRAdmissionValidationDisabled bool | ||||||
| } | } | ||||||
|  |  | ||||||
| var ( | var ( | ||||||
| @@ -87,6 +89,7 @@ var ( | |||||||
| func (p *Plugin) InspectFeatureGates(featureGates featuregate.FeatureGate) { | func (p *Plugin) InspectFeatureGates(featureGates featuregate.FeatureGate) { | ||||||
| 	p.expansionRecoveryEnabled = featureGates.Enabled(features.RecoverVolumeExpansionFailure) | 	p.expansionRecoveryEnabled = featureGates.Enabled(features.RecoverVolumeExpansionFailure) | ||||||
| 	p.dynamicResourceAllocationEnabled = featureGates.Enabled(features.DynamicResourceAllocation) | 	p.dynamicResourceAllocationEnabled = featureGates.Enabled(features.DynamicResourceAllocation) | ||||||
|  | 	p.kubeletCSRAdmissionValidationDisabled = featureGates.Enabled(features.DisableKubeletCSRAdmissionValidation) | ||||||
| } | } | ||||||
|  |  | ||||||
| // SetExternalKubeInformerFactory registers an informer factory into Plugin | // SetExternalKubeInformerFactory registers an informer factory into Plugin | ||||||
| @@ -117,6 +120,7 @@ var ( | |||||||
| 	leaseResource         = coordapi.Resource("leases") | 	leaseResource         = coordapi.Resource("leases") | ||||||
| 	csiNodeResource       = storage.Resource("csinodes") | 	csiNodeResource       = storage.Resource("csinodes") | ||||||
| 	resourceSliceResource = resource.Resource("resourceslices") | 	resourceSliceResource = resource.Resource("resourceslices") | ||||||
|  | 	csrResource           = certapi.Resource("certificatesigningrequests") | ||||||
| ) | ) | ||||||
|  |  | ||||||
| // Admit checks the admission policy and triggers corresponding actions | // Admit checks the admission policy and triggers corresponding actions | ||||||
| @@ -171,6 +175,11 @@ func (p *Plugin) Admit(ctx context.Context, a admission.Attributes, o admission. | |||||||
| 	case resourceSliceResource: | 	case resourceSliceResource: | ||||||
| 		return p.admitResourceSlice(nodeName, a) | 		return p.admitResourceSlice(nodeName, a) | ||||||
|  |  | ||||||
|  | 	case csrResource: | ||||||
|  | 		if p.kubeletCSRAdmissionValidationDisabled { | ||||||
|  | 			return nil | ||||||
|  | 		} | ||||||
|  | 		return p.admitCSR(nodeName, a) | ||||||
| 	default: | 	default: | ||||||
| 		return nil | 		return nil | ||||||
| 	} | 	} | ||||||
| @@ -670,3 +679,31 @@ func (p *Plugin) admitResourceSlice(nodeName string, a admission.Attributes) err | |||||||
|  |  | ||||||
| 	return nil | 	return nil | ||||||
| } | } | ||||||
|  |  | ||||||
|  | func (p *Plugin) admitCSR(nodeName string, a admission.Attributes) error { | ||||||
|  | 	// Create requests for Kubelet serving signer and Kube API server client | ||||||
|  | 	// kubelet signer with a CN that begins with "system:node:" must have a CN | ||||||
|  | 	// that is exactly the node's name. | ||||||
|  | 	// Other CSR attributes get checked in CSR validation by the signer. | ||||||
|  | 	if a.GetOperation() != admission.Create { | ||||||
|  | 		return nil | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	csr, ok := a.GetObject().(*certapi.CertificateSigningRequest) | ||||||
|  | 	if !ok { | ||||||
|  | 		return admission.NewForbidden(a, fmt.Errorf("unexpected type %T", a.GetObject())) | ||||||
|  | 	} | ||||||
|  | 	if csr.Spec.SignerName != certapi.KubeletServingSignerName && csr.Spec.SignerName != certapi.KubeAPIServerClientKubeletSignerName { | ||||||
|  | 		return nil | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	x509cr, err := certapi.ParseCSR(csr.Spec.Request) | ||||||
|  | 	if err != nil { | ||||||
|  | 		return admission.NewForbidden(a, fmt.Errorf("unable to parse csr: %w", err)) | ||||||
|  | 	} | ||||||
|  | 	if x509cr.Subject.CommonName != fmt.Sprintf("system:node:%s", nodeName) { | ||||||
|  | 		return admission.NewForbidden(a, fmt.Errorf("can only create a node CSR with CN=system:node:%s", nodeName)) | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	return nil | ||||||
|  | } | ||||||
|   | |||||||
| @@ -18,6 +18,11 @@ package noderestriction | |||||||
|  |  | ||||||
| import ( | import ( | ||||||
| 	"context" | 	"context" | ||||||
|  | 	"crypto/rand" | ||||||
|  | 	"crypto/rsa" | ||||||
|  | 	"crypto/x509" | ||||||
|  | 	"crypto/x509/pkix" | ||||||
|  | 	"encoding/pem" | ||||||
| 	"reflect" | 	"reflect" | ||||||
| 	"strings" | 	"strings" | ||||||
| 	"testing" | 	"testing" | ||||||
| @@ -41,6 +46,7 @@ import ( | |||||||
| 	"k8s.io/component-base/featuregate" | 	"k8s.io/component-base/featuregate" | ||||||
| 	kubeletapis "k8s.io/kubelet/pkg/apis" | 	kubeletapis "k8s.io/kubelet/pkg/apis" | ||||||
| 	authenticationapi "k8s.io/kubernetes/pkg/apis/authentication" | 	authenticationapi "k8s.io/kubernetes/pkg/apis/authentication" | ||||||
|  | 	certificatesapi "k8s.io/kubernetes/pkg/apis/certificates" | ||||||
| 	"k8s.io/kubernetes/pkg/apis/coordination" | 	"k8s.io/kubernetes/pkg/apis/coordination" | ||||||
| 	api "k8s.io/kubernetes/pkg/apis/core" | 	api "k8s.io/kubernetes/pkg/apis/core" | ||||||
| 	"k8s.io/kubernetes/pkg/apis/policy" | 	"k8s.io/kubernetes/pkg/apis/policy" | ||||||
| @@ -213,11 +219,15 @@ type admitTestCase struct { | |||||||
| 	nodesGetter corev1lister.NodeLister | 	nodesGetter corev1lister.NodeLister | ||||||
| 	attributes  admission.Attributes | 	attributes  admission.Attributes | ||||||
| 	features    featuregate.FeatureGate | 	features    featuregate.FeatureGate | ||||||
|  | 	setupFunc   func(t *testing.T) | ||||||
| 	err         string | 	err         string | ||||||
| } | } | ||||||
|  |  | ||||||
| func (a *admitTestCase) run(t *testing.T) { | func (a *admitTestCase) run(t *testing.T) { | ||||||
| 	t.Run(a.name, func(t *testing.T) { | 	t.Run(a.name, func(t *testing.T) { | ||||||
|  | 		if a.setupFunc != nil { | ||||||
|  | 			a.setupFunc(t) | ||||||
|  | 		} | ||||||
| 		c := NewPlugin(nodeidentifier.NewDefaultNodeIdentifier()) | 		c := NewPlugin(nodeidentifier.NewDefaultNodeIdentifier()) | ||||||
| 		if a.features != nil { | 		if a.features != nil { | ||||||
| 			c.InspectFeatureGates(a.features) | 			c.InspectFeatureGates(a.features) | ||||||
| @@ -375,6 +385,8 @@ func Test_nodePlugin_Admit(t *testing.T) { | |||||||
| 		} | 		} | ||||||
| 		aLabeledPod  = withLabels(coremypod, labelsA) | 		aLabeledPod  = withLabels(coremypod, labelsA) | ||||||
| 		abLabeledPod = withLabels(coremypod, labelsAB) | 		abLabeledPod = withLabels(coremypod, labelsAB) | ||||||
|  |  | ||||||
|  | 		privKey, _ = rsa.GenerateKey(rand.Reader, 2048) | ||||||
| 	) | 	) | ||||||
|  |  | ||||||
| 	existingPodsIndex.Add(v1mymirrorpod) | 	existingPodsIndex.Add(v1mymirrorpod) | ||||||
| @@ -1238,6 +1250,42 @@ func Test_nodePlugin_Admit(t *testing.T) { | |||||||
| 			attributes: admission.NewAttributesRecord(nil, nil, csiNodeKind, nodeInfo.Namespace, nodeInfo.Name, csiNodeResource, "", admission.Delete, &metav1.UpdateOptions{}, false, mynode), | 			attributes: admission.NewAttributesRecord(nil, nil, csiNodeKind, nodeInfo.Namespace, nodeInfo.Name, csiNodeResource, "", admission.Delete, &metav1.UpdateOptions{}, false, mynode), | ||||||
| 			err:        "", | 			err:        "", | ||||||
| 		}, | 		}, | ||||||
|  | 		// CSR | ||||||
|  | 		{ | ||||||
|  | 			name:       "allowed CSR create correct node serving", | ||||||
|  | 			attributes: createCSRAttributes("system:node:mynode", certificatesapi.KubeletServingSignerName, true, privKey, mynode), | ||||||
|  | 			err:        "", | ||||||
|  | 		}, | ||||||
|  | 		{ | ||||||
|  | 			name:       "allowed CSR create correct node client", | ||||||
|  | 			attributes: createCSRAttributes("system:node:mynode", certificatesapi.KubeAPIServerClientKubeletSignerName, true, privKey, mynode), | ||||||
|  | 			err:        "", | ||||||
|  | 		}, | ||||||
|  | 		{ | ||||||
|  | 			name:       "allowed CSR create non-node CSR", | ||||||
|  | 			attributes: createCSRAttributes("some-other-identity", certificatesapi.KubeAPIServerClientSignerName, true, privKey, mynode), | ||||||
|  | 			err:        "", | ||||||
|  | 		}, | ||||||
|  | 		{ | ||||||
|  | 			name:       "deny CSR create incorrect node", | ||||||
|  | 			attributes: createCSRAttributes("system:node:othernode", certificatesapi.KubeletServingSignerName, true, privKey, mynode), | ||||||
|  | 			err:        "forbidden: can only create a node CSR with CN=system:node:mynode", | ||||||
|  | 		}, | ||||||
|  | 		{ | ||||||
|  | 			name:       "allow CSR create incorrect node with feature gate disabled", | ||||||
|  | 			attributes: createCSRAttributes("system:node:othernode", certificatesapi.KubeletServingSignerName, true, privKey, mynode), | ||||||
|  | 			err:        "", | ||||||
|  | 			features:   feature.DefaultFeatureGate, | ||||||
|  | 			setupFunc: func(t *testing.T) { | ||||||
|  | 				t.Helper() | ||||||
|  | 				featuregatetesting.SetFeatureGateDuringTest(t, feature.DefaultFeatureGate, features.DisableKubeletCSRAdmissionValidation, true) | ||||||
|  | 			}, | ||||||
|  | 		}, | ||||||
|  | 		{ | ||||||
|  | 			name:       "deny CSR create invalid", | ||||||
|  | 			attributes: createCSRAttributes("system:node:mynode", certificatesapi.KubeletServingSignerName, false, privKey, mynode), | ||||||
|  | 			err:        "unable to parse csr: asn1: syntax error: sequence truncated", | ||||||
|  | 		}, | ||||||
| 	} | 	} | ||||||
| 	for _, tt := range tests { | 	for _, tt := range tests { | ||||||
| 		tt.nodesGetter = existingNodes | 		tt.nodesGetter = existingNodes | ||||||
| @@ -1603,6 +1651,31 @@ func createPodAttributes(pod *api.Pod, user user.Info) admission.Attributes { | |||||||
| 	return admission.NewAttributesRecord(pod, nil, podKind, pod.Namespace, pod.Name, podResource, "", admission.Create, &metav1.CreateOptions{}, false, user) | 	return admission.NewAttributesRecord(pod, nil, podKind, pod.Namespace, pod.Name, podResource, "", admission.Create, &metav1.CreateOptions{}, false, user) | ||||||
| } | } | ||||||
|  |  | ||||||
|  | func createCSRAttributes(cn, signer string, validCsr bool, key any, user user.Info) admission.Attributes { | ||||||
|  | 	csrResource := certificatesapi.Resource("certificatesigningrequests").WithVersion("v1") | ||||||
|  | 	csrKind := certificatesapi.Kind("CertificateSigningRequest").WithVersion("v1") | ||||||
|  |  | ||||||
|  | 	csrPem := []byte("-----BEGIN CERTIFICATE REQUEST-----\n-----END CERTIFICATE REQUEST-----") | ||||||
|  | 	if validCsr { | ||||||
|  | 		structuredCsr := x509.CertificateRequest{ | ||||||
|  | 			Subject: pkix.Name{ | ||||||
|  | 				CommonName: cn, | ||||||
|  | 			}, | ||||||
|  | 		} | ||||||
|  | 		csrDer, _ := x509.CreateCertificateRequest(rand.Reader, &structuredCsr, key) | ||||||
|  | 		csrPem = pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE REQUEST", Bytes: csrDer}) | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	csreq := &certificatesapi.CertificateSigningRequest{ | ||||||
|  | 		Spec: certificatesapi.CertificateSigningRequestSpec{ | ||||||
|  | 			Request:    csrPem, | ||||||
|  | 			SignerName: signer, | ||||||
|  | 		}, | ||||||
|  | 	} | ||||||
|  | 	return admission.NewAttributesRecord(csreq, nil, csrKind, "", "", csrResource, "", admission.Create, &metav1.CreateOptions{}, false, user) | ||||||
|  |  | ||||||
|  | } | ||||||
|  |  | ||||||
| func TestAdmitResourceSlice(t *testing.T) { | func TestAdmitResourceSlice(t *testing.T) { | ||||||
| 	apiResource := resourceapi.SchemeGroupVersion.WithResource("resourceslices") | 	apiResource := resourceapi.SchemeGroupVersion.WithResource("resourceslices") | ||||||
| 	nodename := "mynode" | 	nodename := "mynode" | ||||||
|   | |||||||
		Reference in New Issue
	
	Block a user
	 Kubernetes Prow Robot
					Kubernetes Prow Robot