Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
// Package handlers β€” input validation helpers shared by RBAC and namespace
// handlers. Added for #6627: the RBAC/namespace request structs previously had
// no field-level validation, so empty or malformed payloads silently relied on
// Field-level input validation helpers shared by RBAC and namespace handlers.
// Added for #6627: the RBAC/namespace request structs previously had no
// field-level validation, so empty or malformed payloads silently relied on
// downstream Kubernetes API checks. These helpers centralise the rules so every
// handler rejects bad input at the HTTP boundary with a specific 400 error.
package handlers
// Moved here from the root handlers package in epic #23685.
package httputil

import (
"fmt"
Expand Down Expand Up @@ -47,10 +48,10 @@ var dnsSubdomainRegex = regexp.MustCompile(`^[a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-
// #6675 Copilot followup: allow any number of `:label` segments.
var roleNameRegex = regexp.MustCompile(`^[a-z0-9]([-a-z0-9]*[a-z0-9])?(:[a-z0-9]([-a-z0-9]*[a-z0-9])?)*(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*$`)

// validateDNSLabel checks that s is a non-empty RFC 1123 DNS label suitable
// ValidateDNSLabel checks that s is a non-empty RFC 1123 DNS label suitable
// for a Kubernetes object name (ServiceAccount, Namespace, RoleBinding, etc).
// Returns a user-facing error that names the field.
func validateDNSLabel(field, s string) error {
func ValidateDNSLabel(field, s string) error {
if s == "" {
return fmt.Errorf("%s is required", field)
}
Expand All @@ -63,10 +64,10 @@ func validateDNSLabel(field, s string) error {
return nil
}

// validateDNSSubdomain checks that s is a non-empty RFC 1123 DNS subdomain.
// ValidateDNSSubdomain checks that s is a non-empty RFC 1123 DNS subdomain.
// Used for fields that may legitimately contain dots (not currently used,
// kept for future extensibility when we validate e.g. hostnames).
func validateDNSSubdomain(field, s string) error {
func ValidateDNSSubdomain(field, s string) error {
if s == "" {
return fmt.Errorf("%s is required", field)
}
Expand All @@ -79,10 +80,10 @@ func validateDNSSubdomain(field, s string) error {
return nil
}

// validateClusterName is a looser validator for cluster IDs. Cluster names
// ValidateClusterName is a looser validator for cluster IDs. Cluster names
// in this codebase come from kubeconfig contexts and may contain dots,
// dashes, slashes, and digits. We only enforce non-empty and length.
func validateClusterName(field, s string) error {
func ValidateClusterName(field, s string) error {
if s == "" {
return fmt.Errorf("%s is required", field)
}
Expand All @@ -95,9 +96,9 @@ func validateClusterName(field, s string) error {
return nil
}

// validateRoleName accepts a Kubernetes Role/ClusterRole name. These may
// ValidateRoleName accepts a Kubernetes Role/ClusterRole name. These may
// contain a system: prefix and optional dots.
func validateRoleName(field, s string) error {
func ValidateRoleName(field, s string) error {
if s == "" {
return fmt.Errorf("%s is required", field)
}
Expand All @@ -110,9 +111,9 @@ func validateRoleName(field, s string) error {
return nil
}

// validateEnum checks that s is one of allowed (case-sensitive). Used for
// ValidateEnum checks that s is one of allowed (case-sensitive). Used for
// fields like subjectKind, roleKind, and the namespace-access role shortcuts.
func validateEnum(field, s string, allowed []string) error {
func ValidateEnum(field, s string, allowed []string) error {
if s == "" {
return fmt.Errorf("%s is required", field)
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
package handlers
package httputil

import (
"strings"
Expand Down Expand Up @@ -28,7 +28,7 @@ func TestValidateDNSLabel(t *testing.T) {
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
err := validateDNSLabel("field", tc.value)
err := ValidateDNSLabel("field", tc.value)
if tc.wantErr && err == nil {
t.Fatalf("expected error, got nil")
}
Expand Down Expand Up @@ -56,10 +56,11 @@ func TestValidateClusterName(t *testing.T) {
{"empty", "", true},
{"newline", "prod\nhacked", true},
{"tab", "prod\thacked", true},
{"too long", strings.Repeat("a", maxK8sDNSSubdomainLen+1), true},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
err := validateClusterName("cluster", tc.value)
err := ValidateClusterName("cluster", tc.value)
if tc.wantErr && err == nil {
t.Fatalf("expected error")
}
Expand All @@ -84,10 +85,11 @@ func TestValidateRoleName(t *testing.T) {
{"dotted", "rbac.example.com", false},
{"empty", "", true},
{"uppercase", "Admin", true},
{"too long", strings.Repeat("a", maxRoleNameLen+1), true},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
err := validateRoleName("role", tc.value)
err := ValidateRoleName("role", tc.value)
if tc.wantErr && err == nil {
t.Fatalf("expected error")
}
Expand All @@ -101,18 +103,18 @@ func TestValidateRoleName(t *testing.T) {
// TestValidateEnum covers accept/reject and the empty-string required case.
func TestValidateEnum(t *testing.T) {
allowed := []string{"User", "Group", "ServiceAccount"}
if err := validateEnum("subjectKind", "User", allowed); err != nil {
if err := ValidateEnum("subjectKind", "User", allowed); err != nil {
t.Fatalf("unexpected: %v", err)
}
if err := validateEnum("subjectKind", "pod", allowed); err == nil {
if err := ValidateEnum("subjectKind", "pod", allowed); err == nil {
t.Fatalf("expected error for disallowed value")
}
if err := validateEnum("subjectKind", "", allowed); err == nil {
if err := ValidateEnum("subjectKind", "", allowed); err == nil {
t.Fatalf("expected error for empty value")
}
}

// TestValidateDNSSubdomain exercises validateDNSSubdomain's empty, too-long,
// TestValidateDNSSubdomain exercises ValidateDNSSubdomain's empty, too-long,
// invalid-char, and valid (including multi-label) cases.
func TestValidateDNSSubdomain(t *testing.T) {
cases := []struct {
Expand All @@ -131,7 +133,7 @@ func TestValidateDNSSubdomain(t *testing.T) {
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
err := validateDNSSubdomain("field", tc.value)
err := ValidateDNSSubdomain("field", tc.value)
if tc.wantErr && err == nil {
t.Fatalf("expected error, got nil")
}
Expand Down
46 changes: 46 additions & 0 deletions pkg/api/handlers/internal/httputil/validation.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
// Cron-expression and Kubernetes API-version validators. Moved here from the
// root handlers package in epic #23685 so domain subpackages can share them.
package httputil

import (
"regexp"
"strings"
)

// cronFieldCount is the number of fields in a standard cron expression.
const cronFieldCount = 5

// cronFieldPattern matches a single cron field (digits, *, /, -, comma).
var cronFieldPattern = regexp.MustCompile(`^[\d\*,/\-]+$`)

// K8sVersionPattern matches Kubernetes API versions (e.g. "v1", "v1beta1", "v2alpha1").
var K8sVersionPattern = regexp.MustCompile(`^v[0-9]+([a-z]+[0-9]+)?$`)

// maxCronFieldLen is the maximum length of a single cron field to prevent abuse.
const maxCronFieldLen = 64

// IsValidCronSchedule validates a 5-field cron expression.
// It does not validate semantic correctness (e.g. day 32), only structural format.
func IsValidCronSchedule(schedule string) bool {
fields := strings.Fields(schedule)
if len(fields) != cronFieldCount {
return false
}
for _, f := range fields {
if len(f) > maxCronFieldLen {
return false
}
if !cronFieldPattern.MatchString(f) {
return false
}
}
return true
}

// IsValidK8sVersion validates a Kubernetes API version string.
func IsValidK8sVersion(version string) bool {
if len(version) > MaxK8sNameLen {
return false
}
return K8sVersionPattern.MatchString(version)
}
Loading
Loading