Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
64 changes: 53 additions & 11 deletions internal/actions/ctl.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ package actions
import (
"errors"
"fmt"
"regexp"
"strconv"
"strings"

Expand Down Expand Up @@ -73,7 +74,13 @@ const (
//
// Here are some notes about the options:
//
// 1. Option `ruleRemoveTargetById`, `ruleRemoveTargetByMsg`, and `ruleRemoveTargetByTag`, users don't need to use the char ! before the target list.
// 1. Option `ruleRemoveTargetById`, `ruleRemoveTargetByMsg`, and `ruleRemoveTargetByTag` accept a collection key in two forms:
// - **Exact string**: `ARGS:user` — removes only the variable whose name is exactly `user`.
// - **Regular expression** (delimited by `/`): `ARGS:/^json\.\d+\.field$/` — removes all variables whose
// names match the pattern. The closing `/` must not be preceded by an odd number of backslashes
// (e.g. `/foo\/` is treated as the literal string `/foo\/`, not a regex). An empty pattern (`//`) is rejected.
// Pattern matching is always case-insensitive because variable names are lowercased before comparison.
// Users do not need to use the `!` character before the target list.
//
// 2. Option `ruleRemoveById` is triggered at run time and should be specified before the rule in which it is disabling.
//
Expand All @@ -99,17 +106,30 @@ const (
// SecRule REQUEST_URI "@beginsWith /index.php" "phase:1,t:none,pass,\
// nolog,ctl:ruleRemoveTargetById=981260;ARGS:user"
//
// # white-list all JSON array fields matching a pattern for rule #932125 when the REQUEST_URI begins with /api/jobs
//
// SecRule REQUEST_URI "@beginsWith /api/jobs" "phase:1,t:none,pass,\
// nolog,ctl:ruleRemoveTargetById=932125;ARGS:/^json\.\d+\.jobdescription$/"
//
// ```
type ctlFn struct {
action ctlFunctionType
value string
collection variables.RuleVariable
colKey string
colKeyRx *regexp.Regexp
}

func (a *ctlFn) Init(_ plugintypes.RuleMetadata, data string) error {
func (a *ctlFn) Init(m plugintypes.RuleMetadata, data string) error {
// Type-assert RuleMetadata to *corazawaf.Rule to access the rule's memoizer.
// When the assertion fails (e.g., in tests using a stub RuleMetadata), the
// memoizer remains nil and regex compilation proceeds without caching.
var memoizer plugintypes.Memoizer
if r, ok := m.(*corazawaf.Rule); ok {
memoizer = r.Memoizer()
}
var err error
a.action, a.value, a.collection, a.colKey, err = parseCtl(data)
a.action, a.value, a.collection, a.colKey, a.colKeyRx, err = parseCtl(data, memoizer)
return err
}

Expand Down Expand Up @@ -140,21 +160,21 @@ func (a *ctlFn) Evaluate(_ plugintypes.RuleMetadata, txS plugintypes.Transaction
}
for _, r := range tx.WAF.Rules.GetRules() {
if r.ID_ >= start && r.ID_ <= end {
tx.RemoveRuleTargetByID(r.ID_, a.collection, a.colKey)
tx.RemoveRuleTargetByID(r.ID_, a.collection, a.colKey, a.colKeyRx)
}
}
case ctlRuleRemoveTargetByTag:
rules := tx.WAF.Rules.GetRules()
for _, r := range rules {
if utils.InSlice(a.value, r.Tags_) {
tx.RemoveRuleTargetByID(r.ID(), a.collection, a.colKey)
tx.RemoveRuleTargetByID(r.ID(), a.collection, a.colKey, a.colKeyRx)
}
}
case ctlRuleRemoveTargetByMsg:
rules := tx.WAF.Rules.GetRules()
for _, r := range rules {
if r.Msg != nil && r.Msg.String() == a.value {
tx.RemoveRuleTargetByID(r.ID(), a.collection, a.colKey)
tx.RemoveRuleTargetByID(r.ID(), a.collection, a.colKey, a.colKeyRx)
}
}
case ctlAuditEngine:
Expand Down Expand Up @@ -375,18 +395,40 @@ func (a *ctlFn) Type() plugintypes.ActionType {
return plugintypes.ActionTypeNondisruptive
}

func parseCtl(data string) (ctlFunctionType, string, variables.RuleVariable, string, error) {
func parseCtl(data string, memoizer plugintypes.Memoizer) (ctlFunctionType, string, variables.RuleVariable, string, *regexp.Regexp, error) {
action, ctlVal, ok := strings.Cut(data, "=")
if !ok {
return ctlUnknown, "", 0, "", errors.New("invalid syntax")
return ctlUnknown, "", 0, "", nil, errors.New("invalid syntax")
}
value, col, ok := strings.Cut(ctlVal, ";")
var colkey, colname string
if ok {
colname, colkey, _ = strings.Cut(col, ":")
colkey = strings.TrimSpace(colkey)
}
collection, _ := variables.Parse(strings.TrimSpace(colname))
colkey = strings.ToLower(colkey)
var keyRx *regexp.Regexp
if isRegex, rxPattern := utils.HasRegex(colkey); isRegex {
if len(rxPattern) == 0 {
return ctlUnknown, "", 0, "", nil, errors.New("empty regex pattern in ctl collection key")
}
var err error
if memoizer != nil {
re, compileErr := memoizer.Do(rxPattern, func() (any, error) { return regexp.Compile(rxPattern) })
if compileErr != nil {
return ctlUnknown, "", 0, "", nil, fmt.Errorf("invalid regex in ctl collection key: %w", compileErr)
}
keyRx = re.(*regexp.Regexp)
} else {
keyRx, err = regexp.Compile(rxPattern)
if err != nil {
return ctlUnknown, "", 0, "", nil, fmt.Errorf("invalid regex in ctl collection key: %w", err)
}
}
colkey = ""
} else {
colkey = strings.ToLower(colkey)
}
var act ctlFunctionType
switch action {
case "auditEngine":
Expand Down Expand Up @@ -430,9 +472,9 @@ func parseCtl(data string) (ctlFunctionType, string, variables.RuleVariable, str
case "debugLogLevel":
act = ctlDebugLogLevel
default:
return ctlUnknown, "", 0x00, "", fmt.Errorf("unknown ctl action %q", action)
return ctlUnknown, "", 0x00, "", nil, fmt.Errorf("unknown ctl action %q", action)
}
return act, value, collection, strings.TrimSpace(colkey), nil
return act, value, collection, colkey, keyRx, nil
}

// parseRange parses a range string of the form "start-end" and returns the start and end
Expand Down
109 changes: 89 additions & 20 deletions internal/actions/ctl_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import (

"github.com/corazawaf/coraza/v3/debuglog"
"github.com/corazawaf/coraza/v3/internal/corazawaf"
"github.com/corazawaf/coraza/v3/internal/memoize"
"github.com/corazawaf/coraza/v3/types"
"github.com/corazawaf/coraza/v3/types/variables"
)
Expand All @@ -32,6 +33,15 @@ func TestCtl(t *testing.T) {
}
},
},
"ruleRemoveTargetById regex key": {
// Rule 1 is in WAF; the regex /^test.*/ should remove matching ARGS targets
input: "ruleRemoveTargetById=1;ARGS:/^test.*/",
checkTX: func(t *testing.T, tx *corazawaf.Transaction, logEntry string) {
if strings.Contains(logEntry, "Invalid") || strings.Contains(logEntry, "invalid") {
t.Errorf("unexpected error in log: %q", logEntry)
}
},
},
"ruleRemoveTargetByTag": {
input: "ruleRemoveTargetByTag=tag1",
},
Expand Down Expand Up @@ -386,7 +396,7 @@ func TestCtl(t *testing.T) {

func TestParseCtl(t *testing.T) {
t.Run("invalid ctl", func(t *testing.T) {
ctl, _, _, _, err := parseCtl("invalid")
ctl, _, _, _, _, err := parseCtl("invalid", nil)
if err == nil {
t.Errorf("expected error, got nil")
}
Expand All @@ -397,7 +407,7 @@ func TestParseCtl(t *testing.T) {
})

t.Run("malformed ctl", func(t *testing.T) {
ctl, _, _, _, err := parseCtl("unknown=")
ctl, _, _, _, _, err := parseCtl("unknown=", nil)
if err == nil {
t.Errorf("expected error, got nil")
}
Expand All @@ -407,35 +417,83 @@ func TestParseCtl(t *testing.T) {
}
})

t.Run("invalid regex in colKey", func(t *testing.T) {
_, _, _, _, _, err := parseCtl("ruleRemoveTargetById=1;ARGS:/[invalid/", nil)
if err == nil {
t.Errorf("expected error for invalid regex, got nil")
}
})

t.Run("empty regex pattern in colKey", func(t *testing.T) {
_, _, _, _, _, err := parseCtl("ruleRemoveTargetById=1;ARGS://", nil)
if err == nil {
t.Errorf("expected error for empty regex pattern, got nil")
}
})

t.Run("escaped slash not treated as regex", func(t *testing.T) {
_, _, _, key, rx, err := parseCtl(`ruleRemoveTargetById=1;ARGS:/user\/`, nil)
if err != nil {
t.Fatalf("unexpected error: %s", err.Error())
}
if rx != nil {
t.Errorf("expected nil regex for escaped-slash key, got: %s", rx.String())
}
if key != `/user\/` {
t.Errorf("unexpected key, want %q, have %q", `/user\/`, key)
}
})

t.Run("memoizer with valid regex", func(t *testing.T) {
m := memoize.NewMemoizer(99)
_, _, _, _, keyRx, err := parseCtl("ruleRemoveTargetById=1;ARGS:/^test.*/", m)
if err != nil {
t.Fatalf("unexpected error: %s", err.Error())
}
if keyRx == nil {
t.Error("expected non-nil compiled regex, got nil")
}
})

t.Run("memoizer with invalid regex", func(t *testing.T) {
m := memoize.NewMemoizer(100)
_, _, _, _, _, err := parseCtl("ruleRemoveTargetById=1;ARGS:/[invalid/", m)
if err == nil {
t.Error("expected error for invalid regex with memoizer, got nil")
}
})

tCases := []struct {
input string
expectAction ctlFunctionType
expectValue string
expectCollection variables.RuleVariable
expectKey string
expectKeyRx string
}{
{"auditEngine=On", ctlAuditEngine, "On", variables.Unknown, ""},
{"auditLogParts=A", ctlAuditLogParts, "A", variables.Unknown, ""},
{"requestBodyAccess=On", ctlRequestBodyAccess, "On", variables.Unknown, ""},
{"requestBodyLimit=100", ctlRequestBodyLimit, "100", variables.Unknown, ""},
{"requestBodyProcessor=JSON", ctlRequestBodyProcessor, "JSON", variables.Unknown, ""},
{"forceRequestBodyVariable=On", ctlForceRequestBodyVariable, "On", variables.Unknown, ""},
{"responseBodyAccess=On", ctlResponseBodyAccess, "On", variables.Unknown, ""},
{"responseBodyLimit=100", ctlResponseBodyLimit, "100", variables.Unknown, ""},
{"responseBodyProcessor=JSON", ctlResponseBodyProcessor, "JSON", variables.Unknown, ""},
{"forceResponseBodyVariable=On", ctlForceResponseBodyVariable, "On", variables.Unknown, ""},
{"ruleEngine=On", ctlRuleEngine, "On", variables.Unknown, ""},
{"ruleRemoveById=1", ctlRuleRemoveByID, "1", variables.Unknown, ""},
{"ruleRemoveById=1-9", ctlRuleRemoveByID, "1-9", variables.Unknown, ""},
{"ruleRemoveByMsg=MY_MSG", ctlRuleRemoveByMsg, "MY_MSG", variables.Unknown, ""},
{"ruleRemoveByTag=MY_TAG", ctlRuleRemoveByTag, "MY_TAG", variables.Unknown, ""},
{"ruleRemoveTargetByMsg=MY_MSG;ARGS:user", ctlRuleRemoveTargetByMsg, "MY_MSG", variables.Args, "user"},
{"ruleRemoveTargetById=2;REQUEST_FILENAME:", ctlRuleRemoveTargetByID, "2", variables.RequestFilename, ""},
{"auditEngine=On", ctlAuditEngine, "On", variables.Unknown, "", ""},
{"auditLogParts=A", ctlAuditLogParts, "A", variables.Unknown, "", ""},
{"requestBodyAccess=On", ctlRequestBodyAccess, "On", variables.Unknown, "", ""},
{"requestBodyLimit=100", ctlRequestBodyLimit, "100", variables.Unknown, "", ""},
{"requestBodyProcessor=JSON", ctlRequestBodyProcessor, "JSON", variables.Unknown, "", ""},
{"forceRequestBodyVariable=On", ctlForceRequestBodyVariable, "On", variables.Unknown, "", ""},
{"responseBodyAccess=On", ctlResponseBodyAccess, "On", variables.Unknown, "", ""},
{"responseBodyLimit=100", ctlResponseBodyLimit, "100", variables.Unknown, "", ""},
{"responseBodyProcessor=JSON", ctlResponseBodyProcessor, "JSON", variables.Unknown, "", ""},
{"forceResponseBodyVariable=On", ctlForceResponseBodyVariable, "On", variables.Unknown, "", ""},
{"ruleEngine=On", ctlRuleEngine, "On", variables.Unknown, "", ""},
{"ruleRemoveById=1", ctlRuleRemoveByID, "1", variables.Unknown, "", ""},
{"ruleRemoveById=1-9", ctlRuleRemoveByID, "1-9", variables.Unknown, "", ""},
{"ruleRemoveByMsg=MY_MSG", ctlRuleRemoveByMsg, "MY_MSG", variables.Unknown, "", ""},
{"ruleRemoveByTag=MY_TAG", ctlRuleRemoveByTag, "MY_TAG", variables.Unknown, "", ""},
{"ruleRemoveTargetByMsg=MY_MSG;ARGS:user", ctlRuleRemoveTargetByMsg, "MY_MSG", variables.Args, "user", ""},
{"ruleRemoveTargetById=2;REQUEST_FILENAME:", ctlRuleRemoveTargetByID, "2", variables.RequestFilename, "", ""},
{"ruleRemoveTargetById=2;ARGS:/^json\\.\\d+\\.description$/", ctlRuleRemoveTargetByID, "2", variables.Args, "", `^json\.\d+\.description$`},
}
for _, tCase := range tCases {
testName, _, _ := strings.Cut(tCase.input, "=")
t.Run(testName, func(t *testing.T) {
action, value, collection, colKey, err := parseCtl(tCase.input)
action, value, collection, colKey, colKeyRx, err := parseCtl(tCase.input, nil)
if err != nil {
t.Fatalf("unexpected error: %s", err.Error())
}
Expand All @@ -451,6 +509,17 @@ func TestParseCtl(t *testing.T) {
if colKey != tCase.expectKey {
t.Errorf("unexpected key, want: %s, have: %s", tCase.expectKey, colKey)
}
if tCase.expectKeyRx == "" {
if colKeyRx != nil {
t.Errorf("unexpected non-nil regex, have: %s", colKeyRx.String())
}
} else {
if colKeyRx == nil {
t.Errorf("expected non-nil regex matching %q, got nil", tCase.expectKeyRx)
} else if colKeyRx.String() != tCase.expectKeyRx {
t.Errorf("unexpected regex, want: %s, have: %s", tCase.expectKeyRx, colKeyRx.String())
}
}
})
}

Expand Down
14 changes: 9 additions & 5 deletions internal/corazawaf/rule.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import (
"github.com/corazawaf/coraza/v3/experimental/plugins/macro"
"github.com/corazawaf/coraza/v3/experimental/plugins/plugintypes"
"github.com/corazawaf/coraza/v3/internal/corazarules"
utils "github.com/corazawaf/coraza/v3/internal/strings"
"github.com/corazawaf/coraza/v3/types"
"github.com/corazawaf/coraza/v3/types/variables"
)
Expand Down Expand Up @@ -236,7 +237,7 @@ func (r *Rule) doEvaluate(logger debuglog.Logger, phase types.RulePhase, tx *Tra
for _, c := range ecol {
if c.Variable == v.Variable {
// TODO shall we check the pointer?
v.Exceptions = append(v.Exceptions, ruleVariableException{c.KeyStr, nil})
v.Exceptions = append(v.Exceptions, ruleVariableException{c.KeyStr, c.KeyRx})
}
}

Expand Down Expand Up @@ -529,11 +530,9 @@ func (r *Rule) ClearDisruptiveActions() {
// hasRegex checks the received key to see if it is between forward slashes.
// if it is, it will return true and the content of the regular expression inside the slashes.
// otherwise it will return false and the same key.
// Delegates to utils.HasRegex which properly handles escaped slashes.
func hasRegex(key string) (bool, string) {
if len(key) > 2 && key[0] == '/' && key[len(key)-1] == '/' {
return true, key[1 : len(key)-1]
}
return false, key
return utils.HasRegex(key)
}

// caseSensitiveVariable returns true if the variable is case sensitive
Expand Down Expand Up @@ -747,6 +746,11 @@ func (r *Rule) SetMemoizer(m plugintypes.Memoizer) {
r.memoizer = m
}

// Memoizer returns the memoizer used for caching compiled regexes in variable selectors.
func (r *Rule) Memoizer() plugintypes.Memoizer {
return r.memoizer
}

func (r *Rule) memoizeDo(key string, fn func() (any, error)) (any, error) {
if r.memoizer != nil {
return r.memoizer.Do(key, fn)
Expand Down
4 changes: 2 additions & 2 deletions internal/corazawaf/rule_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -101,7 +101,7 @@ func TestNoMatchEvaluateBecauseOfException(t *testing.T) {
_ = r.AddAction("dummyDeny", action)
tx := NewWAF().NewTransaction()
tx.AddGetRequestArgument("test", "0")
tx.RemoveRuleTargetByID(1, tc.variable, "test")
tx.RemoveRuleTargetByID(1, tc.variable, "test", nil)
var matchedValues []types.MatchData
matchdata := r.doEvaluate(debuglog.Noop(), types.PhaseRequestHeaders, tx, &matchedValues, 0, tx.transformationCache)
if len(matchdata) != 0 {
Expand Down Expand Up @@ -147,7 +147,7 @@ func TestNoMatchEvaluateBecauseOfWholeCollectionException(t *testing.T) {
tx.AddGetRequestArgument("test", "0")
tx.AddGetRequestArgument("other", "0")
// Remove with empty key should exclude the entire collection
tx.RemoveRuleTargetByID(1, tc.variable, "")
tx.RemoveRuleTargetByID(1, tc.variable, "", nil)
var matchedValues []types.MatchData
matchdata := r.doEvaluate(debuglog.Noop(), types.PhaseRequestHeaders, tx, &matchedValues, 0, tx.transformationCache)
if len(matchdata) != 0 {
Expand Down
12 changes: 9 additions & 3 deletions internal/corazawaf/transaction.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import (
"net/url"
"os"
"path/filepath"
"regexp"
"strconv"
"strings"
"time"
Expand Down Expand Up @@ -679,12 +680,17 @@ func (tx *Transaction) GetField(rv ruleVariableParams) []types.MatchData {
return matches
}

// RemoveRuleTargetByID Removes the VARIABLE:KEY from the rule ID
// It's mostly used by CTL to dynamically remove targets from rules
func (tx *Transaction) RemoveRuleTargetByID(id int, variable variables.RuleVariable, key string) {
// RemoveRuleTargetByID removes the VARIABLE:KEY from the rule ID.
// It is mostly used by CTL to dynamically remove targets from rules.
// key is an exact string to match against the variable name; keyRx is an
// optional compiled regular expression that, when non-nil, is used instead of
// key for pattern-based matching (e.g. removing all ARGS matching
// /^json\.\d+\.field$/ from a given rule).
func (tx *Transaction) RemoveRuleTargetByID(id int, variable variables.RuleVariable, key string, keyRx *regexp.Regexp) {
c := ruleVariableParams{
Variable: variable,
KeyStr: key,
KeyRx: keyRx,
}

if multiphaseEvaluation && (variable == variables.Args || variable == variables.ArgsNames) {
Expand Down
Loading
Loading