diff --git a/build/Dockerfile.nginxplus b/build/Dockerfile.nginxplus index bf68260ae1..c2db62db33 100644 --- a/build/Dockerfile.nginxplus +++ b/build/Dockerfile.nginxplus @@ -13,7 +13,7 @@ FROM alpine:${ALPINE_VERSION} ARG NGINX_PLUS_VERSION=R34 # renovate: datasource=github-tags depName=nginx/agent ARG NGINX_AGENT_VERSION=v3.0.2 -ARG APP_PROTECT_VERSION=34.5.342 +ARG APP_PROTECT_VERSION=34.5.442 ARG INCLUDE_NAP_WAF=false ARG NJS_DIR ARG NGINX_CONF_DIR diff --git a/internal/controller/manager.go b/internal/controller/manager.go index f7bc0a68e5..53faedfe4d 100644 --- a/internal/controller/manager.go +++ b/internal/controller/manager.go @@ -48,6 +48,7 @@ import ( "github.com/nginx/nginx-gateway-fabric/internal/controller/nginx/config/policies/clientsettings" "github.com/nginx/nginx-gateway-fabric/internal/controller/nginx/config/policies/observability" "github.com/nginx/nginx-gateway-fabric/internal/controller/nginx/config/policies/upstreamsettings" + "github.com/nginx/nginx-gateway-fabric/internal/controller/nginx/config/policies/waf" ngxvalidation "github.com/nginx/nginx-gateway-fabric/internal/controller/nginx/config/validation" "github.com/nginx/nginx-gateway-fabric/internal/controller/provisioner" "github.com/nginx/nginx-gateway-fabric/internal/controller/state" @@ -326,6 +327,10 @@ func createPolicyManager( GVK: mustExtractGVK(&ngfAPIv1alpha1.UpstreamSettingsPolicy{}), Validator: upstreamsettings.NewValidator(validator), }, + { + GVK: mustExtractGVK(&ngfAPIv1alpha1.WAFPolicy{}), + Validator: waf.NewValidator(validator), + }, } return policies.NewManager(mustExtractGVK, cfgs...) @@ -507,6 +512,12 @@ func registerControllers( controller.WithK8sPredicate(k8spredicate.GenerationChangedPredicate{}), }, }, + { + objectType: &ngfAPIv1alpha1.WAFPolicy{}, + options: []controller.Option{ + controller.WithK8sPredicate(k8spredicate.GenerationChangedPredicate{}), + }, + }, } if cfg.ExperimentalFeatures { @@ -745,6 +756,7 @@ func prepareFirstEventBatchPreparerArgs(cfg config.Config) ([]client.Object, []c &ngfAPIv1alpha1.ClientSettingsPolicyList{}, &ngfAPIv1alpha2.ObservabilityPolicyList{}, &ngfAPIv1alpha1.UpstreamSettingsPolicyList{}, + &ngfAPIv1alpha1.WAFPolicyList{}, partialObjectMetadataList, } diff --git a/internal/controller/manager_test.go b/internal/controller/manager_test.go index 75aecfe04a..36d29b4c7b 100644 --- a/internal/controller/manager_test.go +++ b/internal/controller/manager_test.go @@ -68,6 +68,7 @@ func TestPrepareFirstEventBatchPreparerArgs(t *testing.T) { &ngfAPIv1alpha1.ClientSettingsPolicyList{}, &ngfAPIv1alpha2.ObservabilityPolicyList{}, &ngfAPIv1alpha1.UpstreamSettingsPolicyList{}, + &ngfAPIv1alpha1.WAFPolicyList{}, }, }, { @@ -97,6 +98,7 @@ func TestPrepareFirstEventBatchPreparerArgs(t *testing.T) { &ngfAPIv1alpha1.ClientSettingsPolicyList{}, &ngfAPIv1alpha2.ObservabilityPolicyList{}, &ngfAPIv1alpha1.UpstreamSettingsPolicyList{}, + &ngfAPIv1alpha1.WAFPolicyList{}, }, }, { @@ -124,6 +126,7 @@ func TestPrepareFirstEventBatchPreparerArgs(t *testing.T) { &ngfAPIv1alpha2.ObservabilityPolicyList{}, &ngfAPIv1alpha1.SnippetsFilterList{}, &ngfAPIv1alpha1.UpstreamSettingsPolicyList{}, + &ngfAPIv1alpha1.WAFPolicyList{}, }, }, { @@ -154,6 +157,7 @@ func TestPrepareFirstEventBatchPreparerArgs(t *testing.T) { &ngfAPIv1alpha2.ObservabilityPolicyList{}, &ngfAPIv1alpha1.SnippetsFilterList{}, &ngfAPIv1alpha1.UpstreamSettingsPolicyList{}, + &ngfAPIv1alpha1.WAFPolicyList{}, }, }, } diff --git a/internal/controller/nginx/config/generator.go b/internal/controller/nginx/config/generator.go index 992db73067..cfb1159f0c 100644 --- a/internal/controller/nginx/config/generator.go +++ b/internal/controller/nginx/config/generator.go @@ -16,6 +16,7 @@ import ( "github.com/nginx/nginx-gateway-fabric/internal/controller/nginx/config/policies/clientsettings" "github.com/nginx/nginx-gateway-fabric/internal/controller/nginx/config/policies/observability" "github.com/nginx/nginx-gateway-fabric/internal/controller/nginx/config/policies/upstreamsettings" + "github.com/nginx/nginx-gateway-fabric/internal/controller/nginx/config/policies/waf" "github.com/nginx/nginx-gateway-fabric/internal/controller/state/dataplane" "github.com/nginx/nginx-gateway-fabric/internal/framework/file" ) @@ -44,6 +45,9 @@ const ( // includesFolder is the folder where are all include files are stored. includesFolder = configFolder + "/includes" + // appProtectBundleFolder is the folder where the NGINX App Protect WAF bundles are stored. + appProtectBundleFolder = "/etc/app_protect/bundles" + // httpConfigFile is the path to the configuration file with HTTP configuration. httpConfigFile = httpFolder + "/http.conf" @@ -119,10 +123,15 @@ func (g GeneratorImpl) Generate(conf dataplane.Configuration) []agent.File { policyGenerator := policies.NewCompositeGenerator( clientsettings.NewGenerator(), observability.NewGenerator(conf.Telemetry), + waf.NewGenerator(), ) files = append(files, g.executeConfigTemplates(conf, policyGenerator)...) + for id, bundle := range conf.WAF.WAFBundles { + files = append(files, generateWAFBundle(id, bundle)) + } + for id, bundle := range conf.CertBundles { files = append(files, generateCertBundle(id, bundle)) } @@ -245,3 +254,19 @@ func generateCertBundle(id dataplane.CertBundleID, cert []byte) agent.File { func generateCertBundleFileName(id dataplane.CertBundleID) string { return filepath.Join(secretsFolder, string(id)+".crt") } + +func generateWAFBundle(id dataplane.WAFBundleID, bundle []byte) agent.File { + return agent.File{ + Meta: &pb.FileMeta{ + Name: GenerateWAFBundleFileName(id), + Hash: filesHelper.GenerateHash(bundle), + Permissions: file.RegularFileMode, + Size: int64(len(bundle)), + }, + Contents: bundle, + } +} + +func GenerateWAFBundleFileName(id dataplane.WAFBundleID) string { + return filepath.Join(appProtectBundleFolder, string(id)+".tgz") +} diff --git a/internal/controller/nginx/config/policies/policy.go b/internal/controller/nginx/config/policies/policy.go index 93d6054155..640216fe04 100644 --- a/internal/controller/nginx/config/policies/policy.go +++ b/internal/controller/nginx/config/policies/policy.go @@ -26,6 +26,8 @@ type Policy interface { type GlobalSettings struct { // TelemetryEnabled is whether telemetry is enabled in the NginxProxy resource. TelemetryEnabled bool + // WAFEnabled is whether WAF is enabled in the NginxProxy resource. + WAFEnabled bool } // ValidateTargetRef validates a policy's targetRef for the proper group and kind. diff --git a/internal/controller/nginx/config/policies/waf/generator.go b/internal/controller/nginx/config/policies/waf/generator.go new file mode 100644 index 0000000000..fcdaa68968 --- /dev/null +++ b/internal/controller/nginx/config/policies/waf/generator.go @@ -0,0 +1,121 @@ +package waf + +import ( + "fmt" + "text/template" + + ngfAPI "github.com/nginx/nginx-gateway-fabric/apis/v1alpha1" + "github.com/nginx/nginx-gateway-fabric/internal/controller/nginx/config/http" + "github.com/nginx/nginx-gateway-fabric/internal/controller/nginx/config/policies" + "github.com/nginx/nginx-gateway-fabric/internal/framework/helpers" +) + +var tmpl = template.Must(template.New("waf policy").Parse(wafTemplate)) + +const wafTemplate = ` +{{- if .BundlePath }} +app_protect_enable on; +app_protect_policy_file "{{ .BundlePath }}"; +{{- end }} +{{- if .SecurityLogs }} +app_protect_security_log_enable on; +{{- range .SecurityLogs }} +{{- if .LogProfile }} +app_protect_security_log "{{ .LogProfile }}" {{ .Destination }}; +{{- else if .LogProfileBundlePath }} +app_protect_security_log "{{ .LogProfileBundlePath }}" {{ .Destination }}; +{{- end }} +{{- end }} +{{- end }} +` + +// Generator generates nginx configuration based on a WAF policy. +type Generator struct { + policies.UnimplementedGenerator +} + +// NewGenerator returns a new instance of Generator. +func NewGenerator() *Generator { + return &Generator{} +} + +// GenerateForServer generates policy configuration for the server block. +func (g Generator) GenerateForServer(pols []policies.Policy, _ http.Server) policies.GenerateResultFiles { + return generate(pols) +} + +// GenerateForLocation generates policy configuration for a normal location block. +func (g Generator) GenerateForLocation(pols []policies.Policy, _ http.Location) policies.GenerateResultFiles { + return generate(pols) +} + +func generate(pols []policies.Policy) policies.GenerateResultFiles { + files := make(policies.GenerateResultFiles, 0, len(pols)) + + for _, pol := range pols { + wp, ok := pol.(*ngfAPI.WAFPolicy) + if !ok { + continue + } + + fields := map[string]any{} + + if wp.Spec.PolicySource != nil && wp.Spec.PolicySource.FileLocation != "" { + fileLocation := wp.Spec.PolicySource.FileLocation + bundleName := helpers.ToSafeFileName(fileLocation) + bundlePath := fmt.Sprintf("%s/%s.tgz", "/etc/app_protect/bundles", bundleName) + fields["BundlePath"] = bundlePath + } + + if len(wp.Spec.SecurityLogs) > 0 { + securityLogs := make([]map[string]string, 0, len(wp.Spec.SecurityLogs)) + + for _, secLog := range wp.Spec.SecurityLogs { + logEntry := map[string]string{} + + if secLog.LogProfile != nil { + logEntry["LogProfile"] = string(*secLog.LogProfile) + } + + if secLog.LogProfileBundle != nil && secLog.LogProfileBundle.FileLocation != "" { + bundleName := helpers.ToSafeFileName(secLog.LogProfileBundle.FileLocation) + bundlePath := fmt.Sprintf("%s/%s.tgz", "/etc/app_protect/bundles", bundleName) + logEntry["LogProfileBundlePath"] = bundlePath + } + + destination := formatSecurityLogDestination(secLog.Destination) + logEntry["Destination"] = destination + + securityLogs = append(securityLogs, logEntry) + } + + fields["SecurityLogs"] = securityLogs + } + + files = append(files, policies.File{ + Name: fmt.Sprintf("WafPolicy_%s_%s.conf", wp.Namespace, wp.Name), + Content: helpers.MustExecuteTemplate(tmpl, fields), + }) + } + + return files +} + +func formatSecurityLogDestination(dest ngfAPI.SecurityLogDestination) string { + switch dest.Type { + case ngfAPI.SecurityLogDestinationTypeStderr: + return "stderr" + case ngfAPI.SecurityLogDestinationTypeFile: + if dest.File != nil { + return dest.File.Path + } + return "stderr" + case ngfAPI.SecurityLogDestinationTypeSyslog: + if dest.Syslog != nil { + return fmt.Sprintf("syslog:server=%s", dest.Syslog.Server) + } + return "stderr" + default: + return "stderr" + } +} diff --git a/internal/controller/nginx/config/policies/waf/generator_test.go b/internal/controller/nginx/config/policies/waf/generator_test.go new file mode 100644 index 0000000000..a3600ac69d --- /dev/null +++ b/internal/controller/nginx/config/policies/waf/generator_test.go @@ -0,0 +1,217 @@ +package waf_test + +import ( + "testing" + + . "github.com/onsi/gomega" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + + ngfAPIv1alpha1 "github.com/nginx/nginx-gateway-fabric/apis/v1alpha1" + ngfAPIv1alpha2 "github.com/nginx/nginx-gateway-fabric/apis/v1alpha2" + "github.com/nginx/nginx-gateway-fabric/internal/controller/nginx/config/http" + "github.com/nginx/nginx-gateway-fabric/internal/controller/nginx/config/policies" + "github.com/nginx/nginx-gateway-fabric/internal/controller/nginx/config/policies/waf" +) + +func TestGenerate(t *testing.T) { + t.Parallel() + tests := []struct { + name string + policy policies.Policy + expStrings []string + }{ + { + name: "basic case", + policy: &ngfAPIv1alpha1.WAFPolicy{ + ObjectMeta: metav1.ObjectMeta{ + Name: "my-name", + Namespace: "my-namespace", + }, + Spec: ngfAPIv1alpha1.WAFPolicySpec{ + PolicySource: &ngfAPIv1alpha1.WAFPolicySource{ + FileLocation: "http://example.com/policy.tgz", + }, + }, + }, + expStrings: []string{ + "app_protect_enable on;", + "app_protect_policy_file \"/etc/app_protect/bundles/", + }, + }, + { + name: "security log with built-in profile and stderr destination", + policy: &ngfAPIv1alpha1.WAFPolicy{ + ObjectMeta: metav1.ObjectMeta{ + Name: "waf-with-log", + Namespace: "test-ns", + }, + Spec: ngfAPIv1alpha1.WAFPolicySpec{ + PolicySource: &ngfAPIv1alpha1.WAFPolicySource{ + FileLocation: "http://example.com/policy.tgz", + }, + SecurityLogs: []ngfAPIv1alpha1.WAFSecurityLog{ + { + LogProfile: func() *ngfAPIv1alpha1.LogProfile { + lp := ngfAPIv1alpha1.LogProfileDefault + return &lp + }(), + Destination: ngfAPIv1alpha1.SecurityLogDestination{ + Type: ngfAPIv1alpha1.SecurityLogDestinationTypeStderr, + }, + }, + }, + }, + }, + expStrings: []string{ + "app_protect_enable on;", + "app_protect_policy_file \"/etc/app_protect/bundles/", + "app_protect_security_log_enable on;", + "app_protect_security_log \"log_default\" stderr;", + }, + }, + { + name: "security log with custom bundle and file destination", + policy: &ngfAPIv1alpha1.WAFPolicy{ + ObjectMeta: metav1.ObjectMeta{ + Name: "waf-custom-log", + Namespace: "test-ns", + }, + Spec: ngfAPIv1alpha1.WAFPolicySpec{ + SecurityLogs: []ngfAPIv1alpha1.WAFSecurityLog{ + { + LogProfileBundle: &ngfAPIv1alpha1.WAFPolicySource{ + FileLocation: "http://example.com/custom-log.tgz", + }, + Destination: ngfAPIv1alpha1.SecurityLogDestination{ + Type: ngfAPIv1alpha1.SecurityLogDestinationTypeFile, + File: &ngfAPIv1alpha1.SecurityLogFile{ + Path: "/var/log/nginx/security.log", + }, + }, + }, + }, + }, + }, + expStrings: []string{ + "app_protect_security_log_enable on;", + "app_protect_security_log \"/etc/app_protect/bundles/", + "/var/log/nginx/security.log;", + }, + }, + { + name: "security log with syslog destination", + policy: &ngfAPIv1alpha1.WAFPolicy{ + ObjectMeta: metav1.ObjectMeta{ + Name: "waf-syslog", + Namespace: "test-ns", + }, + Spec: ngfAPIv1alpha1.WAFPolicySpec{ + SecurityLogs: []ngfAPIv1alpha1.WAFSecurityLog{ + { + LogProfile: func() *ngfAPIv1alpha1.LogProfile { + lp := ngfAPIv1alpha1.LogProfileBlocked + return &lp + }(), + Destination: ngfAPIv1alpha1.SecurityLogDestination{ + Type: ngfAPIv1alpha1.SecurityLogDestinationTypeSyslog, + Syslog: &ngfAPIv1alpha1.SecurityLogSyslog{ + Server: "syslog.example.com:514", + }, + }, + }, + }, + }, + }, + expStrings: []string{ + "app_protect_security_log_enable on;", + "app_protect_security_log \"log_blocked\" syslog:server=syslog.example.com:514;", + }, + }, + { + name: "multiple security logs", + policy: &ngfAPIv1alpha1.WAFPolicy{ + ObjectMeta: metav1.ObjectMeta{ + Name: "waf-multi-log", + Namespace: "test-ns", + }, + Spec: ngfAPIv1alpha1.WAFPolicySpec{ + PolicySource: &ngfAPIv1alpha1.WAFPolicySource{ + FileLocation: "http://example.com/policy.tgz", + }, + SecurityLogs: []ngfAPIv1alpha1.WAFSecurityLog{ + { + LogProfile: func() *ngfAPIv1alpha1.LogProfile { + lp := ngfAPIv1alpha1.LogProfileAll + return &lp + }(), + Destination: ngfAPIv1alpha1.SecurityLogDestination{ + Type: ngfAPIv1alpha1.SecurityLogDestinationTypeStderr, + }, + }, + { + LogProfile: func() *ngfAPIv1alpha1.LogProfile { + lp := ngfAPIv1alpha1.LogProfileBlocked + return &lp + }(), + Destination: ngfAPIv1alpha1.SecurityLogDestination{ + Type: ngfAPIv1alpha1.SecurityLogDestinationTypeFile, + File: &ngfAPIv1alpha1.SecurityLogFile{ + Path: "/var/log/blocked.log", + }, + }, + }, + }, + }, + }, + expStrings: []string{ + "app_protect_enable on;", + "app_protect_policy_file \"/etc/app_protect/bundles/", + "app_protect_security_log_enable on;", + "app_protect_security_log \"log_all\" stderr;", + "app_protect_security_log \"log_blocked\" /var/log/blocked.log;", + }, + }, + } + + checkResults := func(t *testing.T, resFiles policies.GenerateResultFiles, expStrings []string) { + t.Helper() + g := NewWithT(t) + g.Expect(resFiles).To(HaveLen(1)) + + for _, str := range expStrings { + g.Expect(string(resFiles[0].Content)).To(ContainSubstring(str)) + } + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + t.Parallel() + generator := waf.NewGenerator() + + resFiles := generator.GenerateForServer([]policies.Policy{test.policy}, http.Server{}) + checkResults(t, resFiles, test.expStrings) + + resFiles = generator.GenerateForLocation([]policies.Policy{test.policy}, http.Location{}) + checkResults(t, resFiles, test.expStrings) + }) + } +} + +func TestGenerateNoPolicies(t *testing.T) { + t.Parallel() + g := NewWithT(t) + + generator := waf.NewGenerator() + + resFiles := generator.GenerateForServer([]policies.Policy{}, http.Server{}) + g.Expect(resFiles).To(BeEmpty()) + + resFiles = generator.GenerateForServer([]policies.Policy{&ngfAPIv1alpha2.ObservabilityPolicy{}}, http.Server{}) + g.Expect(resFiles).To(BeEmpty()) + + resFiles = generator.GenerateForLocation([]policies.Policy{}, http.Location{}) + g.Expect(resFiles).To(BeEmpty()) + + resFiles = generator.GenerateForLocation([]policies.Policy{&ngfAPIv1alpha2.ObservabilityPolicy{}}, http.Location{}) + g.Expect(resFiles).To(BeEmpty()) +} diff --git a/internal/controller/nginx/config/policies/waf/validator.go b/internal/controller/nginx/config/policies/waf/validator.go new file mode 100644 index 0000000000..7b25b674cd --- /dev/null +++ b/internal/controller/nginx/config/policies/waf/validator.go @@ -0,0 +1,134 @@ +package waf + +import ( + "errors" + "fmt" + "net/url" + + "k8s.io/apimachinery/pkg/util/validation/field" + gatewayv1 "sigs.k8s.io/gateway-api/apis/v1" + + ngfAPI "github.com/nginx/nginx-gateway-fabric/apis/v1alpha1" + "github.com/nginx/nginx-gateway-fabric/internal/controller/nginx/config/policies" + "github.com/nginx/nginx-gateway-fabric/internal/controller/state/conditions" + "github.com/nginx/nginx-gateway-fabric/internal/controller/state/validation" + "github.com/nginx/nginx-gateway-fabric/internal/framework/helpers" + "github.com/nginx/nginx-gateway-fabric/internal/framework/kinds" +) + +// Validator validates a WAFPolicy. +// Implements policies.Validator interface. +type Validator struct { + genericValidator validation.GenericValidator +} + +// NewValidator returns a new instance of Validator. +func NewValidator(genericValidator validation.GenericValidator) *Validator { + return &Validator{genericValidator: genericValidator} +} + +// Validate validates the spec of a WAFPolicy. +func (v *Validator) Validate(policy policies.Policy) []conditions.Condition { + wp := helpers.MustCastObject[*ngfAPI.WAFPolicy](policy) + + targetRefPath := field.NewPath("spec").Child("targetRef") + supportedKinds := []gatewayv1.Kind{kinds.Gateway, kinds.HTTPRoute, kinds.GRPCRoute} + supportedGroups := []gatewayv1.Group{gatewayv1.GroupName} + + if err := policies.ValidateTargetRef(wp.Spec.TargetRef, targetRefPath, supportedGroups, supportedKinds); err != nil { + return []conditions.Condition{conditions.NewPolicyInvalid(err.Error())} + } + + if err := v.validateSettings(wp.Spec); err != nil { + return []conditions.Condition{conditions.NewPolicyInvalid(err.Error())} + } + + return nil +} + +// ValidateGlobalSettings validates a WAFPolicy with respect to the NginxProxy global settings. +func (v *Validator) ValidateGlobalSettings( + _ policies.Policy, + globalSettings *policies.GlobalSettings, +) []conditions.Condition { + if globalSettings == nil { + return []conditions.Condition{ + conditions.NewPolicyNotAcceptedNginxProxyNotSet(conditions.PolicyMessageNginxProxyInvalid), + } + } + + // FIXME(ciarams87): Update to condition reason from conditions package when available. + if !globalSettings.WAFEnabled { + return []conditions.Condition{ + conditions.NewPolicyNotAcceptedNginxProxyNotSet("WAF is not enabled in NginxProxy"), + } + } + return nil +} + +// Conflicts returns false as we don't allow merging for WAFPolicies. +func (v Validator) Conflicts(polA, polB policies.Policy) bool { + _ = helpers.MustCastObject[*ngfAPI.WAFPolicy](polA) + _ = helpers.MustCastObject[*ngfAPI.WAFPolicy](polB) + return false +} + +func (v *Validator) validateSettings(spec ngfAPI.WAFPolicySpec) error { + var allErrs field.ErrorList + fieldPath := field.NewPath("spec") + + if spec.PolicySource != nil { + allErrs = append(allErrs, v.validatePolicySource(*spec.PolicySource, fieldPath.Child("policySource"))...) + } + + for i, sl := range spec.SecurityLogs { + logPath := fieldPath.Child("securityLogs").Index(i) + if sl.LogProfileBundle != nil { + allErrs = append(allErrs, v.validatePolicySource(*sl.LogProfileBundle, logPath.Child("logProfileBundle"))...) + } + } + + return allErrs.ToAggregate() +} + +func (v *Validator) validatePolicySource(source ngfAPI.WAFPolicySource, fieldPath *field.Path) field.ErrorList { + var allErrs field.ErrorList + + if err := v.validateFileLocation(source.FileLocation); err != nil { + allErrs = append(allErrs, field.Invalid(fieldPath.Child("fileLocation"), source.FileLocation, err.Error())) + } + + if source.Polling != nil { + if source.Polling.ChecksumLocation != nil { + if err := v.validateFileLocation(*source.Polling.ChecksumLocation); err != nil { + path := fieldPath.Child("polling").Child("checksumLocation") + allErrs = append(allErrs, field.Invalid(path, *source.Polling.ChecksumLocation, err.Error())) + } + } + } + + return allErrs +} + +// validateFileLocation validates that the file location is a valid URL. +// Supports HTTP and HTTPS URLs. +func (v *Validator) validateFileLocation(location string) error { + if location == "" { + return errors.New("cannot be empty") + } + + u, err := url.ParseRequestURI(location) + if err != nil { + return fmt.Errorf("invalid URL format: %w", err) + } + + if u.Scheme != "http" && u.Scheme != "https" { + return errors.New("scheme must be http or https") + } + + if u.Host == "" { + return errors.New("host cannot be empty") + } + + return nil +} diff --git a/internal/controller/nginx/config/policies/waf/validator_test.go b/internal/controller/nginx/config/policies/waf/validator_test.go new file mode 100644 index 0000000000..0bd6edad24 --- /dev/null +++ b/internal/controller/nginx/config/policies/waf/validator_test.go @@ -0,0 +1,368 @@ +package waf_test + +import ( + "testing" + + . "github.com/onsi/gomega" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + v1 "sigs.k8s.io/gateway-api/apis/v1" + "sigs.k8s.io/gateway-api/apis/v1alpha2" + + ngfAPI "github.com/nginx/nginx-gateway-fabric/apis/v1alpha1" + "github.com/nginx/nginx-gateway-fabric/internal/controller/nginx/config/policies" + "github.com/nginx/nginx-gateway-fabric/internal/controller/nginx/config/policies/waf" + "github.com/nginx/nginx-gateway-fabric/internal/controller/nginx/config/validation" + "github.com/nginx/nginx-gateway-fabric/internal/controller/state/conditions" + "github.com/nginx/nginx-gateway-fabric/internal/framework/helpers" + "github.com/nginx/nginx-gateway-fabric/internal/framework/kinds" +) + +func createValidPolicy() *ngfAPI.WAFPolicy { + return &ngfAPI.WAFPolicy{ + ObjectMeta: metav1.ObjectMeta{ + Namespace: "default", + }, + Spec: ngfAPI.WAFPolicySpec{ + TargetRef: v1alpha2.LocalPolicyTargetReference{ + Group: v1.GroupName, + Kind: kinds.Gateway, + Name: "gateway", + }, + PolicySource: &ngfAPI.WAFPolicySource{ + FileLocation: "https://example.com/policy.tgz", + Timeout: helpers.GetPointer[ngfAPI.Duration]("30s"), + }, + }, + } +} + +func TestValidator_Validate(t *testing.T) { + t.Parallel() + tests := []struct { + name string + policy *ngfAPI.WAFPolicy + expConditions []conditions.Condition + }{ + // Target Reference Validation Tests + { + name: "invalid target ref; unsupported group", + policy: &ngfAPI.WAFPolicy{ + ObjectMeta: metav1.ObjectMeta{Namespace: "default"}, + Spec: ngfAPI.WAFPolicySpec{ + TargetRef: v1alpha2.LocalPolicyTargetReference{ + Group: "Unsupported", + Kind: kinds.Gateway, + Name: "gateway", + }, + }, + }, + expConditions: []conditions.Condition{ + conditions.NewPolicyInvalid("spec.targetRef.group: Unsupported value: \"Unsupported\": " + + "supported values: \"gateway.networking.k8s.io\""), + }, + }, + { + name: "invalid target ref; unsupported kind", + policy: &ngfAPI.WAFPolicy{ + ObjectMeta: metav1.ObjectMeta{Namespace: "default"}, + Spec: ngfAPI.WAFPolicySpec{ + TargetRef: v1alpha2.LocalPolicyTargetReference{ + Group: v1.GroupName, + Kind: "Unsupported", + Name: "gateway", + }, + }, + }, + expConditions: []conditions.Condition{ + conditions.NewPolicyInvalid("spec.targetRef.kind: Unsupported value: \"Unsupported\": " + + "supported values: \"Gateway\", \"HTTPRoute\", \"GRPCRoute\""), + }, + }, + { + name: "invalid policy source file location - empty", + policy: &ngfAPI.WAFPolicy{ + ObjectMeta: metav1.ObjectMeta{Namespace: "default"}, + Spec: ngfAPI.WAFPolicySpec{ + TargetRef: v1alpha2.LocalPolicyTargetReference{ + Group: v1.GroupName, + Kind: kinds.Gateway, + Name: "gateway", + }, + PolicySource: &ngfAPI.WAFPolicySource{ + FileLocation: "", + }, + }, + }, + expConditions: []conditions.Condition{ + conditions.NewPolicyInvalid("spec.policySource.fileLocation: Invalid value: \"\": " + + "cannot be empty"), + }, + }, + { + name: "invalid policy source file location - malformed HTTP URL", + policy: &ngfAPI.WAFPolicy{ + ObjectMeta: metav1.ObjectMeta{Namespace: "default"}, + Spec: ngfAPI.WAFPolicySpec{ + TargetRef: v1alpha2.LocalPolicyTargetReference{ + Group: v1.GroupName, + Kind: kinds.Gateway, + Name: "gateway", + }, + PolicySource: &ngfAPI.WAFPolicySource{ + FileLocation: "https://", + }, + }, + }, + expConditions: []conditions.Condition{ + conditions.NewPolicyInvalid("spec.policySource.fileLocation: Invalid value: " + + "\"https://\": host cannot be empty"), + }, + }, + { + name: "invalid security log profile bundle file location - empty", + policy: &ngfAPI.WAFPolicy{ + ObjectMeta: metav1.ObjectMeta{Namespace: "default"}, + Spec: ngfAPI.WAFPolicySpec{ + TargetRef: v1alpha2.LocalPolicyTargetReference{ + Group: v1.GroupName, + Kind: kinds.Gateway, + Name: "gateway", + }, + SecurityLogs: []ngfAPI.WAFSecurityLog{ + { + LogProfileBundle: &ngfAPI.WAFPolicySource{ + FileLocation: "", + }, + Destination: ngfAPI.SecurityLogDestination{ + Type: ngfAPI.SecurityLogDestinationTypeStderr, + }, + }, + }, + }, + }, + expConditions: []conditions.Condition{ + conditions.NewPolicyInvalid("spec.securityLogs[0].logProfileBundle.fileLocation: Invalid value: " + + "\"\": cannot be empty"), + }, + }, + { + name: "valid security log profile bundle with checksum location", + policy: &ngfAPI.WAFPolicy{ + ObjectMeta: metav1.ObjectMeta{Namespace: "default"}, + Spec: ngfAPI.WAFPolicySpec{ + TargetRef: v1alpha2.LocalPolicyTargetReference{ + Group: v1.GroupName, + Kind: kinds.Gateway, + Name: "gateway", + }, + SecurityLogs: []ngfAPI.WAFSecurityLog{ + { + LogProfileBundle: &ngfAPI.WAFPolicySource{ + FileLocation: "https://example.com/profile.tgz", + Polling: &ngfAPI.WAFPolicyPolling{ + ChecksumLocation: helpers.GetPointer("https://my-files/profile.tgz.sha256"), + }, + }, + Destination: ngfAPI.SecurityLogDestination{ + Type: ngfAPI.SecurityLogDestinationTypeStderr, + }, + }, + }, + }, + }, + expConditions: nil, + }, + { + name: "valid basic policy", + policy: createValidPolicy(), + expConditions: nil, + }, + { + name: "valid with minimal config - no policy source", + policy: &ngfAPI.WAFPolicy{ + ObjectMeta: metav1.ObjectMeta{ + Namespace: "default", + }, + Spec: ngfAPI.WAFPolicySpec{ + TargetRef: v1alpha2.LocalPolicyTargetReference{ + Group: v1.GroupName, + Kind: kinds.HTTPRoute, + Name: "route", + }, + }, + }, + expConditions: nil, + }, + { + name: "valid HTTPRoute target", + policy: &ngfAPI.WAFPolicy{ + ObjectMeta: metav1.ObjectMeta{Namespace: "default"}, + Spec: ngfAPI.WAFPolicySpec{ + TargetRef: v1alpha2.LocalPolicyTargetReference{ + Group: v1.GroupName, + Kind: kinds.HTTPRoute, + Name: "route", + }, + PolicySource: &ngfAPI.WAFPolicySource{ + FileLocation: "https://my-files/route-policy.tgz", + }, + }, + }, + expConditions: nil, + }, + { + name: "valid GRPCRoute target", + policy: &ngfAPI.WAFPolicy{ + ObjectMeta: metav1.ObjectMeta{Namespace: "default"}, + Spec: ngfAPI.WAFPolicySpec{ + TargetRef: v1alpha2.LocalPolicyTargetReference{ + Group: v1.GroupName, + Kind: kinds.GRPCRoute, + Name: "grpc-route", + }, + PolicySource: &ngfAPI.WAFPolicySource{ + FileLocation: "https://example.com/grpc-policy.tgz", + }, + }, + }, + expConditions: nil, + }, + { + name: "valid with complete configuration", + policy: &ngfAPI.WAFPolicy{ + ObjectMeta: metav1.ObjectMeta{Namespace: "default"}, + Spec: ngfAPI.WAFPolicySpec{ + TargetRef: v1alpha2.LocalPolicyTargetReference{ + Group: v1.GroupName, + Kind: kinds.Gateway, + Name: "gateway", + }, + PolicySource: &ngfAPI.WAFPolicySource{ + FileLocation: "https://example.com/policy.tgz", + Polling: &ngfAPI.WAFPolicyPolling{ + Enabled: helpers.GetPointer(true), + Interval: helpers.GetPointer[ngfAPI.Duration]("5m"), + ChecksumLocation: helpers.GetPointer("https://my-files/policy.tgz.sha256"), + }, + }, + SecurityLogs: []ngfAPI.WAFSecurityLog{ + { + LogProfileBundle: &ngfAPI.WAFPolicySource{ + FileLocation: "https://example.com/profile.tgz", + }, + Destination: ngfAPI.SecurityLogDestination{ + Type: ngfAPI.SecurityLogDestinationTypeStderr, + }, + }, + }, + }, + }, + expConditions: nil, + }, + { + name: "invalid policy source polling checksum location", + policy: &ngfAPI.WAFPolicy{ + ObjectMeta: metav1.ObjectMeta{Namespace: "default"}, + Spec: ngfAPI.WAFPolicySpec{ + TargetRef: v1alpha2.LocalPolicyTargetReference{ + Group: v1.GroupName, + Kind: kinds.Gateway, + Name: "gateway", + }, + PolicySource: &ngfAPI.WAFPolicySource{ + FileLocation: "https://example.com/policy.tgz", + Polling: &ngfAPI.WAFPolicyPolling{ + ChecksumLocation: helpers.GetPointer("invalid-url"), + }, + }, + }, + }, + expConditions: []conditions.Condition{ + conditions.NewPolicyInvalid("spec.policySource.polling.checksumLocation: Invalid value: " + + "\"invalid-url\": invalid URL format: parse \"invalid-url\": invalid URI for request"), + }, + }, + } + + v := waf.NewValidator(validation.GenericValidator{}) + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + t.Parallel() + g := NewWithT(t) + + conds := v.Validate(test.policy) + g.Expect(conds).To(Equal(test.expConditions)) + }) + } +} + +func TestValidator_ValidateGlobalSettings(t *testing.T) { + t.Parallel() + + tests := []struct { + globalSettings *policies.GlobalSettings + expectedCondition *conditions.Condition + name string + }{ + { + name: "WAF enabled", + globalSettings: &policies.GlobalSettings{ + WAFEnabled: true, + }, + expectedCondition: nil, + }, + { + name: "WAF disabled", + globalSettings: &policies.GlobalSettings{ + WAFEnabled: false, + }, + expectedCondition: &conditions.Condition{ + Type: "Accepted", + Status: "False", + Reason: "NginxProxyConfigNotSet", + Message: "WAF is not enabled in NginxProxy", + }, + }, + { + name: "nil global settings", + globalSettings: nil, + expectedCondition: &conditions.Condition{ + Type: "Accepted", + Status: "False", + Reason: "NginxProxyConfigNotSet", + Message: "The NginxProxy configuration is either invalid or not attached to the GatewayClass", + }, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + t.Parallel() + g := NewWithT(t) + + v := waf.NewValidator(validation.GenericValidator{}) + result := v.ValidateGlobalSettings(createValidPolicy(), test.globalSettings) + + if test.expectedCondition == nil { + g.Expect(result).To(BeNil()) + } else { + g.Expect(result).To(HaveLen(1)) + g.Expect(result[0]).To(Equal(*test.expectedCondition)) + } + }) + } +} + +func TestValidator_Conflicts(t *testing.T) { + t.Parallel() + g := NewWithT(t) + + v := waf.NewValidator(validation.GenericValidator{}) + policy1 := createValidPolicy() + policy2 := createValidPolicy() + + // WAFPolicies should never conflict (always return false) + conflicts := v.Conflicts(policy1, policy2) + g.Expect(conflicts).To(BeFalse()) +} diff --git a/internal/controller/provisioner/objects.go b/internal/controller/provisioner/objects.go index 9cd5702d32..5aaee4ebd1 100644 --- a/internal/controller/provisioner/objects.go +++ b/internal/controller/provisioner/objects.go @@ -41,7 +41,7 @@ const ( defaultWAFEnforcerImagePath = "private-registry.nginx.com/nap/waf-enforcer" defaultWAFConfigMgrImagePath = "private-registry.nginx.com/nap/waf-config-mgr" // FIXME(ciarams87): Figure out best way to handle WAF image tags. - defaultWAFImageTag = "5.6.0" + defaultWAFImageTag = "5.7.0" // WAF shared volume names. appProtectBundlesVolumeName = "app-protect-bundles" diff --git a/internal/controller/provisioner/templates.go b/internal/controller/provisioner/templates.go index 791a807c4d..c0d035cfa0 100644 --- a/internal/controller/provisioner/templates.go +++ b/internal/controller/provisioner/templates.go @@ -44,6 +44,7 @@ allowed_directories: - /etc/nginx - /usr/share/nginx - /var/run/nginx +- /etc/app_protect/bundles/ features: - configuration - certificates diff --git a/internal/controller/state/change_processor.go b/internal/controller/state/change_processor.go index a3b43979c5..87711dab08 100644 --- a/internal/controller/state/change_processor.go +++ b/internal/controller/state/change_processor.go @@ -206,6 +206,11 @@ func NewChangeProcessorImpl(cfg ChangeProcessorConfig) *ChangeProcessorImpl { store: commonPolicyObjectStore, predicate: funcPredicate{stateChanged: isNGFPolicyRelevant}, }, + { + gvk: cfg.MustExtractGVK(&ngfAPIv1alpha1.WAFPolicy{}), + store: commonPolicyObjectStore, + predicate: funcPredicate{stateChanged: isNGFPolicyRelevant}, + }, { gvk: cfg.MustExtractGVK(&v1alpha2.TLSRoute{}), store: newObjectStoreMapAdapter(clusterStore.TLSRoutes), diff --git a/internal/controller/state/change_processor_test.go b/internal/controller/state/change_processor_test.go index ca5979324a..b4a391bb0a 100644 --- a/internal/controller/state/change_processor_test.go +++ b/internal/controller/state/change_processor_test.go @@ -2940,13 +2940,14 @@ var _ = Describe("ChangeProcessor", func() { Describe("NGF Policy resource changes", Ordered, func() { var ( - gw *v1.Gateway - route *v1.HTTPRoute - svc *apiv1.Service - csp, cspUpdated *ngfAPIv1alpha1.ClientSettingsPolicy - obs, obsUpdated *ngfAPIv1alpha2.ObservabilityPolicy - usp, uspUpdated *ngfAPIv1alpha1.UpstreamSettingsPolicy - cspKey, obsKey, uspKey graph.PolicyKey + gw *v1.Gateway + route *v1.HTTPRoute + svc *apiv1.Service + csp, cspUpdated *ngfAPIv1alpha1.ClientSettingsPolicy + obs, obsUpdated *ngfAPIv1alpha2.ObservabilityPolicy + usp, uspUpdated *ngfAPIv1alpha1.UpstreamSettingsPolicy + waf, wafUpdated *ngfAPIv1alpha1.WAFPolicy + cspKey, obsKey, uspKey, wafKey graph.PolicyKey ) BeforeAll(func() { @@ -3068,6 +3069,35 @@ var _ = Describe("ChangeProcessor", func() { Version: "v1alpha1", }, } + + waf = &ngfAPIv1alpha1.WAFPolicy{ + ObjectMeta: metav1.ObjectMeta{ + Name: "waf", + Namespace: "test", + }, + Spec: ngfAPIv1alpha1.WAFPolicySpec{ + TargetRef: v1alpha2.LocalPolicyTargetReference{ + Group: v1.GroupName, + Kind: kinds.Gateway, + Name: "gw", + }, + PolicySource: &ngfAPIv1alpha1.WAFPolicySource{ + FileLocation: "http://example.com/policy.tgz", + }, + }, + } + + wafUpdated = waf.DeepCopy() + wafUpdated.Spec.PolicySource.FileLocation = "http://example.com/updated-policy.tgz" + + wafKey = graph.PolicyKey{ + NsName: types.NamespacedName{Name: "waf", Namespace: "test"}, + GVK: schema.GroupVersionKind{ + Group: ngfAPIv1alpha1.GroupName, + Kind: kinds.WAFPolicy, + Version: "v1alpha1", + }, + } }) /* @@ -3079,6 +3109,7 @@ var _ = Describe("ChangeProcessor", func() { When("a policy is created that references a resource that is not in the last graph", func() { It("reports no changes", func() { processor.CaptureUpsertChange(csp) + processor.CaptureUpsertChange(waf) processor.CaptureUpsertChange(obs) processor.CaptureUpsertChange(usp) @@ -3093,6 +3124,8 @@ var _ = Describe("ChangeProcessor", func() { Expect(graph).ToNot(BeNil()) Expect(graph.NGFPolicies).To(HaveKey(cspKey)) Expect(graph.NGFPolicies[cspKey].Source).To(Equal(csp)) + Expect(graph.NGFPolicies).To(HaveKey(wafKey)) + Expect(graph.NGFPolicies[wafKey].Source).To(Equal(waf)) Expect(graph.NGFPolicies).ToNot(HaveKey(obsKey)) processor.CaptureUpsertChange(route) @@ -3113,6 +3146,7 @@ var _ = Describe("ChangeProcessor", func() { processor.CaptureUpsertChange(cspUpdated) processor.CaptureUpsertChange(obsUpdated) processor.CaptureUpsertChange(uspUpdated) + processor.CaptureUpsertChange(wafUpdated) graph := processor.Process() Expect(graph).ToNot(BeNil()) @@ -3122,6 +3156,8 @@ var _ = Describe("ChangeProcessor", func() { Expect(graph.NGFPolicies[obsKey].Source).To(Equal(obsUpdated)) Expect(graph.NGFPolicies).To(HaveKey(uspKey)) Expect(graph.NGFPolicies[uspKey].Source).To(Equal(uspUpdated)) + Expect(graph.NGFPolicies).To(HaveKey(wafKey)) + Expect(graph.NGFPolicies[wafKey].Source).To(Equal(wafUpdated)) }) }) When("the policy is deleted", func() { @@ -3129,6 +3165,7 @@ var _ = Describe("ChangeProcessor", func() { processor.CaptureDeleteChange(&ngfAPIv1alpha1.ClientSettingsPolicy{}, client.ObjectKeyFromObject(csp)) processor.CaptureDeleteChange(&ngfAPIv1alpha2.ObservabilityPolicy{}, client.ObjectKeyFromObject(obs)) processor.CaptureDeleteChange(&ngfAPIv1alpha1.UpstreamSettingsPolicy{}, client.ObjectKeyFromObject(usp)) + processor.CaptureDeleteChange(&ngfAPIv1alpha1.WAFPolicy{}, client.ObjectKeyFromObject(waf)) graph := processor.Process() Expect(graph).ToNot(BeNil()) diff --git a/internal/controller/state/dataplane/configuration.go b/internal/controller/state/dataplane/configuration.go index 7a1cd01dfe..179cf5cd9e 100644 --- a/internal/controller/state/dataplane/configuration.go +++ b/internal/controller/state/dataplane/configuration.go @@ -79,7 +79,7 @@ func BuildConfiguration( NginxPlus: nginxPlus, MainSnippets: buildSnippetsForContext(g.SnippetsFilters, ngfAPIv1alpha1.NginxContextMain), AuxiliarySecrets: buildAuxiliarySecrets(g.PlusSecrets), - WAF: WAFConfig{Enabled: graph.WAFEnabledForNginxProxy(gateway.EffectiveNginxProxy)}, + WAF: buildWAF(g, gateway), } return config @@ -1143,3 +1143,13 @@ func GetDefaultConfiguration(g *graph.Graph, gateway *graph.Gateway) Configurati AuxiliarySecrets: buildAuxiliarySecrets(g.PlusSecrets), } } + +func buildWAF(g *graph.Graph, gateway *graph.Gateway) WAFConfig { + wb := convertWAFBundles(g.ReferencedWAFBundles) + + wc := WAFConfig{ + Enabled: graph.WAFEnabledForNginxProxy(gateway.EffectiveNginxProxy), + WAFBundles: wb, + } + return wc +} diff --git a/internal/controller/state/dataplane/configuration_test.go b/internal/controller/state/dataplane/configuration_test.go index 95a4806e5a..d81123660a 100644 --- a/internal/controller/state/dataplane/configuration_test.go +++ b/internal/controller/state/dataplane/configuration_test.go @@ -4906,3 +4906,99 @@ func TestBuildNginxPlus(t *testing.T) { }) } } + +func TestBuildWAF(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + graph *graph.Graph + gateway *graph.Gateway + expWAFConfig WAFConfig + }{ + { + name: "WAF disabled, no bundles", + graph: &graph.Graph{ + ReferencedWAFBundles: map[graph.WAFBundleKey]*graph.WAFBundleData{}, + }, + gateway: &graph.Gateway{ + EffectiveNginxProxy: nil, + }, + expWAFConfig: WAFConfig{ + Enabled: false, + WAFBundles: map[WAFBundleID]WAFBundle{}, + }, + }, + { + name: "WAF enabled, no bundles", + graph: &graph.Graph{ + ReferencedWAFBundles: map[graph.WAFBundleKey]*graph.WAFBundleData{}, + }, + gateway: &graph.Gateway{ + EffectiveNginxProxy: &graph.EffectiveNginxProxy{ + WAF: helpers.GetPointer(ngfAPIv1alpha2.WAFEnabled), + }, + }, + expWAFConfig: WAFConfig{ + Enabled: true, + WAFBundles: map[WAFBundleID]WAFBundle{}, + }, + }, + { + name: "WAF disabled, with bundles", + graph: &graph.Graph{ + ReferencedWAFBundles: map[graph.WAFBundleKey]*graph.WAFBundleData{ + "bundle1.tgz": func() *graph.WAFBundleData { + data := graph.WAFBundleData([]byte("bundle data")) + return &data + }(), + }, + }, + gateway: &graph.Gateway{ + EffectiveNginxProxy: &graph.EffectiveNginxProxy{ + WAF: helpers.GetPointer(ngfAPIv1alpha2.WAFDisabled), + }, + }, + expWAFConfig: WAFConfig{ + Enabled: false, + WAFBundles: map[WAFBundleID]WAFBundle{ + "bundle1.tgz": WAFBundle([]byte("bundle data")), + }, + }, + }, + { + name: "WAF enabled, with bundles", + graph: &graph.Graph{ + ReferencedWAFBundles: map[graph.WAFBundleKey]*graph.WAFBundleData{ + "bundle1.tgz": func() *graph.WAFBundleData { + data := graph.WAFBundleData([]byte("first bundle")) + return &data + }(), + "bundle2.tgz": nil, + }, + }, + gateway: &graph.Gateway{ + EffectiveNginxProxy: &graph.EffectiveNginxProxy{ + WAF: helpers.GetPointer(ngfAPIv1alpha2.WAFEnabled), + }, + }, + expWAFConfig: WAFConfig{ + Enabled: true, + WAFBundles: map[WAFBundleID]WAFBundle{ + "bundle1.tgz": WAFBundle([]byte("first bundle")), + "bundle2.tgz": WAFBundle(nil), + }, + }, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + t.Parallel() + g := NewWithT(t) + + result := buildWAF(test.graph, test.gateway) + g.Expect(result).To(Equal(test.expWAFConfig)) + }) + } +} diff --git a/internal/controller/state/dataplane/convert.go b/internal/controller/state/dataplane/convert.go index 0ab57bf0e9..d2a4c4457a 100644 --- a/internal/controller/state/dataplane/convert.go +++ b/internal/controller/state/dataplane/convert.go @@ -166,3 +166,20 @@ func convertSnippetsFilter(filter *graph.SnippetsFilter) SnippetsFilter { return result } + +func convertWAFBundles(graphBundles map[graph.WAFBundleKey]*graph.WAFBundleData) map[WAFBundleID]WAFBundle { + result := make(map[WAFBundleID]WAFBundle, len(graphBundles)) + + for key, value := range graphBundles { + dataplaneKey := WAFBundleID(key) + + var dataplaneValue WAFBundle + if value != nil { + dataplaneValue = WAFBundle(*value) + } + + result[dataplaneKey] = dataplaneValue + } + + return result +} diff --git a/internal/controller/state/dataplane/convert_test.go b/internal/controller/state/dataplane/convert_test.go index 77b4c689ee..d9c7c2a679 100644 --- a/internal/controller/state/dataplane/convert_test.go +++ b/internal/controller/state/dataplane/convert_test.go @@ -6,6 +6,7 @@ import ( . "github.com/onsi/gomega" v1 "sigs.k8s.io/gateway-api/apis/v1" + "github.com/nginx/nginx-gateway-fabric/internal/controller/state/graph" "github.com/nginx/nginx-gateway-fabric/internal/framework/helpers" ) @@ -501,3 +502,69 @@ func TestConvertMatchType(t *testing.T) { }) } } + +func TestConvertWAFBundles(t *testing.T) { + t.Parallel() + + tests := []struct { + input map[graph.WAFBundleKey]*graph.WAFBundleData + expected map[WAFBundleID]WAFBundle + name string + }{ + { + name: "empty input", + input: map[graph.WAFBundleKey]*graph.WAFBundleData{}, + expected: map[WAFBundleID]WAFBundle{}, + }, + { + name: "single bundle with data", + input: map[graph.WAFBundleKey]*graph.WAFBundleData{ + "bundle1.tgz": func() *graph.WAFBundleData { + data := graph.WAFBundleData([]byte("bundle data")) + return &data + }(), + }, + expected: map[WAFBundleID]WAFBundle{ + "bundle1.tgz": WAFBundle([]byte("bundle data")), + }, + }, + { + name: "single bundle with nil data", + input: map[graph.WAFBundleKey]*graph.WAFBundleData{ + "bundle2.tgz": nil, + }, + expected: map[WAFBundleID]WAFBundle{ + "bundle2.tgz": WAFBundle(nil), + }, + }, + { + name: "multiple bundles with mixed data", + input: map[graph.WAFBundleKey]*graph.WAFBundleData{ + "bundle1.tgz": func() *graph.WAFBundleData { + data := graph.WAFBundleData([]byte("first bundle")) + return &data + }(), + "bundle2.tgz": nil, + "bundle3.tgz": func() *graph.WAFBundleData { + data := graph.WAFBundleData([]byte("third bundle")) + return &data + }(), + }, + expected: map[WAFBundleID]WAFBundle{ + "bundle1.tgz": WAFBundle([]byte("first bundle")), + "bundle2.tgz": WAFBundle(nil), + "bundle3.tgz": WAFBundle([]byte("third bundle")), + }, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + t.Parallel() + g := NewWithT(t) + + result := convertWAFBundles(test.input) + g.Expect(result).To(Equal(test.expected)) + }) + } +} diff --git a/internal/controller/state/dataplane/types.go b/internal/controller/state/dataplane/types.go index 706f7a127f..3d00a52fa1 100644 --- a/internal/controller/state/dataplane/types.go +++ b/internal/controller/state/dataplane/types.go @@ -37,6 +37,8 @@ type Configuration struct { Upstreams []Upstream // DeploymentContext contains metadata about NGF and the cluster. DeploymentContext DeploymentContext + // WAF holds the WAF configuration. + WAF WAFConfig // AuxiliarySecrets contains additional secret data, like certificates/keys/tokens that are not related to // Gateway API resources. AuxiliarySecrets map[graph.SecretFileType][]byte @@ -54,8 +56,6 @@ type Configuration struct { NginxPlus NginxPlus // BaseHTTPConfig holds the configuration options at the http context. BaseHTTPConfig BaseHTTPConfig - // WAF holds the WAF configuration. - WAF WAFConfig } // SSLKeyPairID is a unique identifier for a SSLKeyPair. @@ -69,6 +69,13 @@ type CertBundleID string // CertBundle is a Certificate bundle. type CertBundle []byte +// WAFBundleID is a unique identifier for a WAF bundle. +// The ID is safe to use as a file name. +type WAFBundleID string + +// WAFBundle is a WAF bundle. +type WAFBundle []byte + // SSLKeyPair is an SSL private/public key pair. type SSLKeyPair struct { // Cert is the certificate. @@ -449,8 +456,10 @@ type DeploymentContext struct { } // WAFConfig holds the WAF configuration for the dataplane. -// It is used to determine whether WAF is enabled and to load the WAF module. +// It is used to determine whether WAF is enabled and to load the WAF module, as well as storing the WAFBundles. type WAFConfig struct { + // WAFBundles are the WAF Policy Bundles to be stored in the app_protect bundles directory. + WAFBundles map[WAFBundleID]WAFBundle // Enabled indicates whether WAF is enabled. Enabled bool } diff --git a/internal/controller/state/graph/graph.go b/internal/controller/state/graph/graph.go index 12017112e0..f26c756ce3 100644 --- a/internal/controller/state/graph/graph.go +++ b/internal/controller/state/graph/graph.go @@ -72,6 +72,8 @@ type Graph struct { BackendTLSPolicies map[types.NamespacedName]*BackendTLSPolicy // NGFPolicies holds all NGF Policies. NGFPolicies map[PolicyKey]*Policy + // ReferencedWAFBundles includes the WAFPolicy Bundles that have been referenced by any Gateways or Routes. + ReferencedWAFBundles map[WAFBundleKey]*WAFBundleData // SnippetsFilters holds all the SnippetsFilters. SnippetsFilters map[types.NamespacedName]*SnippetsFilter // PlusSecrets holds the secrets related to NGINX Plus licensing. @@ -272,7 +274,7 @@ func BuildGraph( addGatewaysForBackendTLSPolicies(processedBackendTLSPolicies, referencedServices) // policies must be processed last because they rely on the state of the other resources in the graph - processedPolicies := processPolicies( + processedPolicies, referencedWAFBundles := processPolicies( state.NGFPolicies, validators.PolicyValidator, routes, @@ -297,6 +299,7 @@ func BuildGraph( NGFPolicies: processedPolicies, SnippetsFilters: processedSnippetsFilters, PlusSecrets: plusSecrets, + ReferencedWAFBundles: referencedWAFBundles, } g.attachPolicies(validators.PolicyValidator, controllerName) diff --git a/internal/controller/state/graph/nginxproxy.go b/internal/controller/state/graph/nginxproxy.go index 74dd59fdbb..f0e3b07e19 100644 --- a/internal/controller/state/graph/nginxproxy.go +++ b/internal/controller/state/graph/nginxproxy.go @@ -99,7 +99,7 @@ func nginxProxyValid(np *NginxProxy) bool { } func telemetryEnabledForNginxProxy(np *EffectiveNginxProxy) bool { - if np.Telemetry == nil || np.Telemetry.Exporter == nil || np.Telemetry.Exporter.Endpoint == nil { + if np == nil || np.Telemetry == nil || np.Telemetry.Exporter == nil || np.Telemetry.Exporter.Endpoint == nil { return false } diff --git a/internal/controller/state/graph/nginxproxy_test.go b/internal/controller/state/graph/nginxproxy_test.go index 4658b6f046..bfc0908b8b 100644 --- a/internal/controller/state/graph/nginxproxy_test.go +++ b/internal/controller/state/graph/nginxproxy_test.go @@ -318,6 +318,11 @@ func TestTelemetryEnabledForNginxProxy(t *testing.T) { name string enabled bool }{ + { + name: "effective nginx proxy is nil", + ep: nil, + enabled: false, + }, { name: "telemetry struct is nil", ep: &EffectiveNginxProxy{ diff --git a/internal/controller/state/graph/policies.go b/internal/controller/state/graph/policies.go index b5a1251aee..654bde40b2 100644 --- a/internal/controller/state/graph/policies.go +++ b/internal/controller/state/graph/policies.go @@ -3,16 +3,21 @@ package graph import ( "fmt" "sort" + "strconv" + "time" "k8s.io/apimachinery/pkg/runtime/schema" "k8s.io/apimachinery/pkg/types" "sigs.k8s.io/controller-runtime/pkg/client" v1 "sigs.k8s.io/gateway-api/apis/v1" + ngfAPIv1alpha1 "github.com/nginx/nginx-gateway-fabric/apis/v1alpha1" "github.com/nginx/nginx-gateway-fabric/internal/controller/nginx/config/policies" ngfsort "github.com/nginx/nginx-gateway-fabric/internal/controller/sort" "github.com/nginx/nginx-gateway-fabric/internal/controller/state/conditions" "github.com/nginx/nginx-gateway-fabric/internal/controller/state/validation" + "github.com/nginx/nginx-gateway-fabric/internal/framework/fetch" + "github.com/nginx/nginx-gateway-fabric/internal/framework/helpers" "github.com/nginx/nginx-gateway-fabric/internal/framework/kinds" ) @@ -61,6 +66,10 @@ type PolicyKey struct { GVK schema.GroupVersionKind } +type WAFBundleKey string + +type WAFBundleData []byte + const ( gatewayGroupKind = v1.GroupName + "/" + kinds.Gateway hrGroupKind = v1.GroupName + "/" + kinds.HTTPRoute @@ -68,6 +77,12 @@ const ( serviceGroupKind = "core" + "/" + kinds.Service ) +var wafPolicyGVK = schema.GroupVersionKind{ + Group: ngfAPIv1alpha1.SchemeGroupVersion.Group, + Version: ngfAPIv1alpha1.SchemeGroupVersion.Version, + Kind: kinds.WAFPolicy, +} + // attachPolicies attaches the graph's processed policies to the resources they target. It modifies the graph in place. func (g *Graph) attachPolicies(validator validation.PolicyValidator, ctlrName string) { if len(g.Gateways) == 0 { @@ -78,7 +93,7 @@ func (g *Graph) attachPolicies(validator validation.PolicyValidator, ctlrName st for _, ref := range policy.TargetRefs { switch ref.Kind { case kinds.Gateway: - attachPolicyToGateway(policy, ref, g.Gateways, ctlrName) + attachPolicyToGateway(policy, ref, g.Gateways, ctlrName, validator) case kinds.HTTPRoute, kinds.GRPCRoute: route, exists := g.Routes[routeKeyForKind(ref.Kind, ref.Nsname)] if !exists { @@ -163,12 +178,12 @@ func attachPolicyToRoute(policy *Policy, route *L7Route, validator validation.Po return } - // as of now, ObservabilityPolicy is the only policy that needs this check, and it only attaches to Routes for _, parentRef := range route.ParentRefs { if parentRef.Gateway != nil && parentRef.Gateway.EffectiveNginxProxy != nil { gw := parentRef.Gateway globalSettings := &policies.GlobalSettings{ TelemetryEnabled: telemetryEnabledForNginxProxy(gw.EffectiveNginxProxy), + WAFEnabled: WAFEnabledForNginxProxy(gw.EffectiveNginxProxy), } if conds := validator.ValidateGlobalSettings(policy.Source, globalSettings); len(conds) > 0 { @@ -191,6 +206,7 @@ func attachPolicyToGateway( ref PolicyTargetRef, gateways map[types.NamespacedName]*Gateway, ctlrName string, + validator validation.PolicyValidator, ) { if ngfPolicyAncestorsFull(policy, ctlrName) { // FIXME (kate-osborn): https://github.com/nginx/nginx-gateway-fabric/issues/1987 @@ -217,6 +233,17 @@ func attachPolicyToGateway( return } + globalSettings := &policies.GlobalSettings{ + TelemetryEnabled: telemetryEnabledForNginxProxy(gw.EffectiveNginxProxy), + WAFEnabled: WAFEnabledForNginxProxy(gw.EffectiveNginxProxy), + } + + if conds := validator.ValidateGlobalSettings(policy.Source, globalSettings); len(conds) > 0 { + ancestor.Conditions = conds + policy.Ancestors = append(policy.Ancestors, ancestor) + return + } + policy.Ancestors = append(policy.Ancestors, ancestor) gw.Policies = append(gw.Policies, policy) } @@ -227,9 +254,9 @@ func processPolicies( routes map[RouteKey]*L7Route, services map[types.NamespacedName]*ReferencedService, gws map[types.NamespacedName]*Gateway, -) map[PolicyKey]*Policy { +) (map[PolicyKey]*Policy, map[WAFBundleKey]*WAFBundleData) { if len(pols) == 0 || len(gws) == 0 { - return nil + return nil, nil } processedPolicies := make(map[PolicyKey]*Policy) @@ -291,7 +318,9 @@ func processPolicies( markConflictedPolicies(processedPolicies, validator) - return processedPolicies + refPolicyBundles := fetchWAFPolicyBundleData(processedPolicies) + + return processedPolicies, refPolicyBundles } func checkTargetRoutesForOverlap( @@ -448,3 +477,168 @@ func refGroupKind(group v1.Group, kind v1.Kind) string { return fmt.Sprintf("%s/%s", group, kind) } + +func fetchWAFPolicyBundleData( + processedPolicies map[PolicyKey]*Policy, + fetcherFactory ...func(...fetch.Option) fetch.Fetcher, // Optional for testing +) map[WAFBundleKey]*WAFBundleData { + // Use provided factory or default to real fetcher + createFetcher := func(opts ...fetch.Option) fetch.Fetcher { + return fetch.NewDefaultFetcher(opts...) + } + if len(fetcherFactory) > 0 { + createFetcher = fetcherFactory[0] + } + + refPolicyBundles := make(map[WAFBundleKey]*WAFBundleData) + + for policyKey, policy := range processedPolicies { + if policyKey.GVK != wafPolicyGVK { + continue + } + + if !policy.Valid { + continue + } + + wafPolicy, ok := policy.Source.(*ngfAPIv1alpha1.WAFPolicy) + if !ok { + continue + } + + if wafPolicy.Spec.PolicySource != nil && wafPolicy.Spec.PolicySource.FileLocation != "" { + fetcher := createFetcher(buildFetchOptions(wafPolicy.Spec.PolicySource)...) + if !fetchAndStoreBundle(wafPolicy.Spec.PolicySource.FileLocation, policy, refPolicyBundles, fetcher) { + continue // Policy was marked invalid, skip security logs + } + } + + for _, secLog := range wafPolicy.Spec.SecurityLogs { + if secLog.LogProfileBundle == nil || secLog.LogProfileBundle.FileLocation == "" { + continue + } + + fetcher := createFetcher(buildFetchOptions(secLog.LogProfileBundle)...) + if !fetchAndStoreBundle(secLog.LogProfileBundle.FileLocation, policy, refPolicyBundles, fetcher) { + break // Policy was marked invalid, skip other security logs + } + } + } + + if len(refPolicyBundles) == 0 { + return nil + } + + return refPolicyBundles +} + +// fetchAndStoreBundle fetches a bundle using the configuration specified in WAFPolicySource. +// Returns true if successful, false if there was an error (policy will be marked invalid). +func fetchAndStoreBundle( + fileLocation string, + policy *Policy, + bundles map[WAFBundleKey]*WAFBundleData, + fetcher fetch.Fetcher, +) bool { + data, err := fetcher.GetRemoteFile(fileLocation) + if err != nil { + policy.Valid = false + // FIXME(ciarams87): Add appropriate condition when available. + policy.Conditions = append(policy.Conditions, conditions.NewPolicyInvalid("Error fetching policy: "+err.Error())) + return false + } + + bundleData := WAFBundleData(data) + bundleKey := WAFBundleKey(helpers.ToSafeFileName(fileLocation)) + bundles[bundleKey] = &bundleData + + return true +} + +// buildFetchOptions builds fetch options from WAFPolicySource configuration. +func buildFetchOptions(policySource *ngfAPIv1alpha1.WAFPolicySource) []fetch.Option { + var options []fetch.Option + + options = addTimeoutOption(options, policySource) + options = addValidationOptions(options, policySource) + options = addRetryOptions(options, policySource) + + return options +} + +// addTimeoutOption adds timeout configuration to fetch options. +func addTimeoutOption(options []fetch.Option, policySource *ngfAPIv1alpha1.WAFPolicySource) []fetch.Option { + if policySource.Timeout != nil { + if timeout, err := parseDurationString(string(*policySource.Timeout)); err == nil { + options = append(options, fetch.WithTimeout(timeout)) + } + } + return options +} + +// addValidationOptions adds validation configuration to fetch options. +func addValidationOptions(options []fetch.Option, policySource *ngfAPIv1alpha1.WAFPolicySource) []fetch.Option { + if policySource.Validation != nil && len(policySource.Validation.Methods) > 0 { + for _, method := range policySource.Validation.Methods { + if string(method) == "checksum" { + options = addChecksumOption(options, policySource) + } + } + } + return options +} + +// addChecksumOption adds checksum validation configuration to fetch options. +func addChecksumOption(options []fetch.Option, policySource *ngfAPIv1alpha1.WAFPolicySource) []fetch.Option { + if policySource.Polling != nil && policySource.Polling.ChecksumLocation != nil { + checksumLocation := *policySource.Polling.ChecksumLocation + options = append(options, fetch.WithChecksum(checksumLocation)) + } else { + options = append(options, fetch.WithChecksum()) + } + return options +} + +// addRetryOptions adds retry configuration to fetch options. +func addRetryOptions(options []fetch.Option, policySource *ngfAPIv1alpha1.WAFPolicySource) []fetch.Option { + if policySource.Retry == nil { + return options + } + + if policySource.Retry.Attempts != nil { + options = append(options, fetch.WithRetryAttempts(*policySource.Retry.Attempts)) + } + + if policySource.Retry.Backoff != nil { + switch string(*policySource.Retry.Backoff) { + case "exponential": + options = append(options, fetch.WithRetryBackoff(fetch.RetryBackoffExponential)) + case "linear": + options = append(options, fetch.WithRetryBackoff(fetch.RetryBackoffLinear)) + } + } + + if policySource.Retry.MaxDelay != nil { + if maxDelay, err := parseDurationString(string(*policySource.Retry.MaxDelay)); err == nil { + options = append(options, fetch.WithMaxRetryDelay(maxDelay)) + } + } + + return options +} + +// parseDurationString parses a custom duration string that may not have a suffix. +// If no suffix is provided, assumes seconds. +func parseDurationString(durationStr string) (time.Duration, error) { + if durationStr == "" { + return 0, nil + } + + // If the string is just a number, assume seconds + if num, err := strconv.Atoi(durationStr); err == nil { + return time.Duration(num) * time.Second, nil + } + + // Try to parse as a standard Go duration + return time.ParseDuration(durationStr) +} diff --git a/internal/controller/state/graph/policies_test.go b/internal/controller/state/graph/policies_test.go index 7df4a9d432..f6708b1012 100644 --- a/internal/controller/state/graph/policies_test.go +++ b/internal/controller/state/graph/policies_test.go @@ -1,6 +1,7 @@ package graph import ( + "fmt" "slices" "testing" @@ -11,11 +12,14 @@ import ( v1 "sigs.k8s.io/gateway-api/apis/v1" "sigs.k8s.io/gateway-api/apis/v1alpha2" + ngfAPIv1alpha1 "github.com/nginx/nginx-gateway-fabric/apis/v1alpha1" ngfAPIv1alpha2 "github.com/nginx/nginx-gateway-fabric/apis/v1alpha2" "github.com/nginx/nginx-gateway-fabric/internal/controller/nginx/config/policies" "github.com/nginx/nginx-gateway-fabric/internal/controller/nginx/config/policies/policiesfakes" "github.com/nginx/nginx-gateway-fabric/internal/controller/state/conditions" "github.com/nginx/nginx-gateway-fabric/internal/controller/state/validation" + "github.com/nginx/nginx-gateway-fabric/internal/framework/fetch" + "github.com/nginx/nginx-gateway-fabric/internal/framework/fetch/fetchfakes" "github.com/nginx/nginx-gateway-fabric/internal/framework/helpers" "github.com/nginx/nginx-gateway-fabric/internal/framework/kinds" ) @@ -157,7 +161,8 @@ func TestAttachPolicies(t *testing.T) { Namespace: testNs, }, }, - Valid: true, + Valid: true, + EffectiveNginxProxy: &EffectiveNginxProxy{}, }, {Namespace: testNs, Name: "gateway1"}: { Source: &v1.Gateway{ @@ -166,7 +171,8 @@ func TestAttachPolicies(t *testing.T) { Namespace: testNs, }, }, - Valid: true, + Valid: true, + EffectiveNginxProxy: &EffectiveNginxProxy{}, }, } } @@ -241,7 +247,7 @@ func TestAttachPolicies(t *testing.T) { NGFPolicies: test.ngfPolicies, } - graph.attachPolicies(nil, "nginx-gateway") + graph.attachPolicies(&policiesfakes.FakeValidator{}, "nginx-gateway") for _, expect := range test.expects { expect(g, graph) } @@ -526,13 +532,53 @@ func TestAttachPolicyToGateway(t *testing.T) { Namespace: name.Namespace, }, }, - Valid: valid, + Valid: valid, + EffectiveNginxProxy: &EffectiveNginxProxy{}, + } + } + return gws + } + + newGatewayMapWithNginxProxy := func( + valid bool, + nsname []types.NamespacedName, + effectiveNginxProxy *EffectiveNginxProxy, + ) map[types.NamespacedName]*Gateway { + gws := make(map[types.NamespacedName]*Gateway) + for _, name := range nsname { + gws[name] = &Gateway{ + Source: &v1.Gateway{ + ObjectMeta: metav1.ObjectMeta{ + Name: name.Name, + Namespace: name.Namespace, + }, + }, + Valid: valid, + EffectiveNginxProxy: effectiveNginxProxy, } } return gws } + validatorError := &policiesfakes.FakeValidator{ + ValidateGlobalSettingsStub: func(_ policies.Policy, gs *policies.GlobalSettings) []conditions.Condition { + if !gs.TelemetryEnabled { + return []conditions.Condition{ + conditions.NewPolicyNotAcceptedNginxProxyNotSet(conditions.PolicyMessageTelemetryNotEnabled), + } + } + return nil + }, + } + + validatorNoError := &policiesfakes.FakeValidator{ + ValidateGlobalSettingsStub: func(_ policies.Policy, _ *policies.GlobalSettings) []conditions.Condition { + return nil + }, + } + tests := []struct { + validator validation.PolicyValidator policy *Policy gws map[types.NamespacedName]*Gateway name string @@ -556,6 +602,7 @@ func TestAttachPolicyToGateway(t *testing.T) { {Ancestor: getGatewayParentRef(gatewayNsName)}, }, expAttached: true, + validator: validatorNoError, }, { name: "attached with existing ancestor", @@ -578,6 +625,7 @@ func TestAttachPolicyToGateway(t *testing.T) { {Ancestor: getGatewayParentRef(gatewayNsName)}, }, expAttached: true, + validator: validatorNoError, }, { name: "not attached; gateway is not found", @@ -599,6 +647,7 @@ func TestAttachPolicyToGateway(t *testing.T) { }, }, expAttached: false, + validator: validatorNoError, }, { name: "not attached; invalid gateway", @@ -620,6 +669,7 @@ func TestAttachPolicyToGateway(t *testing.T) { }, }, expAttached: false, + validator: validatorNoError, }, { name: "not attached; max ancestors", @@ -636,6 +686,56 @@ func TestAttachPolicyToGateway(t *testing.T) { gws: newGatewayMap(true, []types.NamespacedName{gatewayNsName}), expAncestors: nil, expAttached: false, + validator: validatorNoError, + }, + { + name: "not attached; global settings validation fails", + policy: &Policy{ + Source: &policiesfakes.FakePolicy{}, + TargetRefs: []PolicyTargetRef{ + { + Nsname: gatewayNsName, + Kind: "Gateway", + }, + }, + InvalidForGateways: map[types.NamespacedName]struct{}{}, + }, + gws: newGatewayMapWithNginxProxy(true, []types.NamespacedName{gatewayNsName}, &EffectiveNginxProxy{}), + expAncestors: []PolicyAncestor{ + { + Ancestor: getGatewayParentRef(gatewayNsName), + Conditions: []conditions.Condition{ + conditions.NewPolicyNotAcceptedNginxProxyNotSet(conditions.PolicyMessageTelemetryNotEnabled), + }, + }, + }, + expAttached: false, + validator: validatorError, + }, + { + name: "attached; global settings validation passes", + policy: &Policy{ + Source: &policiesfakes.FakePolicy{}, + TargetRefs: []PolicyTargetRef{ + { + Nsname: gatewayNsName, + Kind: "Gateway", + }, + }, + InvalidForGateways: map[types.NamespacedName]struct{}{}, + }, + gws: newGatewayMapWithNginxProxy(true, []types.NamespacedName{gatewayNsName}, &EffectiveNginxProxy{ + Telemetry: &ngfAPIv1alpha2.Telemetry{ + Exporter: &ngfAPIv1alpha2.TelemetryExporter{ + Endpoint: helpers.GetPointer("test-endpoint"), + }, + }, + }), + expAncestors: []PolicyAncestor{ + {Ancestor: getGatewayParentRef(gatewayNsName)}, + }, + expAttached: true, + validator: validatorError, }, } @@ -644,7 +744,7 @@ func TestAttachPolicyToGateway(t *testing.T) { t.Parallel() g := NewWithT(t) - attachPolicyToGateway(test.policy, test.policy.TargetRefs[0], test.gws, "nginx-gateway") + attachPolicyToGateway(test.policy, test.policy.TargetRefs[0], test.gws, "nginx-gateway", test.validator) if test.expAttached { for _, gw := range test.gws { @@ -1112,7 +1212,7 @@ func TestProcessPolicies(t *testing.T) { t.Parallel() g := NewWithT(t) - processed := processPolicies(test.policies, test.validator, routes, services, gateways) + processed, _ := processPolicies(test.policies, test.validator, routes, services, gateways) g.Expect(processed).To(BeEquivalentTo(test.expProcessedPolicies)) }) } @@ -1275,7 +1375,7 @@ func TestProcessPolicies_RouteOverlap(t *testing.T) { t.Parallel() g := NewWithT(t) - processed := processPolicies(test.policies, test.validator, test.routes, nil, gateways) + processed, _ := processPolicies(test.policies, test.validator, test.routes, nil, gateways) g.Expect(processed).To(HaveLen(len(test.policies))) for _, pol := range processed { @@ -1589,3 +1689,562 @@ func getGatewayParentRef(gwNsName types.NamespacedName) v1.ParentReference { Name: v1.ObjectName(gwNsName.Name), } } + +// createWAFPolicy is a test helper for creating WAF policies. +func createWAFPolicy( + name string, + policySource *ngfAPIv1alpha1.WAFPolicySource, + securityLogs []ngfAPIv1alpha1.WAFSecurityLog, +) *ngfAPIv1alpha1.WAFPolicy { + return &ngfAPIv1alpha1.WAFPolicy{ + ObjectMeta: metav1.ObjectMeta{ + Name: name, + Namespace: testNs, + }, + Spec: ngfAPIv1alpha1.WAFPolicySpec{ + TargetRef: v1alpha2.LocalPolicyTargetReference{ + Group: "gateway.networking.k8s.io", + Kind: "Gateway", + Name: "test-gateway", + }, + PolicySource: policySource, + SecurityLogs: securityLogs, + }, + } +} + +func TestFetchPolicyBundleData(t *testing.T) { + t.Parallel() + + nonWAFPolicyGVK := schema.GroupVersionKind{ + Group: ngfAPIv1alpha1.SchemeGroupVersion.Group, + Version: ngfAPIv1alpha1.SchemeGroupVersion.Version, + Kind: kinds.ObservabilityPolicy, + } + + tests := []struct { + processedPolicies map[PolicyKey]*Policy + fetcherBehavior map[string]error + expectedPolicyState map[string]bool + expectFetchConditions map[string]bool + name string + expectedBundleCount int + }{ + { + name: "no policies", + processedPolicies: map[PolicyKey]*Policy{}, + fetcherBehavior: nil, + expectedBundleCount: 0, + expectedPolicyState: map[string]bool{}, + expectFetchConditions: map[string]bool{}, + }, + { + name: "non-WAF policy", + processedPolicies: map[PolicyKey]*Policy{ + { + NsName: types.NamespacedName{Namespace: testNs, Name: "obs-policy"}, + GVK: nonWAFPolicyGVK, + }: { + Source: &ngfAPIv1alpha2.ObservabilityPolicy{}, + Valid: true, + }, + }, + fetcherBehavior: nil, + expectedBundleCount: 0, + expectedPolicyState: map[string]bool{}, + expectFetchConditions: map[string]bool{}, + }, + { + name: "invalid WAF policy", + processedPolicies: map[PolicyKey]*Policy{ + { + NsName: types.NamespacedName{Namespace: testNs, Name: "invalid-waf"}, + GVK: wafPolicyGVK, + }: { + Source: createWAFPolicy("invalid-waf", &ngfAPIv1alpha1.WAFPolicySource{ + FileLocation: "http://example.com/policy.tgz", + }, nil), + Valid: false, + }, + }, + fetcherBehavior: nil, + expectedBundleCount: 0, + expectedPolicyState: map[string]bool{ + "invalid-waf": false, + }, + expectFetchConditions: map[string]bool{ + "invalid-waf": false, + }, + }, + { + name: "WAF policy with empty FileLocation", + processedPolicies: map[PolicyKey]*Policy{ + { + NsName: types.NamespacedName{Namespace: testNs, Name: "waf-empty"}, + GVK: wafPolicyGVK, + }: { + Source: createWAFPolicy("waf-empty", &ngfAPIv1alpha1.WAFPolicySource{ + FileLocation: "", + }, []ngfAPIv1alpha1.WAFSecurityLog{ + { + LogProfileBundle: &ngfAPIv1alpha1.WAFPolicySource{ + FileLocation: "", + }, + Destination: ngfAPIv1alpha1.SecurityLogDestination{ + Type: ngfAPIv1alpha1.SecurityLogDestinationTypeStderr, + }, + }, + }), + Valid: true, + }, + }, + fetcherBehavior: nil, + expectedBundleCount: 0, + expectedPolicyState: map[string]bool{ + "waf-empty": true, + }, + expectFetchConditions: map[string]bool{ + "waf-empty": false, + }, + }, + { + name: "WAF policy with PolicySource only - success", + processedPolicies: map[PolicyKey]*Policy{ + { + NsName: types.NamespacedName{Namespace: testNs, Name: "waf-policy"}, + GVK: wafPolicyGVK, + }: { + Source: createWAFPolicy("waf-policy", &ngfAPIv1alpha1.WAFPolicySource{ + FileLocation: "http://example.com/policy.tgz", + }, nil), + Valid: true, + }, + }, + fetcherBehavior: map[string]error{ + "http://example.com/policy.tgz": nil, + }, + expectedBundleCount: 1, + expectedPolicyState: map[string]bool{ + "waf-policy": true, + }, + expectFetchConditions: map[string]bool{ + "waf-policy": false, + }, + }, + { + name: "WAF policy with SecurityLogs only - success", + processedPolicies: map[PolicyKey]*Policy{ + { + NsName: types.NamespacedName{Namespace: testNs, Name: "waf-logs"}, + GVK: wafPolicyGVK, + }: { + Source: createWAFPolicy("waf-logs", nil, []ngfAPIv1alpha1.WAFSecurityLog{ + { + LogProfileBundle: &ngfAPIv1alpha1.WAFPolicySource{ + FileLocation: "http://example.com/log-profile.tgz", + }, + Destination: ngfAPIv1alpha1.SecurityLogDestination{ + Type: ngfAPIv1alpha1.SecurityLogDestinationTypeStderr, + }, + }, + }), + Valid: true, + }, + }, + fetcherBehavior: map[string]error{ + "http://example.com/log-profile.tgz": nil, + }, + expectedBundleCount: 1, + expectedPolicyState: map[string]bool{ + "waf-logs": true, + }, + expectFetchConditions: map[string]bool{ + "waf-logs": false, + }, + }, + { + name: "WAF policy with both PolicySource and SecurityLogs - success", + processedPolicies: map[PolicyKey]*Policy{ + { + NsName: types.NamespacedName{Namespace: testNs, Name: "waf-full"}, + GVK: wafPolicyGVK, + }: { + Source: createWAFPolicy("waf-full", &ngfAPIv1alpha1.WAFPolicySource{ + FileLocation: "http://example.com/policy.tgz", + }, []ngfAPIv1alpha1.WAFSecurityLog{ + { + LogProfileBundle: &ngfAPIv1alpha1.WAFPolicySource{ + FileLocation: "http://example.com/log-profile.tgz", + }, + Destination: ngfAPIv1alpha1.SecurityLogDestination{ + Type: ngfAPIv1alpha1.SecurityLogDestinationTypeStderr, + }, + }, + }), + Valid: true, + }, + }, + fetcherBehavior: map[string]error{ + "http://example.com/policy.tgz": nil, + "http://example.com/log-profile.tgz": nil, + }, + expectedBundleCount: 2, + expectedPolicyState: map[string]bool{ + "waf-full": true, + }, + expectFetchConditions: map[string]bool{ + "waf-full": false, + }, + }, + { + name: "WAF policy with PolicySource failure", + processedPolicies: map[PolicyKey]*Policy{ + { + NsName: types.NamespacedName{Namespace: testNs, Name: "waf-fail"}, + GVK: wafPolicyGVK, + }: { + Source: createWAFPolicy("waf-fail", &ngfAPIv1alpha1.WAFPolicySource{ + FileLocation: "http://unreachable.example.com/policy.tgz", + }, []ngfAPIv1alpha1.WAFSecurityLog{ + { + LogProfileBundle: &ngfAPIv1alpha1.WAFPolicySource{ + FileLocation: "http://example.com/log-profile.tgz", + }, + Destination: ngfAPIv1alpha1.SecurityLogDestination{ + Type: ngfAPIv1alpha1.SecurityLogDestinationTypeStderr, + }, + }, + }), + Valid: true, + }, + }, + fetcherBehavior: map[string]error{ + "http://unreachable.example.com/policy.tgz": fmt.Errorf("network error"), + "http://example.com/log-profile.tgz": nil, + }, + expectedBundleCount: 0, + expectedPolicyState: map[string]bool{ + "waf-fail": false, + }, + expectFetchConditions: map[string]bool{ + "waf-fail": true, + }, + }, + { + name: "WAF policy with PolicySource success but SecurityLog failure", + processedPolicies: map[PolicyKey]*Policy{ + { + NsName: types.NamespacedName{Namespace: testNs, Name: "waf-mixed"}, + GVK: wafPolicyGVK, + }: { + Source: createWAFPolicy("waf-mixed", &ngfAPIv1alpha1.WAFPolicySource{ + FileLocation: "http://example.com/policy.tgz", + }, []ngfAPIv1alpha1.WAFSecurityLog{ + { + LogProfileBundle: &ngfAPIv1alpha1.WAFPolicySource{ + FileLocation: "http://unreachable.example.com/log.tgz", + }, + Destination: ngfAPIv1alpha1.SecurityLogDestination{ + Type: ngfAPIv1alpha1.SecurityLogDestinationTypeStderr, + }, + }, + }), + Valid: true, + }, + }, + fetcherBehavior: map[string]error{ + "http://example.com/policy.tgz": nil, + "http://unreachable.example.com/log.tgz": fmt.Errorf("network error"), + }, + expectedBundleCount: 1, + expectedPolicyState: map[string]bool{ + "waf-mixed": false, + }, + expectFetchConditions: map[string]bool{ + "waf-mixed": true, + }, + }, + { + name: "WAF policy with multiple SecurityLog bundles - partial failure", + processedPolicies: map[PolicyKey]*Policy{ + { + NsName: types.NamespacedName{Namespace: testNs, Name: "waf-multi"}, + GVK: wafPolicyGVK, + }: { + Source: createWAFPolicy("waf-multi", nil, []ngfAPIv1alpha1.WAFSecurityLog{ + { + LogProfileBundle: &ngfAPIv1alpha1.WAFPolicySource{ + FileLocation: "http://example.com/log1.tgz", + }, + Destination: ngfAPIv1alpha1.SecurityLogDestination{ + Type: ngfAPIv1alpha1.SecurityLogDestinationTypeStderr, + }, + }, + { + LogProfileBundle: &ngfAPIv1alpha1.WAFPolicySource{ + FileLocation: "http://unreachable.example.com/log2.tgz", + }, + Destination: ngfAPIv1alpha1.SecurityLogDestination{ + Type: ngfAPIv1alpha1.SecurityLogDestinationTypeStderr, + }, + }, + }), + Valid: true, + }, + }, + fetcherBehavior: map[string]error{ + "http://example.com/log1.tgz": nil, + "http://unreachable.example.com/log2.tgz": fmt.Errorf("network error"), + }, + expectedBundleCount: 1, + expectedPolicyState: map[string]bool{ + "waf-multi": false, + }, + expectFetchConditions: map[string]bool{ + "waf-multi": true, + }, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + t.Parallel() + g := NewWithT(t) + + var result map[WAFBundleKey]*WAFBundleData + + if test.fetcherBehavior == nil { + result = fetchWAFPolicyBundleData(test.processedPolicies) + } else { + fetcherFactory := func(_ ...fetch.Option) fetch.Fetcher { + fakeFetcher := &fetchfakes.FakeFetcher{} + fakeFetcher.GetRemoteFileStub = func(url string) ([]byte, error) { + if err, exists := test.fetcherBehavior[url]; exists { + if err != nil { + return nil, err + } + return []byte(fmt.Sprintf("bundle data for %s", url)), nil + } + return nil, fmt.Errorf("unexpected URL: %s", url) + } + return fakeFetcher + } + result = fetchWAFPolicyBundleData(test.processedPolicies, fetcherFactory) + } + + if test.expectedBundleCount == 0 { + g.Expect(result).To(BeNil()) + } else { + g.Expect(result).ToNot(BeNil()) + g.Expect(result).To(HaveLen(test.expectedBundleCount)) + for _, bundleData := range result { + g.Expect(bundleData).ToNot(BeNil()) + g.Expect(*bundleData).ToNot(BeEmpty()) + } + } + + for policyName, expectedValid := range test.expectedPolicyState { + found := false + for _, policy := range test.processedPolicies { + if policy.Source.GetName() == policyName { + found = true + g.Expect(policy.Valid).To(Equal(expectedValid), + fmt.Sprintf("Policy %s should have Valid=%v", policyName, expectedValid)) + + if expectFetchConditions, exists := test.expectFetchConditions[policyName]; exists && expectFetchConditions { + g.Expect(policy.Conditions).ToNot(BeEmpty(), + fmt.Sprintf("Policy %s should have fetch error conditions", policyName)) + g.Expect(policy.Conditions[0].Reason).To(Equal("Invalid")) + g.Expect(policy.Conditions[0].Message).To(ContainSubstring("Error fetching policy:")) + } + break + } + } + g.Expect(found).To(BeTrue(), fmt.Sprintf("Policy %s not found", policyName)) + } + }) + } +} + +func TestBuildFetchOptions(t *testing.T) { + t.Parallel() + + tests := []struct { + policySource *ngfAPIv1alpha1.WAFPolicySource + name string + description string + expectedCount int + }{ + { + name: "empty policy source", + policySource: &ngfAPIv1alpha1.WAFPolicySource{}, + expectedCount: 0, + description: "Should return empty options for empty policy source", + }, + { + name: "timeout option", + policySource: &ngfAPIv1alpha1.WAFPolicySource{ + Timeout: helpers.GetPointer(ngfAPIv1alpha1.Duration("30s")), + }, + expectedCount: 1, + description: "Should create timeout option", + }, + { + name: "checksum validation", + policySource: &ngfAPIv1alpha1.WAFPolicySource{ + Validation: &ngfAPIv1alpha1.WAFPolicyValidation{ + Methods: []ngfAPIv1alpha1.WAFPolicyValidationMethod{ngfAPIv1alpha1.WAFPolicyValidationChecksum}, + }, + }, + expectedCount: 1, + description: "Should create checksum validation option", + }, + { + name: "checksum with custom location", + policySource: &ngfAPIv1alpha1.WAFPolicySource{ + Validation: &ngfAPIv1alpha1.WAFPolicyValidation{ + Methods: []ngfAPIv1alpha1.WAFPolicyValidationMethod{ngfAPIv1alpha1.WAFPolicyValidationChecksum}, + }, + Polling: &ngfAPIv1alpha1.WAFPolicyPolling{ + ChecksumLocation: helpers.GetPointer("http://example.com/checksums"), + }, + }, + expectedCount: 1, + description: "Should create checksum validation option with custom location", + }, + { + name: "retry attempts", + policySource: &ngfAPIv1alpha1.WAFPolicySource{ + Retry: &ngfAPIv1alpha1.WAFPolicyRetry{ + Attempts: helpers.GetPointer[int32](3), + }, + }, + expectedCount: 1, + description: "Should create retry attempts option", + }, + { + name: "exponential backoff", + policySource: &ngfAPIv1alpha1.WAFPolicySource{ + Retry: &ngfAPIv1alpha1.WAFPolicyRetry{ + Backoff: helpers.GetPointer(ngfAPIv1alpha1.WAFPolicyRetryBackoffExponential), + }, + }, + expectedCount: 1, + description: "Should create exponential backoff option", + }, + { + name: "linear backoff", + policySource: &ngfAPIv1alpha1.WAFPolicySource{ + Retry: &ngfAPIv1alpha1.WAFPolicyRetry{ + Backoff: helpers.GetPointer(ngfAPIv1alpha1.WAFPolicyRetryBackoffLinear), + }, + }, + expectedCount: 1, + description: "Should create linear backoff option", + }, + { + name: "max delay", + policySource: &ngfAPIv1alpha1.WAFPolicySource{ + Retry: &ngfAPIv1alpha1.WAFPolicyRetry{ + MaxDelay: helpers.GetPointer(ngfAPIv1alpha1.Duration("2m")), + }, + }, + expectedCount: 1, + description: "Should create max delay option", + }, + { + name: "all options combined", + policySource: &ngfAPIv1alpha1.WAFPolicySource{ + Timeout: helpers.GetPointer(ngfAPIv1alpha1.Duration("60s")), + Validation: &ngfAPIv1alpha1.WAFPolicyValidation{ + Methods: []ngfAPIv1alpha1.WAFPolicyValidationMethod{ngfAPIv1alpha1.WAFPolicyValidationChecksum}, + }, + Retry: &ngfAPIv1alpha1.WAFPolicyRetry{ + Attempts: helpers.GetPointer[int32](3), + Backoff: helpers.GetPointer(ngfAPIv1alpha1.WAFPolicyRetryBackoffExponential), + MaxDelay: helpers.GetPointer(ngfAPIv1alpha1.Duration("30s")), + }, + }, + expectedCount: 5, + description: "Should create all options when fully configured", + }, + { + name: "invalid timeout ignored", + policySource: &ngfAPIv1alpha1.WAFPolicySource{ + Timeout: helpers.GetPointer(ngfAPIv1alpha1.Duration("invalid-duration")), + Retry: &ngfAPIv1alpha1.WAFPolicyRetry{ + Attempts: helpers.GetPointer[int32](2), + }, + }, + expectedCount: 1, + description: "Should ignore invalid timeout", + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + t.Parallel() + g := NewWithT(t) + + options := buildFetchOptions(test.policySource) + g.Expect(options).To(HaveLen(test.expectedCount), test.description) + }) + } +} + +func TestParseDurationString(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + input string + description string + expectedSec int64 + expectError bool + }{ + { + name: "empty string", + input: "", + expectedSec: 0, + expectError: false, + description: "Should return zero duration for empty string", + }, + { + name: "numeric string assumes seconds", + input: "30", + expectedSec: 30, + expectError: false, + description: "Should parse numeric string as seconds", + }, + { + name: "standard Go duration", + input: "2m30s", + expectedSec: 150, + expectError: false, + description: "Should parse standard Go duration", + }, + { + name: "invalid duration string", + input: "invalid", + expectedSec: -1, + expectError: true, + description: "Should return error for invalid duration string", + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + t.Parallel() + g := NewWithT(t) + + result, err := parseDurationString(test.input) + + if test.expectError { + g.Expect(err).To(HaveOccurred(), test.description) + } else { + g.Expect(err).ToNot(HaveOccurred(), test.description) + g.Expect(result.Seconds()).To(Equal(float64(test.expectedSec)), test.description) + } + }) + } +} diff --git a/internal/framework/helpers/helpers.go b/internal/framework/helpers/helpers.go index 15e51aa9ac..3ac3cf5482 100644 --- a/internal/framework/helpers/helpers.go +++ b/internal/framework/helpers/helpers.go @@ -3,6 +3,8 @@ package helpers import ( "bytes" + "crypto/sha256" + "encoding/base64" "fmt" "text/template" @@ -87,3 +89,10 @@ func MustExecuteTemplate(templ *template.Template, data interface{}) []byte { return buf.Bytes() } + +// ToSafeFileName converts any string to a filesystem-safe filename using SHA256 hash. +func ToSafeFileName(input string) string { + hasher := sha256.New() + hasher.Write([]byte(input)) + return base64.URLEncoding.EncodeToString(hasher.Sum(nil)) +} diff --git a/internal/framework/kinds/kinds.go b/internal/framework/kinds/kinds.go index baeda6f9ee..79e6faaf5a 100644 --- a/internal/framework/kinds/kinds.go +++ b/internal/framework/kinds/kinds.go @@ -41,6 +41,8 @@ const ( SnippetsFilter = "SnippetsFilter" // UpstreamSettingsPolicy is the UpstreamSettingsPolicy kind. UpstreamSettingsPolicy = "UpstreamSettingsPolicy" + // WAFPolicy is the WAFPolicy kind. + WAFPolicy = "WAFPolicy" ) // MustExtractGVK is a function that extracts the GroupVersionKind (GVK) of a client.object.