Rule files, and bans for a clear sign of attack (closes #24)
check / check (push) Successful in 3m28s
check / check (push) Successful in 3m28s
Every *.rules file in SWWAF_RULES_DIR is read at start and on each change. 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 attack. path, query and uri are matched as the request line sent them; header:Host and header:Transfer-Encoding are refused. Bans gain a 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:
@@ -34,8 +34,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 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).
|
||||
@@ -94,7 +94,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
|
||||
@@ -105,6 +106,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
|
||||
@@ -127,6 +131,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.
|
||||
@@ -187,6 +195,7 @@ var (
|
||||
errNotMode = errors.New("is not enforce or observe")
|
||||
errNotPathPrefix = errors.New(
|
||||
"is not a path prefix starting with /, such as /assets/")
|
||||
errNotBoolean = errors.New("is not true or false")
|
||||
)
|
||||
|
||||
// FromEnvironment reads the settings with lookupEnv, normally
|
||||
@@ -224,6 +233,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"),
|
||||
@@ -233,6 +243,8 @@ func FromEnvironment(lookupEnv func(string) (string, bool)) (*Config, error) {
|
||||
"accept,accept-language,accept-encoding,content-type,origin,range"),
|
||||
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 {
|
||||
@@ -316,6 +328,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))
|
||||
|
||||
@@ -42,6 +42,7 @@ const (
|
||||
limitBanDuration = "SWWAF_LIMIT_BAN_DURATION"
|
||||
limitBanRepeatWindow = "SWWAF_LIMIT_BAN_REPEAT_WINDOW"
|
||||
maxBanDuration = "SWWAF_MAX_BAN_DURATION"
|
||||
attackBanDuration = "SWWAF_ATTACK_BAN_DURATION"
|
||||
maxBans = "SWWAF_MAX_BANS"
|
||||
banScopeV4Prefix = "SWWAF_BAN_SCOPE_V4_PREFIX"
|
||||
stateDir = "SWWAF_STATE_DIR"
|
||||
@@ -51,6 +52,8 @@ const (
|
||||
metricsTopN = "SWWAF_METRICS_TOP_N"
|
||||
instanceName = "SWWAF_INSTANCE_NAME"
|
||||
logRequestHeaders = "SWWAF_LOG_REQUEST_HEADERS"
|
||||
rulesDir = "SWWAF_RULES_DIR"
|
||||
rulesEnabled = "SWWAF_RULES_ENABLED"
|
||||
)
|
||||
|
||||
// defaultLogRequestHeaders is the default of SWWAF_LOG_REQUEST_HEADERS.
|
||||
@@ -108,6 +111,7 @@ func TestDefaults(t *testing.T) {
|
||||
LimitBanDuration: time.Hour,
|
||||
LimitBanRepeatWindow: 24 * time.Hour,
|
||||
MaxBanDuration: 7 * 24 * time.Hour,
|
||||
AttackBanDuration: 7 * 24 * time.Hour,
|
||||
MaxBans: 5000,
|
||||
BanScopeV4Prefix: 32,
|
||||
StateDir: "/var/lib/smallwebwaf",
|
||||
@@ -115,6 +119,8 @@ func TestDefaults(t *testing.T) {
|
||||
StateCounterInterval: 15 * time.Minute,
|
||||
MetricsToken: "",
|
||||
MetricsTopN: 50,
|
||||
RulesDir: "/etc/smallwebwaf/rules.d",
|
||||
RulesEnabled: true,
|
||||
})
|
||||
|
||||
if cfg.UpstreamURL.String() != "http://127.0.0.1:8081" {
|
||||
@@ -168,13 +174,13 @@ func TestValuesAsSet(t *testing.T) {
|
||||
rateLimitPerMinute: "60",
|
||||
rateLimitPerHour: "600",
|
||||
rateLimitPerDay: "6000",
|
||||
rateLimitExemptPaths: "/assets/, /favicon.ico",
|
||||
deniedCountries: "cn, RU,kp,Xk",
|
||||
allowedCountries: "de",
|
||||
banResponse: "429",
|
||||
limitBanDuration: "15m",
|
||||
limitBanRepeatWindow: "2d",
|
||||
maxBanDuration: "30d",
|
||||
attackBanDuration: "1d",
|
||||
maxBans: "100",
|
||||
banScopeV4Prefix: "24",
|
||||
stateDir: "/srv/waf-state",
|
||||
@@ -182,6 +188,8 @@ func TestValuesAsSet(t *testing.T) {
|
||||
stateCounterInterval: "1h",
|
||||
metricsToken: token,
|
||||
metricsTopN: "10",
|
||||
rulesDir: "/srv/waf-rules",
|
||||
rulesEnabled: "false",
|
||||
})
|
||||
|
||||
wantSettings(t, cfg, config.Config{
|
||||
@@ -202,6 +210,7 @@ func TestValuesAsSet(t *testing.T) {
|
||||
LimitBanDuration: 15 * time.Minute,
|
||||
LimitBanRepeatWindow: 48 * time.Hour,
|
||||
MaxBanDuration: 30 * 24 * time.Hour,
|
||||
AttackBanDuration: 24 * time.Hour,
|
||||
MaxBans: 100,
|
||||
BanScopeV4Prefix: 24,
|
||||
StateDir: "/srv/waf-state",
|
||||
@@ -209,6 +218,8 @@ func TestValuesAsSet(t *testing.T) {
|
||||
StateCounterInterval: time.Hour,
|
||||
MetricsToken: token,
|
||||
MetricsTopN: 10,
|
||||
RulesDir: "/srv/waf-rules",
|
||||
RulesEnabled: false,
|
||||
})
|
||||
|
||||
if cfg.UpstreamURL.String() != "https://app.internal:8443/" {
|
||||
@@ -221,6 +232,12 @@ func TestValuesAsSet(t *testing.T) {
|
||||
wantNetblocks(t, cfg.DenyNets, "198.51.100.0/24")
|
||||
wantCountries(t, deniedCountries, cfg.DeniedCountries, "CN", "RU", "KP", "XK")
|
||||
wantCountries(t, allowedCountries, cfg.ExclusivelyAllowedCountries, "DE")
|
||||
}
|
||||
|
||||
func TestRateLimitExemptPathsAsSet(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cfg := fromEnvironment(t, environment{rateLimitExemptPaths: "/assets/, /favicon.ico"})
|
||||
|
||||
if !slices.Equal(cfg.RateLimitExemptPaths, []string{"/assets/", "/favicon.ico"}) {
|
||||
t.Errorf("%s gave %v, want /assets/ and /favicon.ico",
|
||||
@@ -356,7 +373,7 @@ func TestTrustedProxiesSetButEmptyTrustNothing(t *testing.T) {
|
||||
func TestInvalidValueStopsTheStart(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
for _, tc := range []struct{ name, value string }{
|
||||
wantStartStopped(t, []struct{ name, value string }{
|
||||
{listenAddr, "8080"}, {listenAddr, ":http"}, {listenAddr, ":65536"},
|
||||
{upstreamURL, "127.0.0.1:8081"},
|
||||
{upstreamURL, "ftp://127.0.0.1:8081"},
|
||||
@@ -406,21 +423,37 @@ func TestInvalidValueStopsTheStart(t *testing.T) {
|
||||
{allowedCountries, "uk"},
|
||||
{allowedCountries, "zz"},
|
||||
{allowedCountries, "de,germany"},
|
||||
{banResponse, "404"}, {banResponse, "drop"}, {banResponse, ""},
|
||||
{limitBanDuration, off}, {limitBanDuration, "0s"}, {limitBanDuration, "1"},
|
||||
{limitBanRepeatWindow, off}, {limitBanRepeatWindow, "-1h"},
|
||||
{maxBanDuration, off}, {maxBanDuration, "1w"},
|
||||
{maxBans, off}, {maxBans, "0"}, {maxBans, "5K"},
|
||||
{banScopeV4Prefix, "33"}, {banScopeV4Prefix, "-1"}, {banScopeV4Prefix, "/24"},
|
||||
{stateDir, ""}, {stateDir, "state"}, {stateDir, "./var/lib/smallwebwaf"},
|
||||
{stateWriteDelay, off}, {stateWriteDelay, "0s"},
|
||||
{stateCounterInterval, off}, {stateCounterInterval, "15"},
|
||||
{metricsTopN, off}, {metricsTopN, "0"}, {metricsTopN, "-1"},
|
||||
{logRequestHeaders, "accept,,origin"}, {logRequestHeaders, "accept;origin"},
|
||||
{logRequestHeaders, "accept language"}, {logRequestHeaders, "x-foo:"},
|
||||
{logRequestHeaders, "host"}, {logRequestHeaders, "accept,Host"},
|
||||
{logRequestHeaders, "transfer-encoding"}, {logRequestHeaders, "TRANSFER-ENCODING"},
|
||||
} {
|
||||
{rulesEnabled, "yes"}, {rulesEnabled, "True"},
|
||||
})
|
||||
}
|
||||
|
||||
func TestInvalidBanOrStateValueStopsTheStart(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
wantStartStopped(t, []struct{ name, value string }{
|
||||
{banResponse, "404"}, {banResponse, "drop"}, {banResponse, ""},
|
||||
{limitBanDuration, off}, {limitBanDuration, "0s"}, {limitBanDuration, "1"},
|
||||
{limitBanRepeatWindow, off}, {limitBanRepeatWindow, "-1h"},
|
||||
{maxBanDuration, off}, {maxBanDuration, "1w"}, {attackBanDuration, off},
|
||||
{maxBans, off}, {maxBans, "0"}, {maxBans, "5K"},
|
||||
{banScopeV4Prefix, "33"}, {banScopeV4Prefix, "-1"}, {banScopeV4Prefix, "/24"},
|
||||
{stateDir, ""}, {stateDir, "state"}, {stateDir, "./var/lib/smallwebwaf"},
|
||||
{stateWriteDelay, off}, {stateWriteDelay, "0s"},
|
||||
{stateCounterInterval, off}, {stateCounterInterval, "15"},
|
||||
})
|
||||
}
|
||||
|
||||
// wantStartStopped checks that each setting, set to its value, stops the
|
||||
// start with an error that names the setting.
|
||||
func wantStartStopped(t *testing.T, invalid []struct{ name, value string }) {
|
||||
t.Helper()
|
||||
|
||||
for _, tc := range invalid {
|
||||
t.Run(tc.name+"="+tc.value, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
@@ -536,6 +569,7 @@ func TestLogsEachSettingWithItsValue(t *testing.T) {
|
||||
limitBanDuration: "1h",
|
||||
limitBanRepeatWindow: "24h",
|
||||
maxBanDuration: "7d",
|
||||
attackBanDuration: "7d",
|
||||
maxBans: "5000",
|
||||
banScopeV4Prefix: "32",
|
||||
stateDir: "/var/lib/smallwebwaf",
|
||||
@@ -545,6 +579,8 @@ func TestLogsEachSettingWithItsValue(t *testing.T) {
|
||||
metricsTopN: "50",
|
||||
instanceName: hostname,
|
||||
logRequestHeaders: defaultLogRequestHeaders,
|
||||
rulesDir: "/etc/smallwebwaf/rules.d",
|
||||
rulesEnabled: "true",
|
||||
}
|
||||
if !maps.Equal(line.Settings, want) {
|
||||
t.Errorf("logged settings\n%v\nwant\n%v", line.Settings, want)
|
||||
@@ -574,8 +610,8 @@ func wantSettings(t *testing.T, got *config.Config, want config.Config) {
|
||||
wantBanSettings(t, got, want)
|
||||
}
|
||||
|
||||
// wantBanSettings checks the settings for bans, the state files and the
|
||||
// metrics.
|
||||
// wantBanSettings checks the settings for bans, the state files, the
|
||||
// metrics and the rule files.
|
||||
func wantBanSettings(t *testing.T, got *config.Config, want config.Config) {
|
||||
t.Helper()
|
||||
|
||||
@@ -583,11 +619,17 @@ func wantBanSettings(t *testing.T, got *config.Config, want config.Config) {
|
||||
got.LimitBanDuration != want.LimitBanDuration ||
|
||||
got.LimitBanRepeatWindow != want.LimitBanRepeatWindow ||
|
||||
got.MaxBanDuration != want.MaxBanDuration ||
|
||||
got.AttackBanDuration != want.AttackBanDuration ||
|
||||
got.MaxBans != want.MaxBans ||
|
||||
got.BanScopeV4Prefix != want.BanScopeV4Prefix {
|
||||
t.Errorf("ban settings\n%+v\nwant\n%+v", got, want)
|
||||
}
|
||||
|
||||
if got.RulesDir != want.RulesDir || got.RulesEnabled != want.RulesEnabled {
|
||||
t.Errorf("rule files in %q, on: %t, want %q, %t",
|
||||
got.RulesDir, got.RulesEnabled, want.RulesDir, want.RulesEnabled)
|
||||
}
|
||||
|
||||
if got.StateDir != want.StateDir ||
|
||||
got.StateWriteDelay != want.StateWriteDelay ||
|
||||
got.StateCounterInterval != want.StateCounterInterval {
|
||||
|
||||
Reference in New Issue
Block a user