Skip to content
Open
37 changes: 25 additions & 12 deletions operator/internal/handlers/internal/storage/secrets.go
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ var (
errS3EndpointUnsupportedScheme = errors.New("scheme of S3 endpoint URL is unsupported")
errS3EndpointAWSInvalid = errors.New("endpoint for AWS S3 must include correct region")
errS3ForcePathStyleInvalid = errors.New(`forcepathstyle must be "true" or "false"`)
errS3VPCBucketName = errors.New("bucket name must not be included in VPC endpoint URL")

errGCPParseCredentialsFile = errors.New("gcp storage secret cannot be parsed from JSON content")
errGCPWrongCredentialSourceFile = errors.New("credential source in secret needs to point to token file")
Expand Down Expand Up @@ -482,7 +483,7 @@ func extractS3ConfigSecret(s *corev1.Secret, credentialMode lokiv1.CredentialMod
}
}

func validateS3Endpoint(endpoint string, region string) error {
func validateS3Endpoint(endpoint, region string) error {
if len(endpoint) == 0 {
return fmt.Errorf("%w: %s", errSecretMissingField, storage.KeyAWSEndpoint)
}
Expand All @@ -491,27 +492,39 @@ func validateS3Endpoint(endpoint string, region string) error {
if err != nil {
return fmt.Errorf("%w: %w", errS3EndpointUnparseable, err)
}

if parsedURL.Scheme == "" {
// Assume "just a hostname" when scheme is empty and produce a clearer error message
return errS3EndpointNoURL
}

if parsedURL.Scheme != "http" && parsedURL.Scheme != "https" {
return fmt.Errorf("%w: %s", errS3EndpointUnsupportedScheme, parsedURL.Scheme)
}

if strings.HasSuffix(endpoint, awsEndpointSuffix) {
if len(region) == 0 {
return fmt.Errorf("%w: %s", errSecretMissingField, storage.KeyAWSRegion)
}
// Non-AWS S3 compatible endpoints (e.g., MinIO) - no further validation needed
if !strings.HasSuffix(endpoint, awsEndpointSuffix) {
return nil
}

validEndpoint := fmt.Sprintf("https://s3.%s%s", region, awsEndpointSuffix)
if endpoint != validEndpoint {
return fmt.Errorf("%w: %s", errS3EndpointAWSInvalid, validEndpoint)
// AWS endpoint validation
if len(region) == 0 {
return fmt.Errorf("%w: %s", errSecretMissingField, storage.KeyAWSRegion)
}

// Check standard AWS S3 endpoint
validEndpoint := fmt.Sprintf("https://s3.%s%s", region, awsEndpointSuffix)
if endpoint == validEndpoint {
return nil
}

// Check VPC endpoint format
host := parsedURL.Hostname()
if strings.Contains(host, ".vpce.amazonaws.com") && strings.Contains(host, region) {
if !strings.HasPrefix(host, "vpce-") {
return errS3VPCBucketName
}
return nil
}
return nil

return fmt.Errorf("%w: %s", errS3EndpointAWSInvalid, validEndpoint)
}

func extractS3SSEConfig(d map[string][]byte) (storage.S3SSEConfig, error) {
Expand Down
33 changes: 33 additions & 0 deletions operator/internal/handlers/internal/storage/secrets_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -719,6 +719,39 @@ func TestS3Extract_ForcePathStyle(t *testing.T) {
ForcePathStyle: true,
},
},
{
desc: "aws s3 vpc endpoint with bucket name should fail",
secret: &corev1.Secret{
ObjectMeta: metav1.ObjectMeta{Name: "test"},
Data: map[string][]byte{
"endpoint": []byte("https://bucket.vpce-1234567-us-east-1c.s3.us-east-1.vpce.amazonaws.com"),
"region": []byte("us-east-1"),
"bucketnames": []byte("this,that"),
"access_key_id": []byte("id"),
"access_key_secret": []byte("secret"),
},
},
wantError: "bucket name must not be included in VPC endpoint URL",
},
{
desc: "aws s3 vpc endpoint without bucket prefix",
secret: &corev1.Secret{
ObjectMeta: metav1.ObjectMeta{Name: "test"},
Data: map[string][]byte{
"endpoint": []byte("https://vpce-1234567-us-east-1c.s3.us-east-1.vpce.amazonaws.com"),
"region": []byte("us-east-1"),
"bucketnames": []byte("this,that"),
"access_key_id": []byte("id"),
"access_key_secret": []byte("secret"),
},
},
wantOptions: &storage.S3StorageConfig{
Endpoint: "https://vpce-1234567-us-east-1c.s3.us-east-1.vpce.amazonaws.com",
Region: "us-east-1",
Buckets: "this,that",
ForcePathStyle: false,
},
},
{
desc: "invalid forcepathstyle value",
secret: &corev1.Secret{
Expand Down