The Core Rule Set, run by Coraza, on each request's method, URL and headers (closes #25)
check / check (push) Waiting to run
check / check (push) Waiting to run
Coraza v3.8.1 runs the Core Rule Set 4.25.0 (coraza-coreruleset v4.25.0) after the rule files, with the six changes and the default SWWAF_WAF_DISABLED_RULES that SPEC.md gives; no body, no response. The parameter names in the third and fourth changes are matched in any case, as Coraza does. SWWAF_WAF_DISABLED_RULES refuses 900000 to 900999, smallwebwaf's own rules. A request with more than 1000 query parameters adds 5 (rule 900300). In block mode a match is refused with 403, an offence counted toward the error burst; in detect mode it is let through. Both log waf_rule_ids, waf_score and duration_waf, raise waf_block, and count smallwebwaf_waf_matches_total. Judgement call: waf_block is raised in block mode too. Deviation: no engine-error path; with no body read, Coraza cannot fail. Model: opus-5-5
This commit was merged in pull request #121.
This commit is contained in:
+112
-10
@@ -42,8 +42,8 @@ type Config struct {
|
||||
InstanceName string
|
||||
// Observe is true in observe mode, when SWWAF_MODE is observe rather
|
||||
// than enforce: a request that SWWAF_DENY_NETS, a ban, the country
|
||||
// lists, a rate limit or a rule would refuse is passed to the app
|
||||
// instead, and no ban is made.
|
||||
// lists, a rate limit, a rule or the Core Rule Set would refuse is
|
||||
// passed to the app instead, and no ban is made.
|
||||
Observe bool
|
||||
// TrustedProxies are the netblocks whose X-Forwarded-For is
|
||||
// believed (SWWAF_TRUSTED_PROXIES).
|
||||
@@ -239,12 +239,25 @@ type Config struct {
|
||||
// unless RulesEnabled is false (SWWAF_RULES_ENABLED).
|
||||
RulesDir string
|
||||
RulesEnabled bool
|
||||
// WAFMode is what the Core Rule Set does (SWWAF_WAF_MODE): WAFModeOff,
|
||||
// WAFModeDetect or WAFModeBlock. WAFParanoiaLevel is its paranoia
|
||||
// level, from 1 to 4 (SWWAF_WAF_PARANOIA_LEVEL), and
|
||||
// WAFAnomalyThreshold the anomaly score at which a request is a match
|
||||
// (SWWAF_WAF_ANOMALY_THRESHOLD), 0 while it is off. WAFDisabledRules
|
||||
// are the ids of its rules switched off (SWWAF_WAF_DISABLED_RULES), and
|
||||
// WAFExemptPaths the path prefixes it does not inspect
|
||||
// (SWWAF_WAF_EXEMPT_PATHS).
|
||||
WAFMode string
|
||||
WAFParanoiaLevel int
|
||||
WAFAnomalyThreshold int
|
||||
WAFDisabledRules []int
|
||||
WAFExemptPaths []string
|
||||
// TrapPaths are the paths a request for which is a clear sign of
|
||||
// attack (SWWAF_TRAP_PATHS), each starting with / and without a ?.
|
||||
TrapPaths []string
|
||||
// ErrorBurstThreshold is the most requests of a client within a minute
|
||||
// that smallwebwaf may refuse after a rule file match or for a missing
|
||||
// or wrong token; one more breaks a limit
|
||||
// that smallwebwaf may refuse after a rule file or Core Rule Set match
|
||||
// or for a missing or wrong token; one more breaks a limit
|
||||
// (SWWAF_ERROR_BURST_THRESHOLD). 0 is off.
|
||||
ErrorBurstThreshold int64
|
||||
// LogRemoteURL is where every line on stdout is also sent
|
||||
@@ -310,6 +323,16 @@ type Config struct {
|
||||
// off.
|
||||
const off = "off"
|
||||
|
||||
// The values of SWWAF_WAF_MODE.
|
||||
const (
|
||||
// WAFModeOff runs no request through the Core Rule Set.
|
||||
WAFModeOff = off
|
||||
// WAFModeDetect logs and alerts a match, and refuses nothing.
|
||||
WAFModeDetect = "detect"
|
||||
// WAFModeBlock refuses a match with 403.
|
||||
WAFModeBlock = "block"
|
||||
)
|
||||
|
||||
// fileSource is the SWWAF_LOOKUP_SOURCE that looks clients up in the
|
||||
// lookup database, the file SWWAF_LOOKUP_DB_PATH names.
|
||||
const fileSource = "file"
|
||||
@@ -327,6 +350,14 @@ const (
|
||||
minIPv6GroupPrefix = 32
|
||||
// minTokenLength is the fewest characters a token may have.
|
||||
minTokenLength = 32
|
||||
// maxParanoiaLevel is the Core Rule Set's highest paranoia level.
|
||||
maxParanoiaLevel = 4
|
||||
// firstSetupRuleID to lastSetupRuleID are the ids the Core Rule Set
|
||||
// keeps for the rules that set it up, which smallwebwaf's own rules
|
||||
// have too (see internal/waf). Switching one off would undo a change
|
||||
// that no setting undoes.
|
||||
firstSetupRuleID = 900000
|
||||
lastSetupRuleID = 900999
|
||||
// masked is what the log shows for a token that is set, and in place of
|
||||
// a secret in another setting.
|
||||
masked = "********"
|
||||
@@ -388,6 +419,13 @@ var (
|
||||
"is not a path prefix starting with /, such as /assets/")
|
||||
errNotTrapPath = errors.New(
|
||||
"is not a path starting with / and without a ?, such as /wp-login.php")
|
||||
errNotWAFMode = errors.New("is not off, detect or block")
|
||||
errNotParanoiaLevel = errors.New("is not a paranoia level, from 1 to 4")
|
||||
errNotRuleID = errors.New(
|
||||
"is not the id of a Core Rule Set rule, a whole number such as 942100")
|
||||
errSetupRuleID = errors.New(
|
||||
"is from 900000 to 900999, the ids of the rules that set the Core Rule Set " +
|
||||
"up and of smallwebwaf's own, which cannot be switched off")
|
||||
errNotBoolean = errors.New("is not true or false")
|
||||
errNotLogRemoteURL = errors.New(
|
||||
"is not syslog+udp, syslog+tcp or syslog+tls with a host and a port, " +
|
||||
@@ -509,12 +547,18 @@ func FromEnvironment(lookupEnv func(string) (string, bool)) (*Config, error) {
|
||||
StateCounterInterval: env.durationNotOff("SWWAF_STATE_COUNTER_INTERVAL", "15m"),
|
||||
LogRequestHeaders: env.headerNames("SWWAF_LOG_REQUEST_HEADERS",
|
||||
"accept,accept-language,accept-encoding,content-type,origin,range"),
|
||||
LogLevel: env.logLevel("SWWAF_LOG_LEVEL", "info"),
|
||||
AdminToken: env.token("SWWAF_ADMIN_TOKEN"),
|
||||
MetricsToken: env.token("SWWAF_METRICS_TOKEN"),
|
||||
MetricsTopN: env.numberNotOff("SWWAF_METRICS_TOP_N", "50"),
|
||||
RulesDir: env.value("SWWAF_RULES_DIR", "/etc/smallwebwaf/rules.d"),
|
||||
RulesEnabled: env.boolean("SWWAF_RULES_ENABLED", "true"),
|
||||
LogLevel: env.logLevel("SWWAF_LOG_LEVEL", "info"),
|
||||
AdminToken: env.token("SWWAF_ADMIN_TOKEN"),
|
||||
MetricsToken: env.token("SWWAF_METRICS_TOKEN"),
|
||||
MetricsTopN: env.numberNotOff("SWWAF_METRICS_TOP_N", "50"),
|
||||
RulesDir: env.value("SWWAF_RULES_DIR", "/etc/smallwebwaf/rules.d"),
|
||||
RulesEnabled: env.boolean("SWWAF_RULES_ENABLED", "true"),
|
||||
WAFMode: env.wafMode("SWWAF_WAF_MODE", WAFModeBlock),
|
||||
WAFParanoiaLevel: env.paranoiaLevel("SWWAF_WAF_PARANOIA_LEVEL", "1"),
|
||||
WAFAnomalyThreshold: env.numberOrOff("SWWAF_WAF_ANOMALY_THRESHOLD", "5"),
|
||||
WAFDisabledRules: env.ruleIDs("SWWAF_WAF_DISABLED_RULES",
|
||||
"920340,920420,920440,920640,930130,930140"),
|
||||
WAFExemptPaths: env.pathPrefixes("SWWAF_WAF_EXEMPT_PATHS", ""),
|
||||
TrapPaths: env.trapPaths("SWWAF_TRAP_PATHS"),
|
||||
ErrorBurstThreshold: env.count("SWWAF_ERROR_BURST_THRESHOLD", "30"),
|
||||
LogRemoteURL: env.logRemoteURL("SWWAF_LOG_REMOTE_URL"),
|
||||
@@ -765,6 +809,39 @@ func (e *environment) trapPaths(name string) []string {
|
||||
return paths
|
||||
}
|
||||
|
||||
// wafMode reads the setting that is what the Core Rule Set does: off,
|
||||
// detect or block.
|
||||
func (e *environment) wafMode(name, defaultValue string) string {
|
||||
mode := e.value(name, defaultValue)
|
||||
if mode != WAFModeOff && mode != WAFModeDetect && mode != WAFModeBlock {
|
||||
e.check(name, fmt.Errorf("%q %w", mode, errNotWAFMode))
|
||||
}
|
||||
|
||||
return mode
|
||||
}
|
||||
|
||||
// paranoiaLevel reads the setting that is the Core Rule Set's paranoia
|
||||
// level, from 1 to 4.
|
||||
func (e *environment) paranoiaLevel(name, defaultValue string) int {
|
||||
value := e.value(name, defaultValue)
|
||||
|
||||
level, err := strconv.Atoi(value)
|
||||
if err != nil || level < 1 || level > maxParanoiaLevel {
|
||||
e.check(name, fmt.Errorf("%q %w", value, errNotParanoiaLevel))
|
||||
}
|
||||
|
||||
return level
|
||||
}
|
||||
|
||||
// ruleIDs reads the setting that is a list of the ids of Core Rule Set
|
||||
// rules.
|
||||
func (e *environment) ruleIDs(name, defaultValue string) []int {
|
||||
ids, err := parseRuleIDs(e.value(name, defaultValue))
|
||||
e.check(name, err)
|
||||
|
||||
return ids
|
||||
}
|
||||
|
||||
// countries reads a setting that is a list of countries.
|
||||
func (e *environment) countries(name, defaultValue string) []string {
|
||||
countries, err := parseCountries(e.value(name, defaultValue))
|
||||
@@ -1575,6 +1652,31 @@ func parseTrapPaths(value string) ([]string, error) {
|
||||
return paths, nil
|
||||
}
|
||||
|
||||
// parseRuleIDs reads a comma-separated list of the ids of Core Rule Set
|
||||
// rules, each a whole number above zero and outside firstSetupRuleID to
|
||||
// lastSetupRuleID.
|
||||
func parseRuleIDs(value string) ([]int, error) {
|
||||
items, err := parseList(value)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
ids := make([]int, len(items))
|
||||
|
||||
for i, item := range items {
|
||||
ids[i], err = strconv.Atoi(item)
|
||||
if err != nil || ids[i] <= 0 {
|
||||
return nil, fmt.Errorf("%q %w", item, errNotRuleID)
|
||||
}
|
||||
|
||||
if ids[i] >= firstSetupRuleID && ids[i] <= lastSetupRuleID {
|
||||
return nil, fmt.Errorf("%q %w", item, errSetupRuleID)
|
||||
}
|
||||
}
|
||||
|
||||
return ids, nil
|
||||
}
|
||||
|
||||
// countryCodes are the two-letter codes ISO 3166-1 assigns today, and XK,
|
||||
// the code in common use for Kosovo. golang.org/x/text/language cannot
|
||||
// check them: it also takes withdrawn codes such as su, and reserved ones
|
||||
|
||||
@@ -91,6 +91,11 @@ const (
|
||||
logLevel = "SWWAF_LOG_LEVEL"
|
||||
rulesDir = "SWWAF_RULES_DIR"
|
||||
rulesEnabled = "SWWAF_RULES_ENABLED"
|
||||
wafMode = "SWWAF_WAF_MODE"
|
||||
wafParanoiaLevel = "SWWAF_WAF_PARANOIA_LEVEL"
|
||||
wafAnomalyThreshold = "SWWAF_WAF_ANOMALY_THRESHOLD"
|
||||
wafDisabledRules = "SWWAF_WAF_DISABLED_RULES"
|
||||
wafExemptPaths = "SWWAF_WAF_EXEMPT_PATHS"
|
||||
trapPaths = "SWWAF_TRAP_PATHS"
|
||||
errorBurstThreshold = "SWWAF_ERROR_BURST_THRESHOLD"
|
||||
logRemoteURL = "SWWAF_LOG_REMOTE_URL"
|
||||
@@ -170,6 +175,9 @@ const (
|
||||
// defaultReputationCacheTTL is the default of SWWAF_REPUTATION_CACHE_TTL.
|
||||
const defaultReputationCacheTTL = "24h"
|
||||
|
||||
// defaultWAFDisabledRules is the default of SWWAF_WAF_DISABLED_RULES.
|
||||
const defaultWAFDisabledRules = "920340,920420,920440,920640,930130,930140"
|
||||
|
||||
// defaultLogRequestHeaders is the default of SWWAF_LOG_REQUEST_HEADERS.
|
||||
const defaultLogRequestHeaders = "accept,accept-language,accept-encoding," +
|
||||
"content-type,origin,range"
|
||||
@@ -553,6 +561,105 @@ func TestInvalidTrapPathOrErrorBurstThresholdStopsTheStart(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCoreRuleSetSettings(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
for _, tc := range []struct {
|
||||
env environment
|
||||
want config.Config
|
||||
}{
|
||||
{
|
||||
environment{},
|
||||
config.Config{
|
||||
WAFMode: config.WAFModeBlock, WAFParanoiaLevel: 1, WAFAnomalyThreshold: 5,
|
||||
WAFDisabledRules: []int{920340, 920420, 920440, 920640, 930130, 930140},
|
||||
WAFExemptPaths: []string{},
|
||||
},
|
||||
},
|
||||
{
|
||||
environment{
|
||||
wafMode: config.WAFModeDetect, wafParanoiaLevel: "4", wafAnomalyThreshold: "10",
|
||||
wafDisabledRules: "942100, 920350", wafExemptPaths: "/api/, /static/",
|
||||
},
|
||||
config.Config{
|
||||
WAFMode: config.WAFModeDetect, WAFParanoiaLevel: 4, WAFAnomalyThreshold: 10,
|
||||
WAFDisabledRules: []int{942100, 920350},
|
||||
WAFExemptPaths: []string{"/api/", "/static/"},
|
||||
},
|
||||
},
|
||||
{
|
||||
environment{wafMode: off, wafAnomalyThreshold: off, wafDisabledRules: ""},
|
||||
config.Config{
|
||||
WAFMode: config.WAFModeOff, WAFParanoiaLevel: 1, WAFAnomalyThreshold: 0,
|
||||
WAFDisabledRules: []int{}, WAFExemptPaths: []string{},
|
||||
},
|
||||
},
|
||||
} {
|
||||
cfg := fromEnvironment(t, tc.env)
|
||||
|
||||
got := config.Config{
|
||||
WAFMode: cfg.WAFMode, WAFParanoiaLevel: cfg.WAFParanoiaLevel,
|
||||
WAFAnomalyThreshold: cfg.WAFAnomalyThreshold,
|
||||
WAFDisabledRules: cfg.WAFDisabledRules, WAFExemptPaths: cfg.WAFExemptPaths,
|
||||
}
|
||||
if !reflect.DeepEqual(got, tc.want) {
|
||||
t.Errorf("%v gave\n%+v\nwant\n%+v", tc.env, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestInvalidCoreRuleSetSettingStopsTheStart(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
const (
|
||||
notParanoiaLevel = " is not a paranoia level, from 1 to 4"
|
||||
setupRule = " is from 900000 to 900999, the ids of the rules that set " +
|
||||
"the Core Rule Set up and of smallwebwaf's own, which cannot be switched off"
|
||||
)
|
||||
|
||||
for _, tc := range []struct{ name, value, want string }{
|
||||
{wafMode, "enforce", `"enforce" is not off, detect or block`},
|
||||
{wafParanoiaLevel, "0", `"0"` + notParanoiaLevel},
|
||||
{wafParanoiaLevel, "5", `"5"` + notParanoiaLevel},
|
||||
{wafParanoiaLevel, off, `"off"` + notParanoiaLevel},
|
||||
{
|
||||
wafAnomalyThreshold, "0",
|
||||
`"0" is not a whole number above zero, such as 60, or off`,
|
||||
},
|
||||
{
|
||||
wafDisabledRules, "920340,REQUEST-920",
|
||||
`"REQUEST-920" is not the id of a Core Rule Set rule, ` +
|
||||
`a whole number such as 942100`,
|
||||
},
|
||||
{
|
||||
wafDisabledRules, "-942100",
|
||||
`"-942100" is not the id of a Core Rule Set rule, ` +
|
||||
`a whole number such as 942100`,
|
||||
},
|
||||
// The paranoia level, the allowed methods, the headers refused, and
|
||||
// a request with more query parameters than Coraza keeps.
|
||||
{wafDisabledRules, "942100,900000", `"900000"` + setupRule},
|
||||
{wafDisabledRules, "942100,900200", `"900200"` + setupRule},
|
||||
{wafDisabledRules, "942100,900250", `"900250"` + setupRule},
|
||||
{wafDisabledRules, "942100,900300", `"900300"` + setupRule},
|
||||
{
|
||||
wafExemptPaths, "api/",
|
||||
`"api/" is not a path prefix starting with /, such as /assets/`,
|
||||
},
|
||||
} {
|
||||
t.Run(tc.name+"="+tc.value, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
_, err := config.FromEnvironment(environment{tc.name: tc.value}.lookupEnv)
|
||||
|
||||
want := tc.name + ": " + tc.want
|
||||
if err == nil || err.Error() != want {
|
||||
t.Errorf("error %v, want %s", err, want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestInstanceNameAndLoggedHeadersAsSet(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
@@ -2291,6 +2398,11 @@ func TestLogsEachSettingWithItsValue(t *testing.T) {
|
||||
logLevel: "info",
|
||||
rulesDir: "/etc/smallwebwaf/rules.d",
|
||||
rulesEnabled: "true",
|
||||
wafMode: config.WAFModeBlock,
|
||||
wafParanoiaLevel: "1",
|
||||
wafAnomalyThreshold: "5",
|
||||
wafDisabledRules: defaultWAFDisabledRules,
|
||||
wafExemptPaths: "",
|
||||
trapPaths: "",
|
||||
errorBurstThreshold: "30",
|
||||
logRemoteURL: "",
|
||||
|
||||
Reference in New Issue
Block a user