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:
@@ -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