From 343bc84552c1b2514c68f97f15d8e3c1afc04c2a Mon Sep 17 00:00:00 2001 From: Zufar Dhiyaulhaq Date: Wed, 3 May 2023 15:41:39 +0700 Subject: [PATCH] feat: introducing dynamic WAF rules based on authority header (#184) Signed-off-by: zufardhiyaulhaq --- README.md | 31 ++++-- example/envoy-config.yaml | 39 +++++-- ftw/envoy-config.yaml | 17 +-- main_test.go | 46 +++++--- wasmplugin/config.go | 61 ++++++++++- wasmplugin/config_test.go | 184 +++++++++++++++++++++++++++++--- wasmplugin/plugin.go | 219 ++++++++++++++++++++++++++++++-------- 7 files changed, 497 insertions(+), 100 deletions(-) diff --git a/README.md b/README.md index b2b5332..1fc2382 100644 --- a/README.md +++ b/README.md @@ -59,11 +59,14 @@ In order to run the coraza-proxy-wasm we need to spin up an envoy configuration "@type": "type.googleapis.com/google.protobuf.StringValue" value: | { - "rules": [ - "SecDebugLogLevel 9", - "SecRuleEngine On", - "SecRule REQUEST_URI \"@streq /admin\" \"id:101,phase:1,t:lowercase,deny\"" - ] + "directives_map": { + "default": [ + "SecDebugLogLevel 9", + "SecRuleEngine On", + "SecRule REQUEST_URI \"@streq /admin\" \"id:101,phase:1,t:lowercase,deny\"" + ] + }, + "default_directive": "default", } vm_config: runtime: "envoy.wasm.runtime.v8" @@ -84,7 +87,14 @@ configuration: "@type": "type.googleapis.com/google.protobuf.StringValue" value: | { - "rules": [ "SecDebugLogLevel 9", "SecRuleEngine On", "Include @owasp_crs/*.conf" ] + "directives_map": { + "default": [ + "SecDebugLogLevel 9", + "SecRuleEngine On", + "Include @owasp_crs/*.conf" + ] + }, + "default_directive": "default", } ``` @@ -95,7 +105,14 @@ configuration: "@type": "type.googleapis.com/google.protobuf.StringValue" value: | { - "rules": [ "SecDebugLogLevel 9", "SecRuleEngine On", "Include @owasp_crs/REQUEST-901-INITIALIZATION.conf" ] + "directives_map": { + "default": [ + "SecDebugLogLevel 9", + "SecRuleEngine On", + "Include @owasp_crs/REQUEST-901-INITIALIZATION.conf" + ] + }, + "default_directive": "default", } ``` diff --git a/example/envoy-config.yaml b/example/envoy-config.yaml index 5902020..a707a13 100644 --- a/example/envoy-config.yaml +++ b/example/envoy-config.yaml @@ -10,6 +10,8 @@ stats_config: regex: "(_identifier=([0-9a-z.:]+))" - tag_name: owner regex: "(_owner=([0-9a-z.:]+))" + - tag_name: authority + regex: "(_authority=([0-9a-z.:]+))" static_resources: listeners: @@ -51,19 +53,36 @@ static_resources: "@type": "type.googleapis.com/google.protobuf.StringValue" value: | { - "rules": [ - "Include @demo-conf", - "Include @crs-setup-demo-conf", - "SecDefaultAction \"phase:3,log,auditlog,pass\"", - "SecDefaultAction \"phase:4,log,auditlog,pass\"", - "SecDefaultAction \"phase:5,log,auditlog,pass\"", - "SecDebugLogLevel 3", - "Include @owasp_crs/*.conf", - "SecRule REQUEST_URI \"@streq /admin\" \"id:101,phase:1,t:lowercase,deny\" \nSecRule REQUEST_BODY \"@rx maliciouspayload\" \"id:102,phase:2,t:lowercase,deny\" \nSecRule RESPONSE_HEADERS::status \"@rx 406\" \"id:103,phase:3,t:lowercase,deny\" \nSecRule RESPONSE_BODY \"@contains responsebodycode\" \"id:104,phase:4,t:lowercase,deny\"" - ], + "directives_map": { + "rs1": [ + "Include @demo-conf", + "Include @crs-setup-demo-conf", + "SecDefaultAction \"phase:3,log,auditlog,pass\"", + "SecDefaultAction \"phase:4,log,auditlog,pass\"", + "SecDefaultAction \"phase:5,log,auditlog,pass\"", + "SecDebugLogLevel 3", + "Include @owasp_crs/*.conf", + "SecRule REQUEST_URI \"@streq /admin\" \"id:101,phase:1,t:lowercase,deny\" \nSecRule REQUEST_BODY \"@rx maliciouspayload\" \"id:102,phase:2,t:lowercase,deny\" \nSecRule RESPONSE_HEADERS::status \"@rx 406\" \"id:103,phase:3,t:lowercase,deny\" \nSecRule RESPONSE_BODY \"@contains responsebodycode\" \"id:104,phase:4,t:lowercase,deny\"" + ], + "rs2": [ + "Include @demo-conf", + "Include @crs-setup-demo-conf", + "SecDefaultAction \"phase:3,log,auditlog,pass\"", + "SecDefaultAction \"phase:4,log,auditlog,pass\"", + "SecDefaultAction \"phase:5,log,auditlog,pass\"", + "SecDebugLogLevel 3", + "Include @owasp_crs/*.conf", + "SecRule REQUEST_URI \"@streq /example\" \"id:101,phase:1,t:lowercase,deny\" \nSecRule REQUEST_BODY \"@rx maliciouspayload\" \"id:102,phase:2,t:lowercase,deny\" \nSecRule RESPONSE_HEADERS::status \"@rx 406\" \"id:103,phase:3,t:lowercase,deny\" \nSecRule RESPONSE_BODY \"@contains responsebodycode\" \"id:104,phase:4,t:lowercase,deny\"" + ] + }, + "default_directive": "rs1", "metric_labels": { "owner": "coraza", "identifier": "global" + }, + "per_authority_directives":{ + "foo.example.com":"rs2", + "bar.example.com":"rs2" } } vm_config: diff --git a/ftw/envoy-config.yaml b/ftw/envoy-config.yaml index 8ac6a38..730c881 100644 --- a/ftw/envoy-config.yaml +++ b/ftw/envoy-config.yaml @@ -36,12 +36,17 @@ static_resources: # NB: inline rules order matter. Some ftw-config rules override the coraza-recommended default ones. value: | { - "rules": [ - "Include @recommended-conf", - "Include @ftw-conf", - "Include @crs-setup-conf", - "Include @owasp_crs/*.conf" - ] + "directives_map": { + "default": [ + "Include @recommended-conf", + "Include @ftw-conf", + "Include @crs-setup-conf", + "Include @owasp_crs/*.conf" + ] + }, + "default_directive": "default", + "metric_labels": {}, + "per_authority_directives": {} } vm_config: runtime: "envoy.wasm.runtime.v8" diff --git a/main_test.go b/main_test.go index 1d4a3e1..c401a79 100644 --- a/main_test.go +++ b/main_test.go @@ -421,9 +421,9 @@ func TestLifecycle(t *testing.T) { tt := tc t.Run(tt.name, func(t *testing.T) { - conf := `{}` + conf := `{"directives_map": {"default": []}, "default_directive": "default"}` if inlineRules := strings.TrimSpace(tt.inlineRules); inlineRules != "" { - conf = fmt.Sprintf(`{"rules": ["%s"]}`, inlineRules) + conf = fmt.Sprintf(`{"directives_map": {"default": ["%s"]}, "default_directive": "default"}`, inlineRules) } opt := proxytest. NewEmulatorOption(). @@ -569,6 +569,7 @@ func TestBadRequest(t *testing.T) { name: "missing path", reqHdrs: [][2]string{ {":method", "GET"}, + {":authority", "localhost"}, }, msg: "Failed to get :path", }, @@ -576,6 +577,7 @@ func TestBadRequest(t *testing.T) { name: "missing method", reqHdrs: [][2]string{ {":path", "/hello"}, + {":authority", "localhost"}, }, msg: "Failed to get :method", }, @@ -585,9 +587,11 @@ func TestBadRequest(t *testing.T) { for _, tc := range tests { tt := tc t.Run(tt.name, func(t *testing.T) { + conf := `{"directives_map": {"default": []}, "default_directive": "default"}` opt := proxytest. NewEmulatorOption(). - WithVMContext(vm) + WithVMContext(vm). + WithPluginConfiguration([]byte(conf)) host, reset := proxytest.NewHostEmulator(opt) defer reset() @@ -609,6 +613,7 @@ func TestBadRequest(t *testing.T) { func TestBadResponse(t *testing.T) { tests := []struct { name string + reqHdrs [][2]string respHdrs [][2]string msg string }{ @@ -616,6 +621,12 @@ func TestBadResponse(t *testing.T) { name: "missing path", respHdrs: [][2]string{ {"content-length", "12"}, + {":authority", "localhost"}, + }, + reqHdrs: [][2]string{ + {":path", "/hello"}, + {":method", "GET"}, + {":authority", "localhost"}, }, msg: "Failed to get :status", }, @@ -625,9 +636,11 @@ func TestBadResponse(t *testing.T) { for _, tc := range tests { tt := tc t.Run(tt.name, func(t *testing.T) { + conf := `{"directives_map": {"default": []}, "default_directive": "default"}` opt := proxytest. NewEmulatorOption(). - WithVMContext(vm) + WithVMContext(vm). + WithPluginConfiguration([]byte(conf)) host, reset := proxytest.NewHostEmulator(opt) defer reset() @@ -636,6 +649,8 @@ func TestBadResponse(t *testing.T) { id := host.InitializeHttpContext() + host.CallOnRequestHeaders(id, tt.reqHdrs, false) + action := host.CallOnResponseHeaders(id, tt.respHdrs, false) require.Equal(t, types.ActionContinue, action) @@ -651,7 +666,7 @@ func TestEmptyBody(t *testing.T) { opt := proxytest. NewEmulatorOption(). WithVMContext(vm). - WithPluginConfiguration([]byte(`{ "rules": [ "SecRequestBodyAccess On", "SecResponseBodyAccess On" ] }`)) + WithPluginConfiguration([]byte(`{"directives_map": {"default": [ "SecRequestBodyAccess On", "SecResponseBodyAccess On" ]}, "default_directive": "default"}`)) host, reset := proxytest.NewHostEmulator(opt) defer reset() @@ -660,6 +675,12 @@ func TestEmptyBody(t *testing.T) { id := host.InitializeHttpContext() + host.CallOnRequestHeaders(id, [][2]string{ + {":path", "/hello"}, + {":method", "GET"}, + {":authority", "localhost"}, + }, false) + action := host.CallOnRequestBody(id, []byte{}, false) require.Equal(t, types.ActionPause, action) action = host.CallOnRequestBody(id, []byte{}, true) @@ -740,11 +761,7 @@ func TestLogError(t *testing.T) { for _, tc := range tests { tt := tc t.Run(fmt.Sprintf("severity %d", tt.severity), func(t *testing.T) { - conf := fmt.Sprintf(` -{ - "rules" : ["SecRule REQUEST_HEADERS:X-CRS-Test \"@rx ^.*$\" \"id:999999,phase:1,log,severity:%d,msg:'%%{MATCHED_VAR}',pass,t:none\""] -} -`, tt.severity) + conf := fmt.Sprintf(`{"directives_map": {"default": ["SecRule REQUEST_HEADERS:X-CRS-Test \"@rx ^.*$\" \"id:999999,phase:1,log,severity:%d,msg:'%%{MATCHED_VAR}',pass,t:none\""]}, "default_directive": "default"}`, tt.severity) opt := proxytest. NewEmulatorOption(). @@ -772,7 +789,7 @@ func TestParseCRS(t *testing.T) { opt := proxytest. NewEmulatorOption(). WithVMContext(vm). - WithPluginConfiguration([]byte(`{ "rules": [ "Include @ftw-conf", "Include @recommended-conf", "Include @crs-setup-conf", "Include @owasp_crs/*.conf" ] }`)) + WithPluginConfiguration([]byte(`{"directives_map": {"default": [ "Include @ftw-conf", "Include @recommended-conf", "Include @crs-setup-conf", "Include @owasp_crs/*.conf" ]}, "default_directive": "default"}`)) host, reset := proxytest.NewHostEmulator(opt) defer reset() @@ -842,7 +859,7 @@ SecRuleEngine On\nSecRule REQUEST_URI \"@streq /hello\" \"id:101,phase:4,t:lower t.Run(tt.name, func(t *testing.T) { conf := fmt.Sprintf(` - { "rules": ["%s"] } + {"directives_map": {"default": ["%s"]}, "default_directive": "default"} `, strings.TrimSpace(tt.rules)) opt := proxytest. NewEmulatorOption(). @@ -882,6 +899,7 @@ func TestRetrieveAddressInfo(t *testing.T) { reqHdrs := [][2]string{ {":path", "/hello"}, {":method", "GET"}, + {":authority", "localhost"}, } testCases := []struct { name string @@ -949,7 +967,7 @@ func TestRetrieveAddressInfo(t *testing.T) { conf := `{}` if inlineRules := strings.TrimSpace(inlineRules); inlineRules != "" { - conf = fmt.Sprintf(`{"rules": ["%s"]}`, inlineRules) + conf = fmt.Sprintf(`{"directives_map": {"default": ["%s"]}, "default_directive": "default"}`, inlineRules) } t.Run(tt.name, func(t *testing.T) { opt := proxytest. @@ -1012,7 +1030,7 @@ func TestParseServerName(t *testing.T) { conf := `{}` if inlineRules := strings.TrimSpace(inlineRules); inlineRules != "" { - conf = fmt.Sprintf(`{"rules": ["%s"]}`, inlineRules) + conf = fmt.Sprintf(`{"directives_map": {"default": ["%s"]}, "default_directive": "default"}`, inlineRules) } t.Run(name, func(t *testing.T) { opt := proxytest. diff --git a/wasmplugin/config.go b/wasmplugin/config.go index d381d06..3a1eaa6 100644 --- a/wasmplugin/config.go +++ b/wasmplugin/config.go @@ -12,10 +12,14 @@ import ( // pluginConfiguration is a type to represent an example configuration for this wasm plugin. type pluginConfiguration struct { - rules []string - metricLabels map[string]string + directivesMap DirectivesMap + metricLabels map[string]string + defaultDirective string + perAuthorityDirectives map[string]string } +type DirectivesMap map[string][]string + func parsePluginConfiguration(data []byte) (pluginConfiguration, error) { config := pluginConfiguration{} @@ -29,8 +33,20 @@ func parsePluginConfiguration(data []byte) (pluginConfiguration, error) { } jsonData := gjson.ParseBytes(data) - jsonData.Get("rules").ForEach(func(_, rule gjson.Result) bool { - config.rules = append(config.rules, rule.String()) + config.directivesMap = make(DirectivesMap) + jsonData.Get("directives_map").ForEach(func(key, value gjson.Result) bool { + directiveName := key.String() + if _, ok := config.directivesMap[directiveName]; ok { + return true + } + + var directive []string + value.ForEach(func(_, value gjson.Result) bool { + directive = append(directive, value.String()) + return true + }) + + config.directivesMap[directiveName] = directive return true }) @@ -40,5 +56,42 @@ func parsePluginConfiguration(data []byte) (pluginConfiguration, error) { return true }) + defaultDirective := jsonData.Get("default_directive") + if defaultDirective.Exists() { + defaultDirectiveName := defaultDirective.String() + if _, ok := config.directivesMap[defaultDirectiveName]; !ok { + return config, fmt.Errorf("directive map not found for default directive: %q", defaultDirectiveName) + } + + config.defaultDirective = defaultDirectiveName + } + + config.perAuthorityDirectives = make(map[string]string) + jsonData.Get("per_authority_directives").ForEach(func(key, value gjson.Result) bool { + config.perAuthorityDirectives[key.String()] = value.String() + return true + }) + + for authority, directiveName := range config.perAuthorityDirectives { + if _, ok := config.directivesMap[directiveName]; !ok { + return config, fmt.Errorf("directive map not found for authority %s: %q", authority, directiveName) + } + } + + if len(config.directivesMap) == 0 { + rules := jsonData.Get("rules") + + if rules.Exists() { + config.defaultDirective = "default" + + var directive []string + rules.ForEach(func(_, value gjson.Result) bool { + directive = append(directive, value.String()) + return true + }) + config.directivesMap["default"] = directive + } + } + return config, nil } diff --git a/wasmplugin/config_test.go b/wasmplugin/config_test.go index 27d1ca3..e417dc0 100644 --- a/wasmplugin/config_test.go +++ b/wasmplugin/config_test.go @@ -24,8 +24,10 @@ func TestParsePluginConfiguration(t *testing.T) { name: "empty json", config: "{}", expectConfig: pluginConfiguration{ - rules: []string{}, - metricLabels: map[string]string{}, + directivesMap: DirectivesMap{}, + metricLabels: map[string]string{}, + defaultDirective: "", + perAuthorityDirectives: map[string]string{}, }, }, { @@ -37,40 +39,189 @@ func TestParsePluginConfiguration(t *testing.T) { name: "inline", config: ` { - "rules": ["SecRuleEngine On"] + "directives_map": { + "default": ["SecRuleEngine On"] + }, + "default_directive": "default" } `, expectConfig: pluginConfiguration{ - rules: []string{"SecRuleEngine On"}, - metricLabels: map[string]string{}, + directivesMap: DirectivesMap{ + "default": []string{"SecRuleEngine On"}, + }, + metricLabels: map[string]string{}, + defaultDirective: "default", + perAuthorityDirectives: map[string]string{}, }, }, { name: "inline many entries", config: ` - { - "rules": ["SecRuleEngine On", "Include @owasp_crs/*.conf\nSecRule REQUEST_URI \"@streq /admin\" \"id:101,phase:1,t:lowercase,deny\""] + { + "directives_map": { + "default": ["SecRuleEngine On", "Include @owasp_crs/*.conf\nSecRule REQUEST_URI \"@streq /admin\" \"id:101,phase:1,t:lowercase,deny\""] + }, + "default_directive": "default" } `, expectConfig: pluginConfiguration{ - rules: []string{"SecRuleEngine On", "Include @owasp_crs/*.conf\nSecRule REQUEST_URI \"@streq /admin\" \"id:101,phase:1,t:lowercase,deny\""}, - metricLabels: map[string]string{}, + directivesMap: DirectivesMap{ + "default": []string{"SecRuleEngine On", "Include @owasp_crs/*.conf\nSecRule REQUEST_URI \"@streq /admin\" \"id:101,phase:1,t:lowercase,deny\""}, + }, + metricLabels: map[string]string{}, + defaultDirective: "default", + perAuthorityDirectives: map[string]string{}, }, }, { name: "metrics label", config: ` - { - "rules": ["SecRuleEngine On", "Include @owasp_crs/*.conf\nSecRule REQUEST_URI \"@streq /admin\" \"id:101,phase:1,t:lowercase,deny\""], + { + "directives_map": { + "default": ["SecRuleEngine On", "Include @owasp_crs/*.conf\nSecRule REQUEST_URI \"@streq /admin\" \"id:101,phase:1,t:lowercase,deny\""] + }, + "default_directive": "default", "metric_labels": {"owner": "coraza","identifier": "global"} } `, expectConfig: pluginConfiguration{ - rules: []string{"SecRuleEngine On", "Include @owasp_crs/*.conf\nSecRule REQUEST_URI \"@streq /admin\" \"id:101,phase:1,t:lowercase,deny\""}, + directivesMap: DirectivesMap{ + "default": []string{"SecRuleEngine On", "Include @owasp_crs/*.conf\nSecRule REQUEST_URI \"@streq /admin\" \"id:101,phase:1,t:lowercase,deny\""}, + }, metricLabels: map[string]string{ "owner": "coraza", "identifier": "global", }, + defaultDirective: "default", + perAuthorityDirectives: map[string]string{}, + }, + }, + { + name: "multiple directives_map", + config: ` + { + "directives_map": { + "default": ["SecRuleEngine On", "Include @owasp_crs/*.conf\nSecRule REQUEST_URI \"@streq /admin\" \"id:101,phase:1,t:lowercase,deny\""], + "custom-01": ["SecRuleEngine On"], + "custom-02": ["SecRuleEngine On"] + }, + "default_directive": "default", + "metric_labels": {"owner": "coraza","identifier": "global"} + } + `, + expectConfig: pluginConfiguration{ + directivesMap: DirectivesMap{ + "default": []string{"SecRuleEngine On", "Include @owasp_crs/*.conf\nSecRule REQUEST_URI \"@streq /admin\" \"id:101,phase:1,t:lowercase,deny\""}, + "custom-02": []string{"SecRuleEngine On"}, + "custom-01": []string{"SecRuleEngine On"}, + }, + metricLabels: map[string]string{ + "owner": "coraza", + "identifier": "global", + }, + defaultDirective: "default", + perAuthorityDirectives: map[string]string{}, + }, + }, + { + name: "multiple directives_map with authority", + config: ` + { + "directives_map": { + "default": ["SecRuleEngine On", "Include @owasp_crs/*.conf\nSecRule REQUEST_URI \"@streq /admin\" \"id:101,phase:1,t:lowercase,deny\""], + "custom-01": ["SecRuleEngine On"], + "custom-02": ["SecRuleEngine On"] + }, + "default_directive": "default", + "metric_labels": {"owner": "coraza","identifier": "global"}, + "per_authority_directives": { + "mydomain.com":"custom-01", + "mydomain2.com":"custom-02" + } + } + `, + expectConfig: pluginConfiguration{ + directivesMap: DirectivesMap{ + "default": []string{"SecRuleEngine On", "Include @owasp_crs/*.conf\nSecRule REQUEST_URI \"@streq /admin\" \"id:101,phase:1,t:lowercase,deny\""}, + "custom-02": []string{"SecRuleEngine On"}, + "custom-01": []string{"SecRuleEngine On"}, + }, + metricLabels: map[string]string{ + "owner": "coraza", + "identifier": "global", + }, + defaultDirective: "default", + perAuthorityDirectives: map[string]string{ + "mydomain.com": "custom-01", + "mydomain2.com": "custom-02", + }, + }, + }, + { + name: "default directive not found", + config: ` + { + "directives_map": { + "default": ["SecRuleEngine On", "Include @owasp_crs/*.conf\nSecRule REQUEST_URI \"@streq /admin\" \"id:101,phase:1,t:lowercase,deny\""] + }, + "default_directive": "foo" + } + `, + expectErr: errors.New("directive map not found for default directive: \"foo\""), + }, + { + name: "per authority rule set not found", + config: ` + { + "directives_map": { + "default": ["SecRuleEngine On", "Include @owasp_crs/*.conf\nSecRule REQUEST_URI \"@streq /admin\" \"id:101,phase:1,t:lowercase,deny\""], + "custom-01": ["SecRuleEngine On"], + "custom-02": ["SecRuleEngine On"] + }, + "default_directive": "default", + "metric_labels": {"owner": "coraza","identifier": "global"}, + "per_authority_directives": { + "mydomain.com":"custom-01", + "mydomain2.com":"custom-03" + } + } + `, + expectErr: errors.New("directive map not found for authority mydomain2.com: \"custom-03\""), + }, + { + name: "backward compatibility with rules", + config: ` + { + "rules": ["SecRuleEngine On", "Include @owasp_crs/*.conf\nSecRule REQUEST_URI \"@streq /admin\" \"id:101,phase:1,t:lowercase,deny\""] + } + `, + expectConfig: pluginConfiguration{ + directivesMap: DirectivesMap{ + "default": []string{"SecRuleEngine On", "Include @owasp_crs/*.conf\nSecRule REQUEST_URI \"@streq /admin\" \"id:101,phase:1,t:lowercase,deny\""}, + }, + defaultDirective: "default", + metricLabels: map[string]string{}, + perAuthorityDirectives: map[string]string{}, + }, + }, + { + name: "prefer directives instead of rules", + config: ` + { + "rules": ["SecRuleEngine On", "Include @owasp_crs/*.conf\nSecRule REQUEST_URI \"@streq /rules\" \"id:101,phase:1,t:lowercase,deny\""], + "directives_map": { + "foo": ["SecRuleEngine On", "Include @owasp_crs/*.conf\nSecRule REQUEST_URI \"@streq /directives\" \"id:101,phase:1,t:lowercase,deny\""] + }, + "default_directive": "foo" + } + `, + expectConfig: pluginConfiguration{ + directivesMap: DirectivesMap{ + "foo": []string{"SecRuleEngine On", "Include @owasp_crs/*.conf\nSecRule REQUEST_URI \"@streq /directives\" \"id:101,phase:1,t:lowercase,deny\""}, + }, + defaultDirective: "foo", + metricLabels: map[string]string{}, + perAuthorityDirectives: map[string]string{}, }, }, } @@ -79,8 +230,13 @@ func TestParsePluginConfiguration(t *testing.T) { t.Run(testCase.name, func(t *testing.T) { cfg, err := parsePluginConfiguration([]byte(testCase.config)) assert.Equal(t, testCase.expectErr, err) - assert.ElementsMatch(t, testCase.expectConfig.rules, cfg.rules) - assert.Equal(t, testCase.expectConfig.metricLabels, cfg.metricLabels) + + if testCase.expectErr == nil { + assert.Equal(t, testCase.expectConfig.directivesMap, cfg.directivesMap) + assert.Equal(t, testCase.expectConfig.metricLabels, cfg.metricLabels) + assert.Equal(t, testCase.expectConfig.defaultDirective, cfg.defaultDirective) + assert.Equal(t, testCase.expectConfig.perAuthorityDirectives, cfg.perAuthorityDirectives) + } }) } } diff --git a/wasmplugin/plugin.go b/wasmplugin/plugin.go index 3995ab9..128339b 100644 --- a/wasmplugin/plugin.go +++ b/wasmplugin/plugin.go @@ -38,14 +38,41 @@ type corazaPlugin struct { // Embed the default plugin context here, // so that we don't need to reimplement all the methods. types.DefaultPluginContext + wafSets wafSets - waf coraza.WAF + wafDefaultEnabled bool + wafDefaultDirective string - metricLabels map[string]string + perAuthorityWafSets perAuthorityWafSets + metricLabels map[string]string metrics *wafMetrics } +type wafSets []wafSet + +func (wfs wafSets) newWAFSetsHttp(contextID uint32) wafSetsHttp { + var wafSetsHttp wafSetsHttp + + for _, wafSet := range wfs { + var wafSetHttp wafSetHttp + wafSetHttp.tx = wafSet.waf.NewTransaction() + wafSetHttp.logger = wafSet.waf.NewTransaction(). + DebugLogger(). + With(debuglog.Uint("context_id", uint(contextID))) + wafSetHttp.name = wafSet.name + + wafSetsHttp = append(wafSetsHttp, wafSetHttp) + } + + return wafSetsHttp +} + +type wafSet struct { + waf coraza.WAF + name string +} + func (ctx *corazaPlugin) OnPluginStart(pluginConfigurationSize int) types.OnPluginStartStatus { data, err := proxywasm.GetPluginConfiguration() if err != nil && err != types.ErrorStatusNotFound { @@ -58,27 +85,40 @@ func (ctx *corazaPlugin) OnPluginStart(pluginConfigurationSize int) types.OnPlug return types.OnPluginStartStatusFailed } - // First we initialize our waf and our seclang parser - conf := coraza.NewWAFConfig(). - WithErrorCallback(logError). - WithDebugLogger(debuglog.DefaultWithPrinterFactory(logPrinterFactory)). - // TODO(anuraaga): Make this configurable in plugin configuration. - // WithRequestBodyLimit(1024 * 1024 * 1024). - // WithRequestBodyInMemoryLimit(1024 * 1024 * 1024). - // Limit equal to MemoryLimit: TinyGo compilation will prevent - // buffering request body to files anyways. - WithRootFS(root) + var wafSets wafSets + for name, directive := range config.directivesMap { + var wafset wafSet + wafset.name = name - waf, err := coraza.NewWAF(conf.WithDirectives(strings.Join(config.rules, "\n"))) - if err != nil { - proxywasm.LogCriticalf("Failed to parse rules: %v", err) - return types.OnPluginStartStatusFailed + // First we initialize our waf and our seclang parser + conf := coraza.NewWAFConfig(). + WithErrorCallback(logError). + WithDebugLogger(debuglog.DefaultWithPrinterFactory(logPrinterFactory)). + // TODO(anuraaga): Make this configurable in plugin configuration. + // WithRequestBodyLimit(1024 * 1024 * 1024). + // WithRequestBodyInMemoryLimit(1024 * 1024 * 1024). + // Limit equal to MemoryLimit: TinyGo compilation will prevent + // buffering request body to files anyways. + WithRootFS(root) + + waf, err := coraza.NewWAF(conf.WithDirectives(strings.Join(directive, "\n"))) + if err != nil { + proxywasm.LogCriticalf("Failed to parse directive: %v", err) + return types.OnPluginStartStatusFailed + } + + wafset.waf = waf + wafSets = append(wafSets, wafset) } - ctx.waf = waf + if len(config.defaultDirective) != 0 { + ctx.wafDefaultEnabled = true + ctx.wafDefaultDirective = config.defaultDirective + } + ctx.wafSets = wafSets + ctx.perAuthorityWafSets = config.perAuthorityDirectives ctx.metricLabels = config.metricLabels - ctx.metrics = NewWAFMetrics() return types.OnPluginStartStatusOK @@ -86,14 +126,13 @@ func (ctx *corazaPlugin) OnPluginStart(pluginConfigurationSize int) types.OnPlug func (ctx *corazaPlugin) NewHttpContext(contextID uint32) types.HttpContext { return &httpContext{ - contextID: contextID, - tx: ctx.waf.NewTransaction(), - // TODO(jcchavezs): figure out how/when enable/disable metrics - metrics: ctx.metrics, - logger: ctx.waf.NewTransaction(). - DebugLogger(). - With(debuglog.Uint("context_id", uint(contextID))), - metricLabels: ctx.metricLabels, + contextID: contextID, + metrics: ctx.metrics, + metricLabels: ctx.metricLabels, + wafSets: ctx.wafSets.newWAFSetsHttp(contextID), + wafDefaultEnabled: ctx.wafDefaultEnabled, + wafDefaultDirective: ctx.wafDefaultDirective, + perAuthorityWafSets: ctx.perAuthorityWafSets, } } @@ -132,6 +171,10 @@ type httpContext struct { types.DefaultHttpContext contextID uint32 tx ctypes.Transaction + wafSets wafSetsHttp + wafDefaultEnabled bool + wafDefaultDirective string + perAuthorityWafSets perAuthorityWafSets httpProtocol string processedRequestBody bool processedResponseBody bool @@ -142,10 +185,81 @@ type httpContext struct { metricLabels map[string]string } +type perAuthorityWafSets map[string]string + +func (aws perAuthorityWafSets) getdirectivesName(authority string) (string, bool) { + for key, value := range aws { + if key == authority { + return value, true + } + } + + return "", false +} + +type wafSetsHttp []wafSetHttp + +func (wsh wafSetsHttp) getTx(name string) (ctypes.Transaction, bool) { + for _, wafSet := range wsh { + if wafSet.name == name { + return wafSet.tx, true + } + } + + return nil, false +} + +func (wsh wafSetsHttp) getlogger(name string) (debuglog.Logger, bool) { + for _, wafSet := range wsh { + if wafSet.name == name { + return wafSet.logger, true + } + } + + return nil, false +} + +type wafSetHttp struct { + tx ctypes.Transaction + logger debuglog.Logger + name string +} + func (ctx *httpContext) OnHttpRequestHeaders(numHeaders int, endOfStream bool) types.Action { defer logTime("OnHttpRequestHeaders", currentTime()) ctx.metrics.CountTX() + + authority, err := proxywasm.GetHttpRequestHeader(":authority") + if err != nil { + return types.ActionContinue + } + + wafDirectiveName, exist := ctx.perAuthorityWafSets.getdirectivesName(authority) + if !exist && ctx.wafDefaultEnabled { + ctx.tx, exist = ctx.wafSets.getTx(ctx.wafDefaultDirective) + if !exist { + return types.ActionContinue + } + + ctx.logger, exist = ctx.wafSets.getlogger(ctx.wafDefaultDirective) + if !exist { + return types.ActionContinue + } + } else { + ctx.tx, exist = ctx.wafSets.getTx(wafDirectiveName) + if !exist { + return types.ActionContinue + } + + ctx.logger, exist = ctx.wafSets.getlogger(wafDirectiveName) + if !exist { + return types.ActionContinue + } + + ctx.metricLabels["authority"] = authority + } + tx := ctx.tx // This currently relies on Envoy's behavior of mapping all requests to HTTP/2 semantics @@ -201,7 +315,7 @@ func (ctx *httpContext) OnHttpRequestHeaders(numHeaders int, endOfStream bool) t } // CRS rules tend to expect Host even with HTTP/2 - authority, err := proxywasm.GetHttpRequestHeader(":authority") + authority, err = proxywasm.GetHttpRequestHeader(":authority") if err == nil { tx.AddRequestHeader("Host", authority) tx.SetServerName(parseServerName(ctx.logger, authority)) @@ -229,6 +343,10 @@ func (ctx *httpContext) OnHttpRequestBody(bodySize int, endOfStream bool) types. return types.ActionContinue } + if ctx.tx == nil { + return types.ActionContinue + } + tx := ctx.tx if tx.IsRuleEngineOff() { @@ -320,6 +438,10 @@ func (ctx *httpContext) OnHttpResponseHeaders(numHeaders int, endOfStream bool) return types.ActionContinue } + if ctx.tx == nil { + return types.ActionContinue + } + tx := ctx.tx if tx.IsRuleEngineOff() { @@ -389,6 +511,10 @@ func (ctx *httpContext) OnHttpResponseBody(bodySize int, endOfStream bool) types return replaceResponseBodyWhenInterrupted(ctx.logger, bodySize) } + if ctx.tx == nil { + return types.ActionContinue + } + tx := ctx.tx if tx.IsRuleEngineOff() { @@ -465,27 +591,30 @@ func (ctx *httpContext) OnHttpStreamDone() { defer logTime("OnHttpStreamDone", currentTime()) tx := ctx.tx - if !tx.IsRuleEngineOff() && !ctx.interruptedAt.isInterrupted() { - // Responses without body won't call OnHttpResponseBody, but there are rules in the response body - // phase that still need to be executed. If they haven't been executed yet, and there has not been a previous - // interruption, now is the time. - if !ctx.processedResponseBody { - ctx.processedResponseBody = true - _, err := tx.ProcessResponseBody() - if err != nil { - ctx.logger.Error(). - Err(err). - Msg("Failed to process response body") + if tx != nil { + if !tx.IsRuleEngineOff() && !ctx.interruptedAt.isInterrupted() { + // Responses without body won't call OnHttpResponseBody, but there are rules in the response body + // phase that still need to be executed. If they haven't been executed yet, and there has not been a previous + // interruption, now is the time. + if !ctx.processedResponseBody { + ctx.processedResponseBody = true + _, err := tx.ProcessResponseBody() + if err != nil { + ctx.logger.Error(). + Err(err). + Msg("Failed to process response body") + } } } - } - // ProcessLogging is still called even if RuleEngine is off for potential logs generated before the engine is turned off. - // Internally, if the engine is off, no log phase rules are evaluated - ctx.tx.ProcessLogging() - _ = ctx.tx.Close() - ctx.logger.Info().Msg("Finished") - logMemStats() + // ProcessLogging is still called even if RuleEngine is off for potential logs generated before the engine is turned off. + // Internally, if the engine is off, no log phase rules are evaluated + ctx.tx.ProcessLogging() + + _ = ctx.tx.Close() + ctx.logger.Info().Msg("Finished") + logMemStats() + } } const noGRPCStream int32 = -1