Skip to content

Commit

Permalink
feat: introducing dynamic WAF rules based on authority header (#184)
Browse files Browse the repository at this point in the history
Signed-off-by: zufardhiyaulhaq <[email protected]>
  • Loading branch information
zufardhiyaulhaq authored May 3, 2023
1 parent c6e8edf commit 343bc84
Show file tree
Hide file tree
Showing 7 changed files with 497 additions and 100 deletions.
31 changes: 24 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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",
}
```

Expand All @@ -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",
}
```

Expand Down
39 changes: 29 additions & 10 deletions example/envoy-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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:
Expand Down
17 changes: 11 additions & 6 deletions ftw/envoy-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
46 changes: 32 additions & 14 deletions main_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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().
Expand Down Expand Up @@ -569,13 +569,15 @@ func TestBadRequest(t *testing.T) {
name: "missing path",
reqHdrs: [][2]string{
{":method", "GET"},
{":authority", "localhost"},
},
msg: "Failed to get :path",
},
{
name: "missing method",
reqHdrs: [][2]string{
{":path", "/hello"},
{":authority", "localhost"},
},
msg: "Failed to get :method",
},
Expand All @@ -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()
Expand All @@ -609,13 +613,20 @@ func TestBadRequest(t *testing.T) {
func TestBadResponse(t *testing.T) {
tests := []struct {
name string
reqHdrs [][2]string
respHdrs [][2]string
msg string
}{
{
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",
},
Expand All @@ -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()
Expand All @@ -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)

Expand All @@ -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()
Expand All @@ -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)
Expand Down Expand Up @@ -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().
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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().
Expand Down Expand Up @@ -882,6 +899,7 @@ func TestRetrieveAddressInfo(t *testing.T) {
reqHdrs := [][2]string{
{":path", "/hello"},
{":method", "GET"},
{":authority", "localhost"},
}
testCases := []struct {
name string
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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.
Expand Down
61 changes: 57 additions & 4 deletions wasmplugin/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -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{}

Expand All @@ -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
})

Expand All @@ -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
}
Loading

0 comments on commit 343bc84

Please sign in to comment.