Rule files, and bans for a clear sign of attack (closes #24)
check / check (push) Waiting to run

Every *.rules file in SWWAF_RULES_DIR is read at start and on each
change, and each request is checked against the rules after the rate
limits: log notes a match, block refuses with 403, ban refuses and bans
the netblock for SWWAF_ATTACK_BAN_DURATION, made permanent by its next
request or clear sign of attack. path, query and uri are matched as the
request line sent them. bans.json gains each ban's cause, and ban notes
count earlier bans by cause. The image ships 00-default.rules.

Judgement call: a header sent twice is matched with its values joined
by ", ".
Judgement call: SWWAF_MAX_BAN_DURATION does not cap a ban for an attack.
Not in this unit: offences for rule matches, with the error burst.

Model: opus-5-5
This commit is contained in:
2026-10-06 14:33:26 +00:00
parent 6ec52e5b87
commit 7f472c40e2
27 changed files with 2164 additions and 263 deletions
+25 -3
View File
@@ -29,8 +29,8 @@ type Config struct {
UpstreamURL *url.URL
// 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 or a rate limit would refuse is passed to the app instead, and
// no ban is made.
// lists, a rate limit or a rule 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).
@@ -85,7 +85,8 @@ type Config struct {
// BanResponse is the status a refused client is answered with, 403
// or 429, or 0 to close the connection without an answer
// (SWWAF_BAN_RESPONSE). It answers a banned client, a request that
// breaks a rate limit, SWWAF_DENY_NETS and the country lists.
// breaks a rate limit or matches a ban rule, SWWAF_DENY_NETS and the
// country lists.
BanResponse int
// LimitBanDuration is the ban for a first broken rate limit
// (SWWAF_LIMIT_BAN_DURATION). A limit broken again within
@@ -96,6 +97,9 @@ type Config struct {
LimitBanDuration time.Duration
LimitBanRepeatWindow time.Duration
MaxBanDuration time.Duration
// AttackBanDuration is the ban for a first clear sign of attack
// (SWWAF_ATTACK_BAN_DURATION). It cannot be off.
AttackBanDuration time.Duration
// MaxBans is the most bans held (SWWAF_MAX_BANS).
MaxBans int
// BanScopeV4Prefix is the length of the netblock around an IPv4
@@ -115,6 +119,10 @@ type Config struct {
// metrics (SWWAF_METRICS_TOP_N).
MetricsToken string
MetricsTopN int
// RulesDir is the directory of the rule files (SWWAF_RULES_DIR), read
// unless RulesEnabled is false (SWWAF_RULES_ENABLED).
RulesDir string
RulesEnabled bool
// settings are the values read, as given or by default, for the
// log line at start.
@@ -168,6 +176,7 @@ var (
"is not an absolute path, such as /var/lib/smallwebwaf")
errShortToken = errors.New("is shorter than 32 characters")
errNotMode = errors.New("is not enforce or observe")
errNotBoolean = errors.New("is not true or false")
)
// FromEnvironment reads the settings with lookupEnv, normally
@@ -202,6 +211,7 @@ func FromEnvironment(lookupEnv func(string) (string, bool)) (*Config, error) {
LimitBanDuration: env.durationNotOff("SWWAF_LIMIT_BAN_DURATION", "1h"),
LimitBanRepeatWindow: env.durationNotOff("SWWAF_LIMIT_BAN_REPEAT_WINDOW", "24h"),
MaxBanDuration: env.durationNotOff("SWWAF_MAX_BAN_DURATION", "7d"),
AttackBanDuration: env.durationNotOff("SWWAF_ATTACK_BAN_DURATION", "7d"),
MaxBans: env.numberNotOff("SWWAF_MAX_BANS", "5000"),
BanScopeV4Prefix: env.v4Prefix("SWWAF_BAN_SCOPE_V4_PREFIX", "32"),
StateDir: env.absolutePath("SWWAF_STATE_DIR", "/var/lib/smallwebwaf"),
@@ -209,6 +219,8 @@ func FromEnvironment(lookupEnv func(string) (string, bool)) (*Config, error) {
StateCounterInterval: env.durationNotOff("SWWAF_STATE_COUNTER_INTERVAL", "15m"),
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"),
}
for _, country := range cfg.ExclusivelyAllowedCountries {
@@ -292,6 +304,16 @@ func (e *environment) observe(name, defaultValue string) bool {
return mode == "observe"
}
// boolean reads a setting that is true or false.
func (e *environment) boolean(name, defaultValue string) bool {
value := e.value(name, defaultValue)
if value != "true" && value != "false" {
e.check(name, fmt.Errorf("%q %w", value, errNotBoolean))
}
return value == "true"
}
// netblocks reads a setting that is a list of netblocks.
func (e *environment) netblocks(name, defaultValue string) []netip.Prefix {
netblocks, err := parseNetblocks(e.value(name, defaultValue))