Skip to content

Commit 82ea43f

Browse files
Make NodeReadinessRule spec fields immutable to prevent security issues (#164)
1 parent 5b50503 commit 82ea43f

File tree

5 files changed

+141
-188
lines changed

5 files changed

+141
-188
lines changed

api/v1alpha1/nodereadinessrule_types.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,7 @@ type NodeReadinessRuleSpec struct {
5656
// +listMapKey=type
5757
// +kubebuilder:validation:MinItems=1
5858
// +kubebuilder:validation:MaxItems=32
59+
// +kubebuilder:validation:XValidation:rule="self == oldSelf",message="conditions is immutable"
5960
Conditions []ConditionRequirement `json:"conditions"` //nolint:kubeapilinter
6061

6162
// enforcementMode specifies how the controller maintains the desired state.
@@ -64,6 +65,7 @@ type NodeReadinessRuleSpec struct {
6465
// "continuous" ensures the state is monitored and corrected throughout the resource lifecycle.
6566
//
6667
// +required
68+
// +kubebuilder:validation:XValidation:rule="self == oldSelf",message="enforcementMode is immutable"
6769
EnforcementMode EnforcementMode `json:"enforcementMode,omitempty"`
6870

6971
// taint defines the specific Taint (Key, Value, and Effect) to be managed
@@ -86,11 +88,15 @@ type NodeReadinessRuleSpec struct {
8688
// +kubebuilder:validation:XValidation:rule="self.key.split('/')[1].matches('^[A-Za-z0-9]([-A-Za-z0-9_.]*[A-Za-z0-9])?$')",message="taint key name part must consist of alphanumeric characters, '-', '_' or '.', and must start and end with an alphanumeric character"
8789
// +kubebuilder:validation:XValidation:rule="!has(self.value) || self.value.size() <= 63",message="taint value length must be at most 63 characters"
8890
// +kubebuilder:validation:XValidation:rule="self.effect in ['NoSchedule', 'PreferNoSchedule', 'NoExecute']",message="taint effect must be one of 'NoSchedule', 'PreferNoSchedule', 'NoExecute'"
91+
// +kubebuilder:validation:XValidation:rule="!has(oldSelf.key) || self.key == oldSelf.key",message="taint key is immutable"
92+
// +kubebuilder:validation:XValidation:rule="!has(oldSelf.effect) || self.effect == oldSelf.effect",message="taint effect is immutable"
93+
// +kubebuilder:validation:XValidation:rule="!has(oldSelf.value) || self.value == oldSelf.value",message="taint value is immutable"
8994
Taint corev1.Taint `json:"taint,omitempty,omitzero"`
9095

9196
// nodeSelector limits the scope of this rule to a specific subset of Nodes.
9297
//
9398
// +required
99+
// +kubebuilder:validation:XValidation:rule="self == oldSelf",message="nodeSelector is immutable"
94100
NodeSelector metav1.LabelSelector `json:"nodeSelector,omitempty,omitzero"`
95101

96102
// dryRun when set to true, The controller will evaluate Node conditions and log intended taint modifications

config/crd/bases/readiness.node.x-k8s.io_nodereadinessrules.yaml

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,9 @@ spec:
9191
x-kubernetes-list-map-keys:
9292
- type
9393
x-kubernetes-list-type: map
94+
x-kubernetes-validations:
95+
- message: conditions is immutable
96+
rule: self == oldSelf
9497
dryRun:
9598
description: |-
9699
dryRun when set to true, The controller will evaluate Node conditions and log intended taint modifications
@@ -106,6 +109,9 @@ spec:
106109
- bootstrap-only
107110
- continuous
108111
type: string
112+
x-kubernetes-validations:
113+
- message: enforcementMode is immutable
114+
rule: self == oldSelf
109115
nodeSelector:
110116
description: nodeSelector limits the scope of this rule to a specific
111117
subset of Nodes.
@@ -153,6 +159,9 @@ spec:
153159
type: object
154160
type: object
155161
x-kubernetes-map-type: atomic
162+
x-kubernetes-validations:
163+
- message: nodeSelector is immutable
164+
rule: self == oldSelf
156165
taint:
157166
description: |-
158167
taint defines the specific Taint (Key, Value, and Effect) to be managed
@@ -207,6 +216,12 @@ spec:
207216
- message: taint effect must be one of 'NoSchedule', 'PreferNoSchedule',
208217
'NoExecute'
209218
rule: self.effect in ['NoSchedule', 'PreferNoSchedule', 'NoExecute']
219+
- message: taint key is immutable
220+
rule: '!has(oldSelf.key) || self.key == oldSelf.key'
221+
- message: taint effect is immutable
222+
rule: '!has(oldSelf.effect) || self.effect == oldSelf.effect'
223+
- message: taint value is immutable
224+
rule: '!has(oldSelf.value) || self.value == oldSelf.value'
210225
required:
211226
- conditions
212227
- enforcementMode

internal/controller/nodereadinessrule_controller_test.go

Lines changed: 80 additions & 66 deletions
Original file line numberDiff line numberDiff line change
@@ -445,7 +445,6 @@ var _ = Describe("NodeReadinessRule Controller", func() {
445445
Key: "readiness.k8s.io/missing-key",
446446
Effect: corev1.TaintEffectNoSchedule,
447447
}
448-
449448
hasTaint = readinessController.hasTaintBySpec(node, nonExistentTaint)
450449
Expect(hasTaint).To(BeFalse())
451450
})
@@ -958,7 +957,7 @@ var _ = Describe("NodeReadinessRule Controller", func() {
958957
})
959958
})
960959

961-
Context("when a rule's nodeSelector changes", func() {
960+
Context("when a rule's nodeSelector is modified", func() {
962961
var rule *nodereadinessiov1alpha1.NodeReadinessRule
963962
var prodNode, devNode *corev1.Node
964963

@@ -1016,78 +1015,93 @@ var _ = Describe("NodeReadinessRule Controller", func() {
10161015
_ = k8sClient.Delete(ctx, rule)
10171016
})
10181017

1019-
It("should remove taints from nodes that no longer match the selector", func() {
1020-
// Initial reconcile - prod node should be managed, dev node should not
1021-
_, err := ruleReconciler.Reconcile(ctx, reconcile.Request{NamespacedName: types.NamespacedName{Name: "selector-change-rule"}})
1022-
Expect(err).NotTo(HaveOccurred())
1023-
1024-
// Verify prod node still has taint (condition not met)
1025-
Eventually(func() bool {
1026-
updatedNode := &corev1.Node{}
1027-
if err := k8sClient.Get(ctx, types.NamespacedName{Name: "prod-node"}, updatedNode); err != nil {
1028-
return false
1029-
}
1030-
for _, taint := range updatedNode.Spec.Taints {
1031-
if taint.Key == selectorChangeTaintKey {
1032-
return true
1033-
}
1034-
}
1035-
return false
1036-
}, time.Second*5).Should(BeTrue(), "Prod node should have taint")
1037-
1038-
// Verify dev node does not have taint (not selected)
1039-
Consistently(func() bool {
1040-
updatedNode := &corev1.Node{}
1041-
if err := k8sClient.Get(ctx, types.NamespacedName{Name: "dev-node"}, updatedNode); err != nil {
1042-
return false
1043-
}
1044-
for _, taint := range updatedNode.Spec.Taints {
1045-
if taint.Key == selectorChangeTaintKey {
1046-
return false // Taint found (unexpected)
1047-
}
1048-
}
1049-
return true // No taint (expected)
1050-
}, time.Second*2).Should(BeTrue(), "Dev node should not have taint")
1051-
1052-
// Update rule to target dev nodes instead of prod nodes
1018+
It("should reject attempts to change the nodeSelector (immutable)", func() {
10531019
updatedRule := &nodereadinessiov1alpha1.NodeReadinessRule{}
10541020
Expect(k8sClient.Get(ctx, types.NamespacedName{Name: "selector-change-rule"}, updatedRule)).To(Succeed())
10551021
updatedRule.Spec.NodeSelector = metav1.LabelSelector{
10561022
MatchLabels: map[string]string{"env": "dev"},
10571023
}
1058-
Expect(k8sClient.Update(ctx, updatedRule)).To(Succeed())
1024+
err := k8sClient.Update(ctx, updatedRule)
1025+
Expect(err).To(HaveOccurred())
1026+
Expect(err.Error()).To(ContainSubstring("nodeSelector is immutable"))
1027+
})
1028+
})
10591029

1060-
// Trigger reconciliation
1061-
_, err = ruleReconciler.Reconcile(ctx, reconcile.Request{NamespacedName: types.NamespacedName{Name: "selector-change-rule"}})
1062-
Expect(err).NotTo(HaveOccurred())
1030+
Context("when attempting to modify immutable fields", func() {
1031+
var rule *nodereadinessiov1alpha1.NodeReadinessRule
10631032

1064-
// Verify taint is removed from prod node (no longer selected)
1065-
Eventually(func() bool {
1066-
updatedNode := &corev1.Node{}
1067-
if err := k8sClient.Get(ctx, types.NamespacedName{Name: "prod-node"}, updatedNode); err != nil {
1068-
return false
1069-
}
1070-
for _, taint := range updatedNode.Spec.Taints {
1071-
if taint.Key == selectorChangeTaintKey {
1072-
return false // Taint still exists
1073-
}
1074-
}
1075-
return true // Taint removed
1076-
}, time.Second*10).Should(BeTrue(), "Prod node taint should be removed after selector change")
1033+
BeforeEach(func() {
1034+
rule = &nodereadinessiov1alpha1.NodeReadinessRule{
1035+
ObjectMeta: metav1.ObjectMeta{
1036+
Name: "immutability-test-rule",
1037+
},
1038+
Spec: nodereadinessiov1alpha1.NodeReadinessRuleSpec{
1039+
Conditions: []nodereadinessiov1alpha1.ConditionRequirement{
1040+
{Type: "Ready", RequiredStatus: corev1.ConditionTrue},
1041+
},
1042+
Taint: corev1.Taint{
1043+
Key: "readiness.k8s.io/immutable",
1044+
Effect: corev1.TaintEffectNoSchedule,
1045+
Value: "test-value",
1046+
},
1047+
EnforcementMode: nodereadinessiov1alpha1.EnforcementModeBootstrapOnly,
1048+
NodeSelector: metav1.LabelSelector{
1049+
MatchLabels: map[string]string{"test": "immutable"},
1050+
},
1051+
},
1052+
}
1053+
Expect(k8sClient.Create(ctx, rule)).To(Succeed())
1054+
})
10771055

1078-
// Verify dev node now gets taint (newly selected, condition not met)
1079-
Eventually(func() bool {
1080-
updatedNode := &corev1.Node{}
1081-
if err := k8sClient.Get(ctx, types.NamespacedName{Name: "dev-node"}, updatedNode); err != nil {
1082-
return false
1083-
}
1084-
for _, taint := range updatedNode.Spec.Taints {
1085-
if taint.Key == selectorChangeTaintKey {
1086-
return true
1087-
}
1088-
}
1089-
return false
1090-
}, time.Second*10).Should(BeTrue(), "Dev node should now have taint after selector change")
1056+
AfterEach(func() {
1057+
_ = k8sClient.Delete(ctx, rule)
1058+
})
1059+
1060+
It("should reject attempts to change taint.key", func() {
1061+
updatedRule := &nodereadinessiov1alpha1.NodeReadinessRule{}
1062+
Expect(k8sClient.Get(ctx, types.NamespacedName{Name: "immutability-test-rule"}, updatedRule)).To(Succeed())
1063+
updatedRule.Spec.Taint.Key = "readiness.k8s.io/different-key"
1064+
err := k8sClient.Update(ctx, updatedRule)
1065+
Expect(err).To(HaveOccurred())
1066+
Expect(err.Error()).To(ContainSubstring("taint key is immutable"))
1067+
})
1068+
1069+
It("should reject attempts to change taint.effect", func() {
1070+
updatedRule := &nodereadinessiov1alpha1.NodeReadinessRule{}
1071+
Expect(k8sClient.Get(ctx, types.NamespacedName{Name: "immutability-test-rule"}, updatedRule)).To(Succeed())
1072+
updatedRule.Spec.Taint.Effect = corev1.TaintEffectNoExecute
1073+
err := k8sClient.Update(ctx, updatedRule)
1074+
Expect(err).To(HaveOccurred())
1075+
Expect(err.Error()).To(ContainSubstring("taint effect is immutable"))
1076+
})
1077+
1078+
It("should reject attempts to change taint.value", func() {
1079+
updatedRule := &nodereadinessiov1alpha1.NodeReadinessRule{}
1080+
Expect(k8sClient.Get(ctx, types.NamespacedName{Name: "immutability-test-rule"}, updatedRule)).To(Succeed())
1081+
updatedRule.Spec.Taint.Value = "different-value"
1082+
err := k8sClient.Update(ctx, updatedRule)
1083+
Expect(err).To(HaveOccurred())
1084+
Expect(err.Error()).To(ContainSubstring("taint value is immutable"))
1085+
})
1086+
1087+
It("should reject attempts to change conditions", func() {
1088+
updatedRule := &nodereadinessiov1alpha1.NodeReadinessRule{}
1089+
Expect(k8sClient.Get(ctx, types.NamespacedName{Name: "immutability-test-rule"}, updatedRule)).To(Succeed())
1090+
updatedRule.Spec.Conditions = []nodereadinessiov1alpha1.ConditionRequirement{
1091+
{Type: "DiskPressure", RequiredStatus: corev1.ConditionFalse},
1092+
}
1093+
err := k8sClient.Update(ctx, updatedRule)
1094+
Expect(err).To(HaveOccurred())
1095+
Expect(err.Error()).To(ContainSubstring("conditions is immutable"))
1096+
})
1097+
1098+
It("should reject attempts to change enforcementMode", func() {
1099+
updatedRule := &nodereadinessiov1alpha1.NodeReadinessRule{}
1100+
Expect(k8sClient.Get(ctx, types.NamespacedName{Name: "immutability-test-rule"}, updatedRule)).To(Succeed())
1101+
updatedRule.Spec.EnforcementMode = nodereadinessiov1alpha1.EnforcementModeContinuous
1102+
err := k8sClient.Update(ctx, updatedRule)
1103+
Expect(err).To(HaveOccurred())
1104+
Expect(err.Error()).To(ContainSubstring("enforcementMode is immutable"))
10911105
})
10921106
})
10931107

internal/webhook/nodereadinessgaterule_webhook.go

Lines changed: 11 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -44,8 +44,6 @@ func NewNodeReadinessRuleWebhook(c client.Client) *NodeReadinessRuleWebhook {
4444
}
4545
}
4646

47-
// +kubebuilder:webhook:path=/validate-readiness-node-x-k8s-io-v1alpha1-nodereadinessrule,mutating=false,failurePolicy=fail,sideEffects=None,groups=readiness.node.x-k8s.io,resources=nodereadinessrules,verbs=create;update,versions=v1alpha1,name=vnodereadinessrule.kb.io,admissionReviewVersions=v1
48-
4947
// validateNodeReadinessRule performs validation logic.
5048
func (w *NodeReadinessRuleWebhook) validateNodeReadinessRule(ctx context.Context, rule *readinessv1alpha1.NodeReadinessRule, isUpdate bool) field.ErrorList {
5149
var allErrs field.ErrorList
@@ -59,54 +57,17 @@ func (w *NodeReadinessRuleWebhook) validateNodeReadinessRule(ctx context.Context
5957
return allErrs
6058
}
6159

62-
// validateSpec validates the spec fields.
60+
// validateSpec validates the spec fields that CRD CEL based XValidation cannot handle.
6361
func (w *NodeReadinessRuleWebhook) validateSpec(spec readinessv1alpha1.NodeReadinessRuleSpec) field.ErrorList {
6462
var allErrs field.ErrorList
65-
specField := field.NewPath("spec")
66-
67-
// Validate conditions
68-
if len(spec.Conditions) == 0 {
69-
allErrs = append(allErrs, field.Required(specField.Child("conditions"), "at least one condition is required"))
70-
}
71-
72-
for i, condition := range spec.Conditions {
73-
condField := specField.Child("conditions").Index(i)
74-
if condition.Type == "" {
75-
allErrs = append(allErrs, field.Required(condField.Child("type"), "condition type cannot be empty"))
76-
}
77-
if condition.RequiredStatus == "" {
78-
allErrs = append(allErrs, field.Required(condField.Child("requiredStatus"), "required status cannot be empty"))
79-
}
80-
}
8163

82-
// Validate nodeSelector
64+
// validate that the nodeSelector isn't empty
8365
selector, err := metav1.LabelSelectorAsSelector(&spec.NodeSelector)
8466
if err != nil {
85-
allErrs = append(allErrs, field.Invalid(specField.Child("nodeSelector"), spec.NodeSelector, err.Error()))
67+
allErrs = append(allErrs, field.Invalid(field.NewPath("spec", "nodeSelector"), spec.NodeSelector, err.Error()))
8668
}
87-
88-
// Validate that the nodeSelector isn't empty.
8969
if selector != nil && selector.Empty() {
90-
allErrs = append(allErrs, field.Required(specField.Child("nodeSelector"), "nodeSelector must not be empty"))
91-
}
92-
93-
// Validate taint
94-
taintField := specField.Child("taint")
95-
if spec.Taint.Key == "" {
96-
allErrs = append(allErrs, field.Required(taintField.Child("key"), "taint key cannot be empty"))
97-
}
98-
if spec.Taint.Effect == "" {
99-
allErrs = append(allErrs, field.Required(taintField.Child("effect"), "taint effect cannot be empty"))
100-
}
101-
102-
// Validate enforcement mode
103-
if spec.EnforcementMode != readinessv1alpha1.EnforcementModeBootstrapOnly &&
104-
spec.EnforcementMode != readinessv1alpha1.EnforcementModeContinuous {
105-
allErrs = append(allErrs, field.Invalid(
106-
specField.Child("enforcementMode"),
107-
spec.EnforcementMode,
108-
"must be 'bootstrap-only' or 'continuous'",
109-
))
70+
allErrs = append(allErrs, field.Required(field.NewPath("spec", "nodeSelector"), "nodeSelector must not be empty"))
11071
}
11172

11273
return allErrs
@@ -169,6 +130,7 @@ func (w *NodeReadinessRuleWebhook) nodSelectorsOverlap(selector1, selector2 meta
169130
// generateNoExecuteWarnings generates admission warnings for NoExecute taint usage.
170131
// NoExecute taints cause immediate pod eviction, which can be disruptive when
171132
// used with continuous enforcement mode.
133+
// Note: This is only called on CREATE since taint.effect is immutable after creation.
172134
func (w *NodeReadinessRuleWebhook) generateNoExecuteWarnings(spec readinessv1alpha1.NodeReadinessRuleSpec) admission.Warnings {
173135
var warnings admission.Warnings
174136

@@ -189,6 +151,7 @@ func (w *NodeReadinessRuleWebhook) generateNoExecuteWarnings(spec readinessv1alp
189151
return warnings
190152
}
191153

154+
// +kubebuilder:webhook:path=/validate-readiness-node-x-k8s-io-v1alpha1-nodereadinessrule,mutating=false,failurePolicy=fail,sideEffects=None,groups=readiness.node.x-k8s.io,resources=nodereadinessrules,verbs=create;update,versions=v1alpha1,name=vnodereadinessrule.kb.io,admissionReviewVersions=v1
192155
// SetupWithManager sets up the webhook with the manager.
193156
func (w *NodeReadinessRuleWebhook) SetupWithManager(mgr ctrl.Manager) error {
194157
return ctrl.NewWebhookManagedBy(mgr).
@@ -216,18 +179,18 @@ func (w *NodeReadinessRuleWebhook) ValidateCreate(ctx context.Context, obj runti
216179
}
217180

218181
func (w *NodeReadinessRuleWebhook) ValidateUpdate(ctx context.Context, oldObj, newObj runtime.Object) (admission.Warnings, error) {
219-
rule, ok := newObj.(*readinessv1alpha1.NodeReadinessRule)
182+
// Update validations are handled at the API level, so we can skip them here to avoid redundant checks.
183+
184+
newRule, ok := newObj.(*readinessv1alpha1.NodeReadinessRule)
220185
if !ok {
221186
return nil, fmt.Errorf("expected NodeReadinessRule, got %T", newObj)
222187
}
223188

224-
if allErrs := w.validateNodeReadinessRule(ctx, rule, true); len(allErrs) > 0 {
189+
if allErrs := w.validateNodeReadinessRule(ctx, newRule, true); len(allErrs) > 0 {
225190
return nil, fmt.Errorf("validation failed: %v", allErrs)
226191
}
227192

228-
// Generate warnings for NoExecute taint usage
229-
warnings := w.generateNoExecuteWarnings(rule.Spec)
230-
return warnings, nil
193+
return nil, nil
231194
}
232195

233196
func (w *NodeReadinessRuleWebhook) ValidateDelete(ctx context.Context, obj runtime.Object) (admission.Warnings, error) {

0 commit comments

Comments
 (0)