Trap paths, and the error burst banning a client refused too often (closes #115)
check / check (push) Waiting to run
check / check (push) Waiting to run
SWWAF_TRAP_PATHS: a request whose path, as a path rule sees it, is one of them is a clear sign of attack, banned as a ban rule's match is; the ban's notes give its trap_path. Checked after the rate limits, before the rule files. SWWAF_ERROR_BURST_THRESHOLD (default 30, or off): more refusals in a minute after a block or ban rule or a trap path, or for a missing or wrong token, ban the client as a broken limit does. Counted in clients.json's minute_refusals; limit_hit error_burst, notes kind refusals. A token refusal is now the offence token_refused, and smallwebwaf_offences_total counts every kind the history does. Judgement call: the threshold is not lowered by a client's limit percentage. Model: opus-5-5
This commit is contained in:
@@ -239,6 +239,14 @@ type Config struct {
|
||||
// unless RulesEnabled is false (SWWAF_RULES_ENABLED).
|
||||
RulesDir string
|
||||
RulesEnabled bool
|
||||
// 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
|
||||
// (SWWAF_ERROR_BURST_THRESHOLD). 0 is off.
|
||||
ErrorBurstThreshold int64
|
||||
// LogRemoteURL is where every line on stdout is also sent
|
||||
// (SWWAF_LOG_REMOTE_URL), nil while it is unset and nothing is sent.
|
||||
// LogRemoteTLSCAs are the certificates a syslog+tls endpoint's
|
||||
@@ -378,6 +386,8 @@ var (
|
||||
errNotBytesCount = errors.New("is not response, request or both")
|
||||
errNotPathPrefix = errors.New(
|
||||
"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")
|
||||
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, " +
|
||||
@@ -505,6 +515,8 @@ func FromEnvironment(lookupEnv func(string) (string, bool)) (*Config, error) {
|
||||
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"),
|
||||
TrapPaths: env.trapPaths("SWWAF_TRAP_PATHS"),
|
||||
ErrorBurstThreshold: env.count("SWWAF_ERROR_BURST_THRESHOLD", "30"),
|
||||
LogRemoteURL: env.logRemoteURL("SWWAF_LOG_REMOTE_URL"),
|
||||
LogRemoteTLSCAs: env.certificates("SWWAF_LOG_REMOTE_TLS_CA_FILE"),
|
||||
LogRemoteBuffer: env.numberNotOff("SWWAF_LOG_REMOTE_BUFFER", "10000"),
|
||||
@@ -744,6 +756,15 @@ func (e *environment) pathPrefixes(name, defaultValue string) []string {
|
||||
return prefixes
|
||||
}
|
||||
|
||||
// trapPaths reads the setting that is the list of trap paths. It is empty
|
||||
// by default.
|
||||
func (e *environment) trapPaths(name string) []string {
|
||||
paths, err := parseTrapPaths(e.value(name, ""))
|
||||
e.check(name, err)
|
||||
|
||||
return paths
|
||||
}
|
||||
|
||||
// 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))
|
||||
@@ -1536,6 +1557,24 @@ func parsePathPrefixes(value string) ([]string, error) {
|
||||
return prefixes, nil
|
||||
}
|
||||
|
||||
// parseTrapPaths reads a comma-separated list of trap paths. Each is
|
||||
// matched against a request's path as a path rule is, without the query,
|
||||
// so a path that does not start with / or holds a ? would never match.
|
||||
func parseTrapPaths(value string) ([]string, error) {
|
||||
paths, err := parseList(value)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
for _, path := range paths {
|
||||
if !strings.HasPrefix(path, "/") || strings.Contains(path, "?") {
|
||||
return nil, fmt.Errorf("%q %w", path, errNotTrapPath)
|
||||
}
|
||||
}
|
||||
|
||||
return paths, 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,8 @@ const (
|
||||
logLevel = "SWWAF_LOG_LEVEL"
|
||||
rulesDir = "SWWAF_RULES_DIR"
|
||||
rulesEnabled = "SWWAF_RULES_ENABLED"
|
||||
trapPaths = "SWWAF_TRAP_PATHS"
|
||||
errorBurstThreshold = "SWWAF_ERROR_BURST_THRESHOLD"
|
||||
logRemoteURL = "SWWAF_LOG_REMOTE_URL"
|
||||
logRemoteTLSCAFile = "SWWAF_LOG_REMOTE_TLS_CA_FILE"
|
||||
logRemoteBuffer = "SWWAF_LOG_REMOTE_BUFFER"
|
||||
@@ -495,6 +497,62 @@ func TestPathPrefixNotStartingWithSlashStopsTheStart(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestTrapPathsAndErrorBurstThreshold(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
for _, tc := range []struct {
|
||||
env environment
|
||||
paths []string
|
||||
threshold int64
|
||||
}{
|
||||
{environment{}, []string{}, 30},
|
||||
{
|
||||
environment{trapPaths: "/wp-login.php, /xmlrpc.php", errorBurstThreshold: "5"},
|
||||
[]string{"/wp-login.php", "/xmlrpc.php"}, 5,
|
||||
},
|
||||
{environment{errorBurstThreshold: off}, []string{}, 0},
|
||||
} {
|
||||
cfg := fromEnvironment(t, tc.env)
|
||||
if !slices.Equal(cfg.TrapPaths, tc.paths) ||
|
||||
cfg.ErrorBurstThreshold != tc.threshold {
|
||||
t.Errorf("%v gave %v and %d, want %v and %d", tc.env, cfg.TrapPaths,
|
||||
cfg.ErrorBurstThreshold, tc.paths, tc.threshold)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestInvalidTrapPathOrErrorBurstThresholdStopsTheStart(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
const notTrapPath = " is not a path starting with / and without a ?, " +
|
||||
"such as /wp-login.php"
|
||||
|
||||
for _, tc := range []struct{ name, value, want string }{
|
||||
{trapPaths, "/wp-login.php,xmlrpc.php", `"xmlrpc.php"` + notTrapPath},
|
||||
{trapPaths, "/xmlrpc.php?rsd", `"/xmlrpc.php?rsd"` + notTrapPath},
|
||||
{
|
||||
trapPaths, "/wp-login.php,,/xmlrpc.php",
|
||||
`"/wp-login.php,,/xmlrpc.php" has an empty item in its list`,
|
||||
},
|
||||
{errorBurstThreshold, "0", `"0" must be more than zero, or off`},
|
||||
{
|
||||
errorBurstThreshold, "30/min",
|
||||
`"30/min" is not a whole number of requests such as 1000, or off`,
|
||||
},
|
||||
} {
|
||||
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()
|
||||
|
||||
@@ -2233,6 +2291,8 @@ func TestLogsEachSettingWithItsValue(t *testing.T) {
|
||||
logLevel: "info",
|
||||
rulesDir: "/etc/smallwebwaf/rules.d",
|
||||
rulesEnabled: "true",
|
||||
trapPaths: "",
|
||||
errorBurstThreshold: "30",
|
||||
logRemoteURL: "",
|
||||
logRemoteTLSCAFile: "",
|
||||
logRemoteBuffer: "10000",
|
||||
|
||||
Reference in New Issue
Block a user