diff --git a/api/v1alpha1/storagecluster_types.go b/api/v1alpha1/storagecluster_types.go index 3bd0e67b..3ea65441 100644 --- a/api/v1alpha1/storagecluster_types.go +++ b/api/v1alpha1/storagecluster_types.go @@ -180,11 +180,13 @@ type BackupCredentialsSecretRef struct { // HashicorpVaultSettings configures the HashiCorp Vault endpoint the cluster uses to store keys. type HashicorpVaultSettings struct { // +operator-sdk:csv:customresourcedefinitions:type=spec,displayName="Vault Base URL" + // +kubebuilder:validation:Pattern=`^https://[a-zA-Z0-9.-]+(:[0-9]{1,5})?(/.*)?$` // BaseURL is the HashiCorp Vault endpoint (e.g. https://vault.example.com:8200). BaseURL string `json:"baseURL,omitempty"` } type BackupSpec struct { + // +kubebuilder:validation:Pattern=`^https://[a-zA-Z0-9.-]+(:[0-9]{1,5})?(/.*)?$` LocalEndpoint string `json:"localEndpoint,omitempty"` // +optional SnapshotBackups *bool `json:"snapshotBackups,omitempty"` diff --git a/config/crd/bases/storage.simplyblock.io_storageclusters.yaml b/config/crd/bases/storage.simplyblock.io_storageclusters.yaml index c161548c..321d639e 100644 --- a/config/crd/bases/storage.simplyblock.io_storageclusters.yaml +++ b/config/crd/bases/storage.simplyblock.io_storageclusters.yaml @@ -80,6 +80,7 @@ spec: - name type: object localEndpoint: + pattern: ^https://[a-zA-Z0-9.-]+(:[0-9]{1,5})?(/.*)?$ type: string localTesting: type: boolean @@ -145,6 +146,7 @@ spec: properties: baseURL: description: BaseURL is the HashiCorp Vault endpoint (e.g. https://vault.example.com:8200). + pattern: ^https://[a-zA-Z0-9.-]+(:[0-9]{1,5})?(/.*)?$ type: string type: object inflightIOThreshold: diff --git a/dist/install.yaml b/dist/install.yaml index 17ecfd0f..c7db033a 100644 --- a/dist/install.yaml +++ b/dist/install.yaml @@ -1294,6 +1294,7 @@ spec: - name type: object localEndpoint: + pattern: ^https://[a-zA-Z0-9.-]+(:[0-9]{1,5})?(/.*)?$ type: string localTesting: type: boolean @@ -1359,6 +1360,7 @@ spec: properties: baseURL: description: BaseURL is the HashiCorp Vault endpoint (e.g. https://vault.example.com:8200). + pattern: ^https://[a-zA-Z0-9.-]+(:[0-9]{1,5})?(/.*)?$ type: string type: object inflightIOThreshold: diff --git a/internal/controller/simplyblockstoragecluster_backup_test.go b/internal/controller/simplyblockstoragecluster_backup_test.go index 7ffc83d8..61a13c44 100644 --- a/internal/controller/simplyblockstoragecluster_backup_test.go +++ b/internal/controller/simplyblockstoragecluster_backup_test.go @@ -33,7 +33,7 @@ func TestBuildBackupConfig(t *testing.T) { }, Spec: simplyblockv1alpha1.StorageClusterSpec{ Backup: &simplyblockv1alpha1.BackupSpec{ - LocalEndpoint: "http://10.10.11.10:9000", + LocalEndpoint: "https://203.0.113.10:9000", SnapshotBackups: &snapshotBackups, WithCompression: &withCompression, SecondaryTarget: &secondaryTarget, @@ -75,7 +75,7 @@ func TestBuildBackupConfig(t *testing.T) { if backupConfig.SecretAccessKey != "password" { t.Fatalf("unexpected secret_access_key: %#v", backupConfig.SecretAccessKey) } - if backupConfig.LocalEndpoint != "http://10.10.11.10:9000" { + if backupConfig.LocalEndpoint != "https://203.0.113.10:9000" { t.Fatalf("unexpected local_endpoint: %#v", backupConfig.LocalEndpoint) } if backupConfig.SnapshotBackups == nil || *backupConfig.SnapshotBackups != true { diff --git a/internal/controller/simplyblockstoragecluster_controller.go b/internal/controller/simplyblockstoragecluster_controller.go index 058bee32..fbf25e15 100644 --- a/internal/controller/simplyblockstoragecluster_controller.go +++ b/internal/controller/simplyblockstoragecluster_controller.go @@ -53,6 +53,10 @@ const ( // (missing, unreadable, or lacking required keys). eventReasonBackupCredentialsError = "BackupCredentialsError" + // eventReasonInvalidConfig is emitted when a user-supplied field in the CR + // fails validation (e.g. a non-HTTPS or private-IP URL). + eventReasonInvalidConfig = "InvalidConfig" + // eventReasonClusterCreationFailed is emitted when the cluster creation API // call returns a non-2xx status. The event message includes the HTTP status // code and the full response body so the root cause is visible without @@ -204,6 +208,13 @@ func (r *StorageClusterReconciler) reconcileCreate( return ctrl.Result{RequeueAfter: 20 * time.Second}, nil } + vaultConfig, err := buildHashicorpVaultConfig(clusterCR.Spec.HashicorpVaultSettings) + if err != nil { + log.Error(err, "Invalid HashiCorp Vault configuration") + r.Recorder.Eventf(clusterCR, corev1.EventTypeWarning, eventReasonInvalidConfig, "Invalid HashiCorp Vault configuration: %v", err) + return ctrl.Result{}, err + } + params := utils.ClusterAddParams{ Name: clusterCR.Name, BlkSize: utils.IntPtrOrDefault(clusterCR.Spec.BlockSize, 512), @@ -236,7 +247,7 @@ func (r *StorageClusterReconciler) reconcileCreate( RpcBasePort: utils.IntPtrOrDefault(clusterCR.Spec.RpcBasePort, 8080), SnodeApiPort: utils.IntPtrOrDefault(clusterCR.Spec.SnodeApiPort, 50001), BackupConfig: backupConfig, - HashicorpVaultSettings: buildHashicorpVaultConfig(clusterCR.Spec.HashicorpVaultSettings), + HashicorpVaultSettings: vaultConfig, EnableFailureDomain: utils.BoolPtrOrFalse(clusterCR.Spec.EnableFailureDomains), } @@ -384,6 +395,12 @@ func (r *StorageClusterReconciler) buildBackupConfig( return nil, fmt.Errorf("secret %q missing key %q", secretName, "secret_access_key") } + if ep := clusterCR.Spec.Backup.LocalEndpoint; ep != "" { + if err := utils.ValidateExternalURL(ep); err != nil { + return nil, fmt.Errorf("backup.localEndpoint: %w", err) + } + } + return &utils.BackupConfig{ AccessKeyID: string(accessKeyID), SecretAccessKey: string(secretAccessKey), @@ -395,11 +412,14 @@ func (r *StorageClusterReconciler) buildBackupConfig( }, nil } -func buildHashicorpVaultConfig(s *simplyblockv1alpha1.HashicorpVaultSettings) *utils.HashicorpVaultConfig { +func buildHashicorpVaultConfig(s *simplyblockv1alpha1.HashicorpVaultSettings) (*utils.HashicorpVaultConfig, error) { if s == nil || s.BaseURL == "" { - return nil + return nil, nil + } + if err := utils.ValidateExternalURL(s.BaseURL); err != nil { + return nil, fmt.Errorf("hashicorpVault.baseURL: %w", err) } - return &utils.HashicorpVaultConfig{BaseURL: s.BaseURL} + return &utils.HashicorpVaultConfig{BaseURL: s.BaseURL}, nil } // SetupWithManager sets up the controller with the Manager. diff --git a/internal/utils/helpers.go b/internal/utils/helpers.go index 2d040c9e..ef1543c1 100644 --- a/internal/utils/helpers.go +++ b/internal/utils/helpers.go @@ -4,11 +4,81 @@ import ( "errors" "fmt" "math" + "net" + "net/url" "regexp" "strconv" "strings" ) +// blockedIPNets contains IP ranges that must never be targeted by user-supplied URLs +// (SSRF protection: RFC-1918, loopback, link-local, cloud IMDS, IPv6 equivalents). +var blockedIPNets = func() []*net.IPNet { + cidrs := []string{ + "10.0.0.0/8", + "172.16.0.0/12", + "192.168.0.0/16", + "127.0.0.0/8", + "169.254.0.0/16", + "0.0.0.0/8", + "::1/128", + "fe80::/10", + "fc00::/7", + } + nets := make([]*net.IPNet, 0, len(cidrs)) + for _, cidr := range cidrs { + _, n, _ := net.ParseCIDR(cidr) + nets = append(nets, n) + } + return nets +}() + +func isBlockedIP(ip net.IP) bool { + for _, n := range blockedIPNets { + if n.Contains(ip) { + return true + } + } + return false +} + +// ValidateExternalURL rejects URLs that are unsafe to forward to the backend: +// - scheme must be https +// - host must not be a blocked IP literal (RFC-1918, loopback, link-local) +// - hostname must resolve and all resolved IPs must not be blocked +func ValidateExternalURL(rawURL string) error { + if rawURL == "" { + return nil + } + u, err := url.Parse(rawURL) + if err != nil { + return fmt.Errorf("malformed URL: %w", err) + } + if u.Scheme != "https" { + return fmt.Errorf("URL scheme must be https, got %q", u.Scheme) + } + host := u.Hostname() + if host == "" { + return fmt.Errorf("URL must contain a hostname") + } + if ip := net.ParseIP(host); ip != nil { + if isBlockedIP(ip) { + return fmt.Errorf("URL targets a restricted IP address (%s)", host) + } + return nil + } + addrs, err := net.LookupHost(host) + if err != nil { + return fmt.Errorf("cannot resolve host %q: %w", host, err) + } + for _, addr := range addrs { + if ip := net.ParseIP(addr); ip != nil && isBlockedIP(ip) { + return fmt.Errorf("URL resolves to a restricted IP address (%s)", addr) + } + } + return nil +} + var exponentMultipliers = []string{"", "K", "M", "G", "T", "P", "E", "Z"} var uuidRegex = regexp.MustCompile(`^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-5][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}$`) diff --git a/internal/utils/helpers_test.go b/internal/utils/helpers_test.go index f7eda93c..ae85bfe5 100644 --- a/internal/utils/helpers_test.go +++ b/internal/utils/helpers_test.go @@ -1,6 +1,48 @@ package utils -import "testing" +import ( + "strings" + "testing" +) + +func TestValidateExternalURL(t *testing.T) { + tests := []struct { + name string + url string + wantErr string // substring; empty means no error expected + }{ + {name: "empty is ok", url: ""}, + {name: "valid https public IP", url: "https://8.8.8.8:8200"}, + {name: "valid https public IP with path", url: "https://1.1.1.1/bucket"}, + {name: "http rejected", url: "http://vault.example.com", wantErr: "scheme must be https"}, + {name: "no scheme rejected", url: "vault.example.com", wantErr: "scheme must be https"}, + {name: "RFC-1918 10.x", url: "https://10.0.0.1", wantErr: "restricted IP"}, + {name: "RFC-1918 172.16.x", url: "https://172.16.5.1", wantErr: "restricted IP"}, + {name: "RFC-1918 192.168.x", url: "https://192.168.1.1", wantErr: "restricted IP"}, + {name: "loopback", url: "https://127.0.0.1", wantErr: "restricted IP"}, + {name: "link-local IMDS", url: "https://169.254.169.254", wantErr: "restricted IP"}, + {name: "IPv6 loopback", url: "https://[::1]", wantErr: "restricted IP"}, + {name: "IPv6 link-local", url: "https://[fe80::1]", wantErr: "restricted IP"}, + {name: "IPv6 ULA", url: "https://[fd00::1]", wantErr: "restricted IP"}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + err := ValidateExternalURL(tc.url) + if tc.wantErr == "" { + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + return + } + if err == nil { + t.Fatalf("expected error containing %q, got nil", tc.wantErr) + } + if !strings.Contains(err.Error(), tc.wantErr) { + t.Fatalf("expected error %q to contain %q", err.Error(), tc.wantErr) + } + }) + } +} func TestIntAndBoolHelpers(t *testing.T) { var i int32 = 7