Skip to content

Commit baf0b37

Browse files
authored
feat: Implements SecRuleUpdateTargetByTag, extends ByID with ranges (#1020)
* Implements SecRuleUpdateTargetByTag, extends ByTag with ranges * tag might be quoted * nit: space * address review
1 parent dc65d09 commit baf0b37

8 files changed

+240
-13
lines changed

internal/corazawaf/rule.go

+3
Original file line numberDiff line numberDiff line change
@@ -471,6 +471,9 @@ func (r *Rule) AddAction(name string, action plugintypes.Action) error {
471471
// it will be used to match the variable, in case of string it will
472472
// be a fixed match, in case of nil it will match everything
473473
func (r *Rule) AddVariable(v variables.RuleVariable, key string, iscount bool) error {
474+
if r == nil {
475+
return fmt.Errorf("cannot add a variable to an undefined rule")
476+
}
474477
var re *regexp.Regexp
475478
if len(key) > 2 && key[0] == '/' && key[len(key)-1] == '/' {
476479
key = key[1 : len(key)-1]

internal/corazawaf/rulegroup.go

+2-2
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ import (
88
"time"
99

1010
"github.com/corazawaf/coraza/v3/internal/corazatypes"
11-
"github.com/corazawaf/coraza/v3/internal/strings"
11+
utils "github.com/corazawaf/coraza/v3/internal/strings"
1212
"github.com/corazawaf/coraza/v3/types"
1313
"github.com/corazawaf/coraza/v3/types/variables"
1414
)
@@ -102,7 +102,7 @@ func (rg *RuleGroup) DeleteByMsg(msg string) {
102102
func (rg *RuleGroup) DeleteByTag(tag string) {
103103
var kept []Rule
104104
for _, r := range rg.rules {
105-
if !strings.InSlice(tag, r.Tags_) {
105+
if !utils.InSlice(tag, r.Tags_) {
106106
kept = append(kept, r)
107107
}
108108
}

internal/seclang/directives.go

+98-6
Original file line numberDiff line numberDiff line change
@@ -354,6 +354,9 @@ func directiveSecRuleRemoveByID(options *DirectiveOptions) error {
354354

355355
options.WAF.Rules.DeleteByID(id)
356356
} else {
357+
if idx == 0 {
358+
return fmt.Errorf("SecRuleUpdateTargetById: invalid negative id: %s", idOrRange)
359+
}
357360
start, err := strconv.Atoi(idOrRange[:idx])
358361
if err != nil {
359362
return err
@@ -952,22 +955,111 @@ func directiveSecDebugLogLevel(options *DirectiveOptions) error {
952955
return options.WAF.SetDebugLogLevel(debuglog.Level(lvl))
953956
}
954957

958+
// Description: Updates the target (variable) list of the specified rule(s).
959+
// Syntax: SecRuleUpdateTargetById ID TARGET1[|TARGET2|TARGET3]
960+
// ---
961+
// This directive will append variables to the specified rule with the targets provided in the second parameter.
962+
// The rule ID can be single IDs or ranges of IDs. The targets are separated by a pipe character.
955963
func directiveSecRuleUpdateTargetByID(options *DirectiveOptions) error {
956-
idStr, v, ok := strings.Cut(options.Opts, " ")
957-
if !ok {
964+
if len(options.Opts) == 0 {
965+
return errEmptyOptions
966+
}
967+
968+
idsOrRanges := strings.Fields(options.Opts)
969+
length := len(idsOrRanges)
970+
if length < 2 {
958971
return errors.New("syntax error: SecRuleUpdateTargetById id \"VARIABLES\"")
959972
}
960-
id, err := strconv.Atoi(idStr)
961-
if err != nil {
962-
return err
973+
// The last element is expected to be the variable(s)
974+
variables := idsOrRanges[length-1]
975+
for _, idOrRange := range idsOrRanges[:length-1] {
976+
if idx := strings.Index(idOrRange, "-"); idx == -1 {
977+
id, err := strconv.Atoi(idOrRange)
978+
if err != nil {
979+
return err
980+
}
981+
return updateTargetBySingleID(id, variables, options)
982+
} else {
983+
if idx == 0 {
984+
return fmt.Errorf("SecRuleUpdateTargetById: invalid negative id: %s", idOrRange)
985+
}
986+
start, err := strconv.Atoi(idOrRange[:idx])
987+
if err != nil {
988+
return err
989+
}
990+
991+
end, err := strconv.Atoi(idOrRange[idx+1:])
992+
if err != nil {
993+
return err
994+
}
995+
if start == end {
996+
return updateTargetBySingleID(start, variables, options)
997+
}
998+
if start > end {
999+
return fmt.Errorf("invalid range: %s", idOrRange)
1000+
}
1001+
1002+
for _, rule := range options.WAF.Rules.GetRules() {
1003+
if rule.ID_ >= start && rule.ID_ <= end {
1004+
rp := RuleParser{
1005+
rule: &rule,
1006+
options: RuleOptions{},
1007+
defaultActions: map[types.RulePhase][]ruleAction{},
1008+
}
1009+
if err := rp.ParseVariables(strings.Trim(variables, "\"")); err != nil {
1010+
return err
1011+
}
1012+
}
1013+
}
1014+
}
9631015
}
1016+
return nil
1017+
}
1018+
1019+
func updateTargetBySingleID(id int, variables string, options *DirectiveOptions) error {
1020+
9641021
rule := options.WAF.Rules.FindByID(id)
1022+
if rule == nil {
1023+
return fmt.Errorf("SecRuleUpdateTargetById: rule \"%d\" not found", id)
1024+
}
9651025
rp := RuleParser{
9661026
rule: rule,
9671027
options: RuleOptions{},
9681028
defaultActions: map[types.RulePhase][]ruleAction{},
9691029
}
970-
return rp.ParseVariables(strings.Trim(v, "\""))
1030+
return rp.ParseVariables(strings.Trim(variables, "\""))
1031+
}
1032+
1033+
// Description: Updates the target (variable) list of the specified rule(s) by tag.
1034+
// Syntax: SecRuleUpdateTargetByTag TAG TARGET1[|TARGET2|TARGET3]
1035+
// ---
1036+
// As an alternative to `SecRuleUpdateTargetById`, this directive will append variables to the specified rule
1037+
// with the targets provided in the second parameter. It can be handy for updating an entire group of rules.
1038+
// Matching is by case-sensitive string equality.
1039+
// This directive will append variables to the specified rule with the targets provided in the second parameter.
1040+
// The rule ID can be single IDs or ranges of IDs. The targets are separated by a pipe character.
1041+
// Note: OWASP CRS has a list of supported tags https://coreruleset.org/docs/rules/metadata/
1042+
func directiveSecRuleUpdateTargetByTag(options *DirectiveOptions) error {
1043+
tagAndvars := strings.Fields(options.Opts)
1044+
if len(tagAndvars) != 2 {
1045+
return errors.New("syntax error: SecRuleUpdateTargetByTag tag \"VARIABLES\"")
1046+
}
1047+
1048+
for _, rule := range options.WAF.Rules.GetRules() {
1049+
inputTag := strings.Trim(tagAndvars[0], "\"")
1050+
if utils.InSlice(inputTag, rule.Tags_) {
1051+
rp := RuleParser{
1052+
rule: &rule,
1053+
options: RuleOptions{},
1054+
defaultActions: map[types.RulePhase][]ruleAction{},
1055+
}
1056+
inputVars := strings.Trim(tagAndvars[1], "\"")
1057+
if err := rp.ParseVariables(inputVars); err != nil {
1058+
return err
1059+
}
1060+
}
1061+
}
1062+
return nil
9711063
}
9721064

9731065
func directiveSecIgnoreRuleCompilationErrors(options *DirectiveOptions) error {

internal/seclang/directives_test.go

+40-2
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
package seclang
55

66
import (
7+
"regexp"
78
"strings"
89
"testing"
910

@@ -34,7 +35,7 @@ func Test_NonImplementedDirective(t *testing.T) {
3435
}
3536
}
3637

37-
func TestSecRuleUpdateTargetBy(t *testing.T) {
38+
func TestSecRuleUpdateTargetByID(t *testing.T) {
3839
waf := corazawaf.NewWAF()
3940
rule, err := ParseRule(RuleOptions{
4041
Data: "REQUEST_URI \"^/test\" \"id:181,tag:test\"",
@@ -158,6 +159,7 @@ func TestDirectives(t *testing.T) {
158159
},
159160
"SecRuleRemoveByTag": {
160161
{"", expectErrorOnDirective},
162+
{"attack-sqli", expectNoErrorOnDirective},
161163
},
162164
"SecRuleRemoveByMsg": {
163165
{"", expectErrorOnDirective},
@@ -168,10 +170,41 @@ func TestDirectives(t *testing.T) {
168170
{"1-a", expectErrorOnDirective},
169171
{"a-2", expectErrorOnDirective},
170172
{"2-1", expectErrorOnDirective},
173+
{"-1", expectErrorOnDirective},
174+
{"-5--1", expectErrorOnDirective},
175+
{"5--1", expectErrorOnDirective},
171176
{"1", expectNoErrorOnDirective},
172177
{"1 2", expectNoErrorOnDirective},
173178
{"1 2 3-4", expectNoErrorOnDirective},
174179
},
180+
"SecRuleUpdateTargetById": {
181+
{"", expectErrorOnDirective},
182+
{"a", expectErrorOnDirective},
183+
{"1-a", expectErrorOnDirective},
184+
{"a-2", expectErrorOnDirective},
185+
{"2-1", expectErrorOnDirective},
186+
{"1-a \"ARGS:wp_post\"", expectErrorOnDirective},
187+
{"a-2 \"ARGS:wp_post\"", expectErrorOnDirective},
188+
{"2-1 \"ARGS:wp_post\"", expectErrorOnDirective},
189+
{"-1 \"ARGS:wp_post\"", expectErrorOnDirective},
190+
{"-5--1 \"ARGS:wp_post\"", expectErrorOnDirective},
191+
{"5--1 \"ARGS:wp_post\"", expectErrorOnDirective},
192+
// Variables has also to be provided to the directive
193+
{"1", expectErrorOnDirective},
194+
{"1 \"ARGS:wp_post\"", expectNoErrorOnDirective},
195+
{"7-7 \"ARGS:wp_post\"", expectNoErrorOnDirective},
196+
{"1 2 \"ARGS:wp_post\"", expectNoErrorOnDirective},
197+
{"1 2 3-4 \"ARGS:wp_post\"", expectNoErrorOnDirective},
198+
{"1 \"REQUEST_BODY|ARGS:wp_post\"", expectNoErrorOnDirective},
199+
{"1 2 3-4 \"ARGS:wp_post|RESPONSE_HEADERS\"", expectNoErrorOnDirective},
200+
},
201+
"SecRuleUpdateTargetByTag": {
202+
{"", expectErrorOnDirective},
203+
{"a", expectErrorOnDirective},
204+
{"tag-1 \"ARGS:wp_post\"", expectNoErrorOnDirective},
205+
{"tag-1 tag-2 \"ARGS:wp_post\"", expectErrorOnDirective}, // Multiple tags in line is not supported
206+
{"tag-2 \"ARGS:wp_post|RESPONSE_HEADERS|!REQUEST_BODY\"", expectNoErrorOnDirective},
207+
},
175208
"SecResponseBodyMimeTypesClear": {
176209
{"", func(w *corazawaf.WAF) bool { return len(w.ResponseBodyMimeTypes) == 0 }},
177210
{"x", expectErrorOnDirective},
@@ -262,7 +295,12 @@ func TestDirectives(t *testing.T) {
262295
}
263296
} else {
264297
if err != nil {
265-
t.Errorf("unexpected error: %s", err.Error())
298+
match, _ := regexp.MatchString(`rule "\d+" not found`, err.Error())
299+
// Logical errors are not checked by this test, therefore this specific pattern is allowed here
300+
if !match {
301+
// Syntax errors are checked
302+
t.Errorf("unexpected error: %s", err.Error())
303+
}
266304
}
267305

268306
if !tCase.check(waf) {

internal/seclang/directivesmap.gen.go

+2-1
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

internal/seclang/generator/directivesmap.go.tmpl

-1
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,6 @@ var directivesMap = map[string]directive{
1616
// Unsupported directives
1717
"secargumentseparator": directiveUnsupported,
1818
"seccookieformat": directiveUnsupported,
19-
"secruleupdatetargetbytag": directiveUnsupported,
2019
"secruleupdatetargetbymsg": directiveUnsupported,
2120
"secruleupdateactionbyid": directiveUnsupported,
2221
"secrulescript": directiveUnsupported,

internal/seclang/rule_parser_test.go

+1-1
Original file line numberDiff line numberDiff line change
@@ -120,7 +120,7 @@ func TestSecRuleUpdateTargetVariableNegation(t *testing.T) {
120120
SecRule REQUEST_URI|REQUEST_COOKIES "abc" "id:9,phase:2"
121121
SecRuleUpdateTargetById 99 "!REQUEST_HEADERS:xyz"
122122
`)
123-
expectedErr = errors.New("cannot create a variable exception for an undefined rule")
123+
expectedErr = errors.New("SecRuleUpdateTargetById: rule \"99\" not found")
124124
if errors.Unwrap(err).Error() != expectedErr.Error() {
125125
t.Fatalf("unexpected error, want %q, have %q", expectedErr, errors.Unwrap(err).Error())
126126
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
// Copyright 2022 Juan Pablo Tosso and the OWASP Coraza contributors
2+
// SPDX-License-Identifier: Apache-2.0
3+
4+
package engine
5+
6+
import (
7+
"github.com/corazawaf/coraza/v3/testing/profile"
8+
)
9+
10+
var _ = profile.RegisterProfile(profile.Profile{
11+
Meta: profile.Meta{
12+
Author: "M4tteoP",
13+
Description: "Test SecRuleUpdateTarget directives",
14+
Enabled: true,
15+
Name: "SecRuleUpdateTarget.yaml",
16+
},
17+
Tests: []profile.Test{
18+
{
19+
Title: "SecRuleUpdateTarget",
20+
Stages: []profile.Stage{
21+
{
22+
Stage: profile.SubStage{
23+
Input: profile.StageInput{
24+
URI: "/index.php?t1=aaa&t2=bbb",
25+
Method: "POST",
26+
Headers: map[string]string{
27+
"content-type": "application/x-www-form-urlencoded",
28+
"Cookie": "cookie=aaa",
29+
},
30+
},
31+
Output: profile.ExpectedOutput{
32+
TriggeredRules: []int{
33+
20,
34+
30,
35+
50,
36+
62,
37+
51,
38+
},
39+
NonTriggeredRules: []int{
40+
10,
41+
12,
42+
16,
43+
40,
44+
60,
45+
61,
46+
},
47+
},
48+
},
49+
},
50+
},
51+
},
52+
},
53+
Rules: `
54+
# ARGS:t1 is removed by SecRuleUpdateTargetById, rule 10 should not be triggered
55+
SecRule ARGS:t1 "@rx aaa" "id:10,phase:1,log"
56+
SecRuleUpdateTargetById 10 "!ARGS:t1"
57+
58+
# ARGS:t2 is removed by SecRuleUpdateTargetById, rule 12 (in range ids 11-13) should not be triggered
59+
SecRule ARGS:t2 "@rx bbb" "id:12,phase:1,log"
60+
SecRuleUpdateTargetById 11-13 "!ARGS:t2"
61+
62+
# ARGS:t2 is removed by SecRuleUpdateTargetById, rule 16 (in range) should not be triggered
63+
SecRule ARGS:t2 "@rx bbb" "id:16,phase:1,log"
64+
SecRuleUpdateTargetById 13-15 16 18 "!ARGS:t2"
65+
66+
# ARGS:t1 is removed by SecRuleUpdateTargetById, but REQUEST_COOKIES should still trigger rule 20
67+
SecRule ARGS:t1|REQUEST_COOKIES "@rx aaa" "id:20,phase:1,log"
68+
SecRuleUpdateTargetById 20 "!ARGS:t1"
69+
70+
# ARGS:t1 is added by SecRuleUpdateTargetById, it should trigger rule 30
71+
SecRule REQUEST_BODY "@rx aaa" "id:30,phase:1,log"
72+
SecRuleUpdateTargetById 30 "ARGS:t1"
73+
74+
# ARGS:t19999 is added by SecRuleUpdateTargetById, it should not trigger rule 40
75+
SecRule REQUEST_BODY "@rx aaa" "id:40,phase:1,log"
76+
SecRuleUpdateTargetById 40 "ARGS:t19999"
77+
78+
# ARGS:t1 is NOT removed by SecRuleUpdateTargetByTag, rule 50 should be triggered
79+
SecRule ARGS:t1 "@rx aaa" "id:50,phase:1,log,tag:tag-1"
80+
SecRuleUpdateTargetByTag tag-1111 "!ARGS:t1"
81+
82+
# ARGS:t1 is NOT removed by SecRuleUpdateTargetByTag. Because case sensitive matching, rule 51 should be triggered
83+
SecRule ARGS:t1 "@rx aaa" "id:51,phase:1,log,tag:tag-1b"
84+
SecRuleUpdateTargetByTag tAg-1b "!ARGS:t1"
85+
86+
# ARGS:t1 is removed by SecRuleUpdateTargetByTag, rule 60,61 should not be triggered.
87+
SecRule ARGS "@rx aaa" "id:60,phase:1,log,tag:tag-2"
88+
SecRule ARGS:t2 "@rx bbb" "id:61,phase:1,log,tag:tag-2"
89+
SecRule ARGS:t1|REQUEST_COOKIES "@rx aaa" "id:62,phase:1,log,tag:tag-2"
90+
# The tag might also be wrapped in double quotes
91+
SecRuleUpdateTargetByTag "tag-2" "!ARGS:t1|!ARGS:t2"
92+
93+
`,
94+
})

0 commit comments

Comments
 (0)