diff --git a/deployments/common/crds/k8s.nginx.org_policies.yaml b/deployments/common/crds/k8s.nginx.org_policies.yaml index 8ca5fd57a2..20fe9187b9 100644 --- a/deployments/common/crds/k8s.nginx.org_policies.yaml +++ b/deployments/common/crds/k8s.nginx.org_policies.yaml @@ -160,6 +160,8 @@ spec: description: WAF defines an WAF policy. type: object properties: + apBundle: + type: string apPolicy: type: string enable: diff --git a/deployments/helm-chart/crds/k8s.nginx.org_policies.yaml b/deployments/helm-chart/crds/k8s.nginx.org_policies.yaml index 8ca5fd57a2..20fe9187b9 100644 --- a/deployments/helm-chart/crds/k8s.nginx.org_policies.yaml +++ b/deployments/helm-chart/crds/k8s.nginx.org_policies.yaml @@ -160,6 +160,8 @@ spec: description: WAF defines an WAF policy. type: object properties: + apBundle: + type: string apPolicy: type: string enable: diff --git a/internal/configs/configurator.go b/internal/configs/configurator.go index bf398cc044..236c78e649 100644 --- a/internal/configs/configurator.go +++ b/internal/configs/configurator.go @@ -32,6 +32,7 @@ import ( const ( pemFileNameForWildcardTLSSecret = "/etc/nginx/secrets/wildcard" // #nosec G101 + appProtectBundleFolder = "/etc/nginx/waf/bundles/" appProtectPolicyFolder = "/etc/nginx/waf/nac-policies/" appProtectLogConfFolder = "/etc/nginx/waf/nac-logconfs/" appProtectUserSigFolder = "/etc/nginx/waf/nac-usersigs/" diff --git a/internal/configs/version2/http.go b/internal/configs/version2/http.go index 325dafba7a..0f068dac4e 100644 --- a/internal/configs/version2/http.go +++ b/internal/configs/version2/http.go @@ -126,6 +126,7 @@ type OIDC struct { type WAF struct { Enable string ApPolicy string + ApBundle string ApSecurityLogEnable bool ApLogConf []string } diff --git a/internal/configs/version2/nginx-plus.virtualserver.tmpl b/internal/configs/version2/nginx-plus.virtualserver.tmpl index 3936fafd5d..d1aa9cf92d 100644 --- a/internal/configs/version2/nginx-plus.virtualserver.tmpl +++ b/internal/configs/version2/nginx-plus.virtualserver.tmpl @@ -225,6 +225,10 @@ server { app_protect_policy_file {{ .ApPolicy }}; {{ end }} + {{ if .ApBundle }} + app_protect_policy_file {{ .ApBundle }}; + {{ end }} + {{ if .ApSecurityLogEnable }} app_protect_security_log_enable on; {{ range $logconf := .ApLogConf }} @@ -429,6 +433,10 @@ server { app_protect_policy_file {{ .ApPolicy }}; {{ end }} + {{ if .ApBundle }} + app_protect_policy_file {{ .ApBundle }}; + {{ end }} + {{ if .ApSecurityLogEnable }} app_protect_security_log_enable on; {{ range $logconf := .ApLogConf }} diff --git a/internal/configs/version2/templates_test.go b/internal/configs/version2/templates_test.go index 6c1f9aad84..534d182ed6 100644 --- a/internal/configs/version2/templates_test.go +++ b/internal/configs/version2/templates_test.go @@ -356,6 +356,351 @@ var virtualServerCfg = VirtualServerConfig{ }, } +var virtualServerCfgWithWAFApBundle = VirtualServerConfig{ + LimitReqZones: []LimitReqZone{ + { + ZoneName: "pol_rl_test_test_test", Rate: "10r/s", ZoneSize: "10m", Key: "$url", + }, + }, + Upstreams: []Upstream{ + { + Name: "test-upstream", + Servers: []UpstreamServer{ + { + Address: "10.0.0.20:8001", + }, + }, + LBMethod: "random", + Keepalive: 32, + MaxFails: 4, + FailTimeout: "10s", + MaxConns: 31, + SlowStart: "10s", + UpstreamZoneSize: "256k", + Queue: &Queue{Size: 10, Timeout: "60s"}, + SessionCookie: &SessionCookie{Enable: true, Name: "test", Path: "/tea", Expires: "25s"}, + NTLM: true, + }, + { + Name: "coffee-v1", + Servers: []UpstreamServer{ + { + Address: "10.0.0.31:8001", + }, + }, + MaxFails: 8, + FailTimeout: "15s", + MaxConns: 2, + UpstreamZoneSize: "256k", + }, + { + Name: "coffee-v2", + Servers: []UpstreamServer{ + { + Address: "10.0.0.32:8001", + }, + }, + MaxFails: 12, + FailTimeout: "20s", + MaxConns: 4, + UpstreamZoneSize: "256k", + }, + }, + SplitClients: []SplitClient{ + { + Source: "$request_id", + Variable: "$split_0", + Distributions: []Distribution{ + { + Weight: "50%", + Value: "@loc0", + }, + { + Weight: "50%", + Value: "@loc1", + }, + }, + }, + }, + Maps: []Map{ + { + Source: "$match_0_0", + Variable: "$match", + Parameters: []Parameter{ + { + Value: "~^1", + Result: "@match_loc_0", + }, + { + Value: "default", + Result: "@match_loc_default", + }, + }, + }, + { + Source: "$http_x_version", + Variable: "$match_0_0", + Parameters: []Parameter{ + { + Value: "v2", + Result: "1", + }, + { + Value: "default", + Result: "0", + }, + }, + }, + }, + HTTPSnippets: []string{"# HTTP snippet"}, + Server: Server{ + ServerName: "example.com", + StatusZone: "example.com", + ProxyProtocol: true, + SSL: &SSL{ + HTTP2: true, + Certificate: "cafe-secret.pem", + CertificateKey: "cafe-secret.pem", + }, + TLSRedirect: &TLSRedirect{ + BasedOn: "$scheme", + Code: 301, + }, + ServerTokens: "off", + SetRealIPFrom: []string{"0.0.0.0/0"}, + RealIPHeader: "X-Real-IP", + RealIPRecursive: true, + Allow: []string{"127.0.0.1"}, + Deny: []string{"127.0.0.1"}, + LimitReqs: []LimitReq{ + { + ZoneName: "pol_rl_test_test_test", + Delay: 10, + Burst: 5, + }, + }, + LimitReqOptions: LimitReqOptions{ + LogLevel: "error", + RejectCode: 503, + }, + JWTAuth: &JWTAuth{ + Realm: "My Api", + Secret: "jwk-secret", + }, + IngressMTLS: &IngressMTLS{ + ClientCert: "ingress-mtls-secret", + VerifyClient: "on", + VerifyDepth: 2, + }, + WAF: &WAF{ + ApBundle: "/etc/nginx/waf/bundles/NginxDefaultPolicy.tgz", + ApSecurityLogEnable: true, + ApLogConf: []string{"/etc/nginx/waf/nac-logconfs/default-logconf"}, + }, + Snippets: []string{"# server snippet"}, + InternalRedirectLocations: []InternalRedirectLocation{ + { + Path: "/split", + Destination: "@split_0", + }, + { + Path: "/coffee", + Destination: "@match", + }, + }, + HealthChecks: []HealthCheck{ + { + Name: "coffee", + URI: "/", + Interval: "5s", + Jitter: "0s", + Fails: 1, + Passes: 1, + Port: 50, + ProxyPass: "http://coffee-v2", + Mandatory: true, + Persistent: true, + }, + { + Name: "tea", + Interval: "5s", + Jitter: "0s", + Fails: 1, + Passes: 1, + Port: 50, + ProxyPass: "http://tea-v2", + GRPCPass: "grpc://tea-v3", + GRPCStatus: createPointerFromInt(12), + GRPCService: "tea-servicev2", + }, + }, + Locations: []Location{ + { + Path: "/", + Snippets: []string{"# location snippet"}, + Allow: []string{"127.0.0.1"}, + Deny: []string{"127.0.0.1"}, + LimitReqs: []LimitReq{ + { + ZoneName: "loc_pol_rl_test_test_test", + }, + }, + ProxyConnectTimeout: "30s", + ProxyReadTimeout: "31s", + ProxySendTimeout: "32s", + ClientMaxBodySize: "1m", + ProxyBuffering: true, + ProxyBuffers: "8 4k", + ProxyBufferSize: "4k", + ProxyMaxTempFileSize: "1024m", + ProxyPass: "http://test-upstream", + ProxyNextUpstream: "error timeout", + ProxyNextUpstreamTimeout: "5s", + Internal: true, + ProxyPassRequestHeaders: false, + ProxyPassHeaders: []string{"Host"}, + ProxyPassRewrite: "$request_uri", + ProxyHideHeaders: []string{"Header"}, + ProxyIgnoreHeaders: "Cache", + Rewrites: []string{"$request_uri $request_uri", "$request_uri $request_uri"}, + AddHeaders: []AddHeader{ + { + Header: Header{ + Name: "Header-Name", + Value: "Header Value", + }, + Always: true, + }, + }, + EgressMTLS: &EgressMTLS{ + Certificate: "egress-mtls-secret.pem", + CertificateKey: "egress-mtls-secret.pem", + VerifyServer: true, + VerifyDepth: 1, + Ciphers: "DEFAULT", + Protocols: "TLSv1.3", + TrustedCert: "trusted-cert.pem", + SessionReuse: true, + ServerName: true, + }, + }, + { + Path: "@loc0", + ProxyConnectTimeout: "30s", + ProxyReadTimeout: "31s", + ProxySendTimeout: "32s", + ClientMaxBodySize: "1m", + ProxyPass: "http://coffee-v1", + ProxyNextUpstream: "error timeout", + ProxyNextUpstreamTimeout: "5s", + ProxyInterceptErrors: true, + ErrorPages: []ErrorPage{ + { + Name: "@error_page_1", + Codes: "400 500", + ResponseCode: 200, + }, + { + Name: "@error_page_2", + Codes: "500", + ResponseCode: 0, + }, + }, + }, + { + Path: "@loc1", + ProxyConnectTimeout: "30s", + ProxyReadTimeout: "31s", + ProxySendTimeout: "32s", + ClientMaxBodySize: "1m", + ProxyPass: "http://coffee-v2", + ProxyNextUpstream: "error timeout", + ProxyNextUpstreamTimeout: "5s", + }, + { + Path: "@loc2", + ProxyConnectTimeout: "30s", + ProxyReadTimeout: "31s", + ProxySendTimeout: "32s", + ClientMaxBodySize: "1m", + ProxyPass: "http://coffee-v2", + GRPCPass: "grpc://coffee-v3", + }, + { + Path: "@match_loc_0", + ProxyConnectTimeout: "30s", + ProxyReadTimeout: "31s", + ProxySendTimeout: "32s", + ClientMaxBodySize: "1m", + ProxyPass: "http://coffee-v2", + ProxyNextUpstream: "error timeout", + ProxyNextUpstreamTimeout: "5s", + }, + { + Path: "@match_loc_default", + ProxyConnectTimeout: "30s", + ProxyReadTimeout: "31s", + ProxySendTimeout: "32s", + ClientMaxBodySize: "1m", + ProxyPass: "http://coffee-v1", + ProxyNextUpstream: "error timeout", + ProxyNextUpstreamTimeout: "5s", + }, + { + Path: "/return", + ProxyInterceptErrors: true, + ErrorPages: []ErrorPage{ + { + Name: "@return_0", + Codes: "418", + ResponseCode: 200, + }, + }, + InternalProxyPass: "http://unix:/var/lib/nginx/nginx-418-server.sock", + }, + }, + ErrorPageLocations: []ErrorPageLocation{ + { + Name: "@vs_cafe_cafe_vsr_tea_tea_tea__tea_error_page_0", + DefaultType: "application/json", + Return: &Return{ + Code: 200, + Text: "Hello World", + }, + Headers: nil, + }, + { + Name: "@vs_cafe_cafe_vsr_tea_tea_tea__tea_error_page_1", + DefaultType: "", + Return: &Return{ + Code: 200, + Text: "Hello World", + }, + Headers: []Header{ + { + Name: "Set-Cookie", + Value: "cookie1=test", + }, + { + Name: "Set-Cookie", + Value: "cookie2=test; Secure", + }, + }, + }, + }, + ReturnLocations: []ReturnLocation{ + { + Name: "@return_0", + DefaultType: "text/html", + Return: Return{ + Code: 200, + Text: "Hello!", + }, + }, + }, + }, +} + var transportServerCfg = TransportServerConfig{ Upstreams: []StreamUpstream{ { @@ -454,9 +799,22 @@ func TestVirtualServerForNginxPlus(t *testing.T) { data, err := executor.ExecuteVirtualServerTemplate(&virtualServerCfg) if err != nil { - t.Fatalf("Failed to execute template: %v", err) + t.Errorf("Failed to execute template: %v", err) + } + t.Log(string(data)) +} + +func TestVirtualServerForNginxPlusWithWAFApBundle(t *testing.T) { + t.Parallel() + executor, err := NewTemplateExecutor(nginxPlusVirtualServerTmpl, nginxPlusTransportServerTmpl) + if err != nil { + t.Fatalf("Failed to create template executor: %v", err) } + data, err := executor.ExecuteVirtualServerTemplate(&virtualServerCfgWithWAFApBundle) + if err != nil { + t.Errorf("Failed to execute template: %v", err) + } t.Log(string(data)) } @@ -469,9 +827,8 @@ func TestVirtualServerForNginx(t *testing.T) { data, err := executor.ExecuteVirtualServerTemplate(&virtualServerCfg) if err != nil { - t.Fatalf("Failed to execute template: %v", err) + t.Errorf("Failed to execute template: %v", err) } - t.Log(string(data)) } @@ -484,9 +841,8 @@ func TestTransportServerForNginxPlus(t *testing.T) { data, err := executor.ExecuteTransportServerTemplate(&transportServerCfg) if err != nil { - t.Fatalf("Failed to execute template: %v", err) + t.Errorf("Failed to execute template: %v", err) } - t.Log(string(data)) } @@ -511,9 +867,8 @@ func TestTransportServerForNginx(t *testing.T) { data, err := executor.ExecuteTransportServerTemplate(&transportServerCfg) if err != nil { - t.Fatalf("Failed to execute template: %v", err) + t.Errorf("Failed to execute template: %v", err) } - t.Log(string(data)) } @@ -530,8 +885,7 @@ func TestTLSPassthroughHosts(t *testing.T) { data, err := executor.ExecuteTLSPassthroughHostsTemplate(&unixSocketsCfg) if err != nil { - t.Fatalf("Failed to execute template: %v", err) + t.Errorf("Failed to execute template: %v", err) } - t.Log(string(data)) } diff --git a/internal/configs/virtualserver.go b/internal/configs/virtualserver.go index 83dbb7030f..0a245dca8a 100644 --- a/internal/configs/virtualserver.go +++ b/internal/configs/virtualserver.go @@ -1104,6 +1104,10 @@ func (p *policiesCfg) addWAFConfig( } } + if waf.ApBundle != "" { + p.WAF.ApBundle = appProtectBundleFolder + waf.ApBundle + } + if waf.SecurityLog != nil && waf.SecurityLogs == nil { glog.V(2).Info("the field securityLog is deprecated nad will be removed in future releases. Use field securityLogs instead") p.WAF.ApSecurityLogEnable = true diff --git a/internal/configs/virtualserver_test.go b/internal/configs/virtualserver_test.go index 85c57f51a9..56b291d119 100644 --- a/internal/configs/virtualserver_test.go +++ b/internal/configs/virtualserver_test.go @@ -3201,6 +3201,55 @@ func TestGeneratePolicies(t *testing.T) { } } +func TestGeneratePolicies_GeneratesWAFPolicyOnValidApBundle(t *testing.T) { + t.Parallel() + + ownerDetails := policyOwnerDetails{ + owner: nil, // nil is OK for the unit test + ownerNamespace: "default", + vsNamespace: "default", + vsName: "test", + } + + test := struct { + policyRefs []conf_v1.PolicyReference + policies map[string]*conf_v1.Policy + policyOpts policyOptions + context string + want policiesCfg + }{ + policyRefs: []conf_v1.PolicyReference{ + { + Name: "waf-bundle", + Namespace: "default", + }, + }, + policies: map[string]*conf_v1.Policy{ + "default/waf-bundle": { + Spec: conf_v1.PolicySpec{ + WAF: &conf_v1.WAF{ + Enable: true, + ApBundle: "testWAFPolicyBundle.tgz", + }, + }, + }, + }, + context: "route", + } + + vsc := newVirtualServerConfigurator(&ConfigParams{}, false, false, &StaticConfigParams{}, false) + want := policiesCfg{ + WAF: &version2.WAF{ + Enable: "on", + ApBundle: "/etc/nginx/waf/bundles/testWAFPolicyBundle.tgz", + }, + } + got := vsc.generatePolicies(ownerDetails, test.policyRefs, test.policies, test.context, policyOptions{}) + if !cmp.Equal(want, got) { + t.Error(cmp.Diff(want, got)) + } +} + func TestGeneratePoliciesFails(t *testing.T) { t.Parallel() ownerDetails := policyOwnerDetails{ diff --git a/pkg/apis/configuration/v1/types.go b/pkg/apis/configuration/v1/types.go index 0c5170b6a6..fa75b33cfa 100644 --- a/pkg/apis/configuration/v1/types.go +++ b/pkg/apis/configuration/v1/types.go @@ -490,6 +490,7 @@ type OIDC struct { type WAF struct { Enable bool `json:"enable"` ApPolicy string `json:"apPolicy"` + ApBundle string `json:"apBundle"` SecurityLog *SecurityLog `json:"securityLog"` SecurityLogs []*SecurityLog `json:"securityLogs"` } diff --git a/pkg/apis/configuration/validation/policy.go b/pkg/apis/configuration/validation/policy.go index 290103e0b9..c822527d1d 100644 --- a/pkg/apis/configuration/validation/policy.go +++ b/pkg/apis/configuration/validation/policy.go @@ -283,12 +283,27 @@ func validateOIDC(oidc *v1.OIDC, fieldPath *field.Path) field.ErrorList { func validateWAF(waf *v1.WAF, fieldPath *field.Path) field.ErrorList { allErrs := field.ErrorList{} + // WAF Policy references either apPolicy or apBundle. + if waf.ApPolicy != "" && waf.ApBundle != "" { + msg := "apPolicy and apBundle fields in the WAF policy are mutually exclusive" + allErrs = append(allErrs, + field.Invalid(fieldPath.Child("apPolicy"), waf.ApPolicy, msg), + field.Invalid(fieldPath.Child("apBundle"), waf.ApBundle, msg), + ) + } + if waf.ApPolicy != "" { for _, msg := range validation.IsQualifiedName(waf.ApPolicy) { allErrs = append(allErrs, field.Invalid(fieldPath.Child("apPolicy"), waf.ApPolicy, msg)) } } + if waf.ApBundle != "" { + for _, msg := range validation.IsQualifiedName(waf.ApBundle) { + allErrs = append(allErrs, field.Invalid(fieldPath.Child("apBundle"), waf.ApBundle, msg)) + } + } + if waf.SecurityLog != nil { allErrs = append(allErrs, validateLogConf(waf.SecurityLog.ApLogConf, waf.SecurityLog.LogDest, fieldPath.Child("securityLog"))...) } diff --git a/pkg/apis/configuration/validation/policy_test.go b/pkg/apis/configuration/validation/policy_test.go index 9b2a360bb4..fb8b44eb89 100644 --- a/pkg/apis/configuration/validation/policy_test.go +++ b/pkg/apis/configuration/validation/policy_test.go @@ -1239,7 +1239,66 @@ func TestValidateWAF(t *testing.T) { } } -func TestValidateWAFInvalid(t *testing.T) { +func TestValidateWAF_FailsOnPresentBothApBundleAndApPolicy(t *testing.T) { + t.Parallel() + + waf := &v1.WAF{ + Enable: true, + ApBundle: "bundle.tgz", + ApPolicy: "default/policy_name", + } + + allErrs := validateWAF(waf, field.NewPath("waf")) + if len(allErrs) == 0 { + t.Errorf("want error, got %v", allErrs) + } +} + +func TestValidateWAF_FailsOnInvalidApBundlePath(t *testing.T) { + t.Parallel() + + tt := []struct { + waf *v1.WAF + }{ + { + waf: &v1.WAF{ + ApBundle: ".", + }, + }, + { + waf: &v1.WAF{ + ApBundle: "../bundle.tgz", + }, + }, + { + waf: &v1.WAF{ + ApBundle: "/bundle.tgz", + }, + }, + } + + for _, tc := range tt { + allErrs := validateWAF(tc.waf, field.NewPath("waf")) + if len(allErrs) == 0 { + t.Errorf("want error, got %v", allErrs) + } + } +} + +func TestValidateWAF_PassesOnValidBundleName(t *testing.T) { + t.Parallel() + + waf := &v1.WAF{ + Enable: true, + ApBundle: "ap-bundle.tgz", + } + gotErrors := validateWAF(waf, field.NewPath("waf")) + if len(gotErrors) != 0 { + t.Errorf("want no errors, got %v", gotErrors) + } +} + +func TestValidateWAF_FailsOnInvalidApPolicy(t *testing.T) { t.Parallel() tests := []struct { waf *v1.WAF