From e678746f3bd9b8ee875594d8271b8fe2830f667b Mon Sep 17 00:00:00 2001 From: Carlos Eduardo Arango Gutierrez Date: Thu, 12 Feb 2026 21:27:53 +0100 Subject: [PATCH] fix(security): validate node labels and IPs before shell interpolation User-controlled label keys/values from YAML config were directly interpolated into kubectl commands via fmt.Sprintf, enabling command injection. Add label validation against Kubernetes label pattern. Also validate PrivateIP with net.ParseIP before grep interpolation. Audit findings #17 (MEDIUM), #18 (MEDIUM). Signed-off-by: Carlos Eduardo Arango Gutierrez --- api/holodeck/v1alpha1/validation.go | 25 +++++++++++++++++++++++++ pkg/provisioner/cluster.go | 8 ++++++++ 2 files changed, 33 insertions(+) diff --git a/api/holodeck/v1alpha1/validation.go b/api/holodeck/v1alpha1/validation.go index f72140cd9..828eb95a2 100644 --- a/api/holodeck/v1alpha1/validation.go +++ b/api/holodeck/v1alpha1/validation.go @@ -18,8 +18,23 @@ package v1alpha1 import ( "fmt" + "regexp" ) +var k8sLabelPattern = regexp.MustCompile(`^[a-zA-Z0-9]([a-zA-Z0-9._\-/]*[a-zA-Z0-9])?$`) + +func validateLabels(labels map[string]string) error { + for k, v := range labels { + if !k8sLabelPattern.MatchString(k) { + return fmt.Errorf("invalid label key %q: contains disallowed characters", k) + } + if v != "" && !k8sLabelPattern.MatchString(v) { + return fmt.Errorf("invalid label value %q for key %q: contains disallowed characters", v, k) + } + } + return nil +} + // Validate validates the ClusterSpec configuration. func (c *ClusterSpec) Validate() error { if c == nil { @@ -43,6 +58,16 @@ func (c *ClusterSpec) Validate() error { } } + // Validate labels for shell-injection safety + if err := validateLabels(c.ControlPlane.Labels); err != nil { + return fmt.Errorf("control-plane labels: %w", err) + } + if c.Workers != nil { + if err := validateLabels(c.Workers.Labels); err != nil { + return fmt.Errorf("worker labels: %w", err) + } + } + // Validate HA configuration if c.HighAvailability != nil { if err := c.HighAvailability.Validate(c.ControlPlane.Count); err != nil { diff --git a/pkg/provisioner/cluster.go b/pkg/provisioner/cluster.go index d596a6672..7e66001e8 100644 --- a/pkg/provisioner/cluster.go +++ b/pkg/provisioner/cluster.go @@ -20,6 +20,7 @@ import ( "bytes" "fmt" "io" + "net" "os" "strings" "sync" @@ -437,6 +438,13 @@ func (cp *ClusterProvisioner) configureNodes(firstCP NodeInfo, nodes []NodeInfo) } defer provisioner.Client.Close() // nolint: errcheck + // Validate all node IPs before interpolating into shell commands + for _, node := range nodes { + if net.ParseIP(node.PrivateIP) == nil { + return fmt.Errorf("invalid private IP for node %s: %q", node.Name, node.PrivateIP) + } + } + // Build the node configuration script // Note: Use sudo -E to preserve KUBECONFIG environment variable, or use --kubeconfig flag var script strings.Builder