Skip to content

Commit

Permalink
feat: adds support for config. (envoyproxy#14)
Browse files Browse the repository at this point in the history
This PR shapes the config to support both inline and embedded rules in
the filter.

```json
{
    "rules": [
        {"inline": "SecRuleEngine On\nSecRule REQUEST_URI \"@Streq /admin\" \"id:101,phase:1,t:lowercase,deny\""},
        {"include": "OWASP_CRS_REQUEST-903.9002-WORDPRESS-EXCLUSION-RULES"}
    ]
}
```

In yaml it would be much nicer:

```yaml
rules:
  - inline: |
        SecRuleEngine On
        SecRule REQUEST_URI "@Streq /admin" "id:101,phase:1,t:lowercase,deny"
  - include: "OWASP_CRS_REQUEST-903.9002-WORDPRESS-EXCLUSION-RULES"
```

Co-authored-by: Anuraag Agrawal <anuraaga@gmail.com>
  • Loading branch information
jcchavezs and anuraaga committed Sep 8, 2022
1 parent ffa3e1f commit c2cd200
Show file tree
Hide file tree
Showing 7 changed files with 345 additions and 116 deletions.
126 changes: 126 additions & 0 deletions config.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
// Copyright 2022 The OWASP Coraza contributors
// SPDX-License-Identifier: Apache-2.0

package main

import (
"bytes"
"errors"
"fmt"
"io"
"io/fs"
"strings"

"github.com/tidwall/gjson"
)

type rule struct {
inline string
include string
}

// pluginConfiguration is a type to represent an example configuration for this wasm plugin.
type pluginConfiguration struct {
rules []rule
}

func parsePluginConfiguration(data []byte) (pluginConfiguration, error) {
config := pluginConfiguration{}

data = bytes.TrimSpace(data)
if len(data) == 0 {
return config, nil
}

if !gjson.ValidBytes(data) {
return config, fmt.Errorf("invalid json: %q", data)
}

jsonData := gjson.ParseBytes(data)
rules := jsonData.Get("rules")
rules.ForEach(func(_, value gjson.Result) bool {
if inline := value.Get("inline"); inline.Exists() {
config.rules = append(config.rules, rule{inline: inline.String()})
return true
} else if include := value.Get("include"); include.Exists() {
config.rules = append(config.rules, rule{include: include.String()})
return true
} else {
return false
}
})

return config, nil
}

func resolveIncludes(rs []rule, crsRules fs.FS) (string, error) {
if len(rs) == 0 {
return "", nil
}

srs := strings.Builder{}
defer srs.Reset()
for _, r := range rs {
switch {
case r.inline != "":
srs.WriteString(strings.TrimSpace(r.inline))

case r.include != "":
if r.include == "OWASP_CRS" {
ors := strings.Builder{}

err := fs.WalkDir(crsRules, ".", func(path string, d fs.DirEntry, err error) error {
if err != nil {
return err
}

if d.IsDir() {
return nil
}

if !strings.HasPrefix(path, "REQUEST-") && !strings.HasPrefix(path, "RESPONSE-") {
return nil
}

f, err := crsRules.Open(path)
if err != nil {
return fmt.Errorf("failed to open embedded rule %q: %s", path, err.Error())
}

fc, err := io.ReadAll(f)
f.Close()
if err != nil {
return fmt.Errorf("failed to read embedded rule file %q: %s", path, err.Error())
}

_, err = ors.Write(bytes.TrimSpace(fc))
return err
})
if err != nil {
return "", fmt.Errorf("failed to walk embedded rules: %s", err.Error())
}

owaspCRSContent := strings.TrimSpace(ors.String())
ors.Reset()
srs.WriteString(owaspCRSContent)
} else {
f, err := crsRules.Open(r.include[len("OWASP_CRS_"):] + ".conf")
if err != nil {
return "", fmt.Errorf("failed to open embedded rule %q: %s", r.include, err.Error())
}
content, err := io.ReadAll(f)
f.Close()
if err != nil {
return "", fmt.Errorf("failed to read embedded rule file: %s", err.Error())
}
content = bytes.TrimSpace(content)
srs.Write(content)
}
default:
return "", errors.New("empty rule")
}
srs.WriteString("\n")
}

return strings.TrimSpace(srs.String()), nil
}
158 changes: 158 additions & 0 deletions config_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,158 @@
// Copyright 2022 The OWASP Coraza contributors
// SPDX-License-Identifier: Apache-2.0

package main

import (
"embed"
"fmt"
"io/fs"
"testing"
)

//go:embed testdata/fake_crs
var fakeCRS embed.FS

func getFakeCRS(t *testing.T) fs.FS {
subCRS, err := fs.Sub(fakeCRS, "testdata/fake_crs")
if err != nil {
t.Fatalf("failed to access CRS filesystem: %s", err.Error())
}
return subCRS
}

func TestResolveIncludesEntireOWASPCRS(t *testing.T) {
rs := []rule{
{
inline: "SecRuleEngine On",
},
{
include: "OWASP_CRS",
},
}

srs, err := resolveIncludes(rs, getFakeCRS(t))
if err != nil {
t.Fatalf("unexpected error: %s", err.Error())
}

expectedRules := `SecRuleEngine On
# just a comment`

if want, have := expectedRules, srs; want != have {
t.Errorf("unexpected rules, want %q, have %q", want, have)
}
}

func TestResolveIncludesSingleCRS(t *testing.T) {
rs := []rule{
{
inline: "SecRuleEngine On",
},
{
include: "OWASP_CRS_REQUEST-911",
},
}
srs, err := resolveIncludes(rs, getFakeCRS(t))
if err != nil {
t.Fatalf("unexpected error: %s", err.Error())
}

expectedRules := `SecRuleEngine On
# just a comment`

if want, have := expectedRules, srs; want != have {
t.Errorf("unexpected rules, want %q, have %q", want, have)
}
}

func TestParsePluginConfiguration(t *testing.T) {
testCases := []struct {
name string
config string
expectErr error
expectConfig pluginConfiguration
}{
{
name: "empty config",
},
{
name: "empty json",
config: "{}",
},
{
name: "inline",
config: `
{
"rules": [
{
"inline": "SecRuleEngine On"
}
]
}
`,
expectConfig: pluginConfiguration{
rules: []rule{
{inline: "SecRuleEngine On"},
},
},
},
{
name: "include",
config: `
{
"rules": [
{
"include": "OWASP_CRS_SOMETHING"
}
]
}
`,
expectConfig: pluginConfiguration{
rules: []rule{
{include: "OWASP_CRS_SOMETHING"},
},
},
},
{
name: "inline & include",
config: `
{
"rules": [
{ "inline": "SecRuleEngine On" },
{
"include": "OWASP_CRS_SOMETHING"
},
{ "inline": "SecRuleEngine Off" }
]
}
`,
expectConfig: pluginConfiguration{
rules: []rule{
{inline: "SecRuleEngine On"},
{include: "OWASP_CRS_SOMETHING"},
{inline: "SecRuleEngine Off"},
},
},
},
}

for _, testCase := range testCases {
t.Run(testCase.name, func(t *testing.T) {
cfg, err := parsePluginConfiguration([]byte(testCase.config))
if want, have := fmt.Sprint(testCase.expectErr), fmt.Sprint(err); want != have {
t.Errorf("unexpected error, want %q, have %q", want, have)
}

if want, have := len(testCase.expectConfig.rules), len(cfg.rules); want != have {
t.Errorf("unexpected number of rules, want %d, have %d", want, have)
}

for i, r := range testCase.expectConfig.rules {
if want, have := r, cfg.rules[i]; want != have {
t.Errorf("unexpected rules, want %q, have %q", want, have)
}
}
})
}
}
5 changes: 3 additions & 2 deletions e2e/envoy-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -31,8 +31,9 @@ static_resources:
"@type": "type.googleapis.com/google.protobuf.StringValue"
value: |
{
"rules":"SecDebugLogLevel 5 \nSecRuleEngine On \nSecRule REQUEST_URI \"@streq /admin\" \"id:101,phase:1,t:lowercase,deny\"",
"include_core_rule_set": false
"rules": [
{"inline": "SecDebugLogLevel 5 \nSecRuleEngine On \nSecRule REQUEST_URI \"@streq /admin\" \"id:101,phase:1,t:lowercase,deny\""}
]
}
vm_config:
runtime: "envoy.wasm.runtime.v8"
Expand Down
7 changes: 5 additions & 2 deletions ftw/envoy-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,10 @@ static_resources:
"@type": "type.googleapis.com/google.protobuf.StringValue"
value: |
{
"rules":"SecRuleEngine On \nSecRule REQUEST_URI \"@streq /admin\" \"id:101,phase:1,t:lowercase,deny\""
"rules": [
{"inline": "SecRuleEngine On \nSecRule REQUEST_URI \"@streq /admin\" \"id:101,phase:1,t:lowercase,deny\""},
{"include": "OWASP_CRS"}
]
}
vm_config:
runtime: "envoy.wasm.runtime.v8"
Expand All @@ -59,4 +62,4 @@ static_resources:
address:
socket_address:
address: httpbin
port_value: 80
port_value: 80
Loading

0 comments on commit c2cd200

Please sign in to comment.