The Core Rule Set, run by Coraza, on each request's method, URL and headers (closes #25)
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.
SWWAF_WAF_MODE, SWWAF_WAF_PARANOIA_LEVEL, SWWAF_WAF_ANOMALY_THRESHOLD and
SWWAF_WAF_EXEMPT_PATHS as specified; SWWAF_WAF_DISABLED_RULES refuses
900000 to 900999, smallwebwaf's own rules among them. A request with more
query parameters than Coraza reads, 1000, 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 is contained in:
2026-10-08 06:57:17 +00:00
parent e81a7f0ca2
commit df8c0ebb29
20 changed files with 1700 additions and 229 deletions
+112
View File
@@ -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: "",