Rename the settings whose names do not say what they control (closes #234)
check / check (push) Waiting to run

Applies the setting renames of the naming audit under the SWW_WAF_ and
SWW_RPROXY_ prefixes, with sneak's names for the IP reputation cache
time and the two time limits, which now take milliseconds. The rproxy
log field default_disposition becomes waf_backend_no_answer, and
config.Config and config.FromEnvironment become config.WAF and
config.WAFFromEnvironment. Go fields, test names and prose follow. No
aliases; nothing reads the old names.

Model: opus-5-5
This commit is contained in:
clawbot committed 2026-11-12 09:08:49 +00:00
1 parent 101cc0aba5
commit a18926ec4d
100 files changed
+2181 -1935

No files matched your search

+1 -1
View File
@@ -45,7 +45,7 @@ const (
// threshold.
EventAnomaly = "anomaly"
// EventCoreRuleSetMatch is a request the Core Rule Set scored at or over
// SWW_WAF_CORE_RULE_SET_ANOMALY_THRESHOLD, refused in block mode, let through in
// SWW_WAF_CORE_RULE_SET_SCORE_THRESHOLD, refused in block mode, let through in
// detect mode.
EventCoreRuleSetMatch = "core_rule_set_match"
// EventReputationHit is a request whose client a blocklist, the
+7 -7
View File
@@ -37,7 +37,7 @@ const (
ScopeASN = "asn"
// ScopeTotal is the whole service.
ScopeTotal = "total"
// ScopeWatchedNet is a named netblock of SWW_WAF_WATCH_NETS.
// ScopeWatchedNet is a named netblock of SWW_WAF_ANOMALY_WATCHED_NETS.
ScopeWatchedNet = "watched_net"
)
@@ -68,7 +68,7 @@ type Thresholds struct {
BytesPerHour int64
}
// NamedNetblock is a netblock SWW_WAF_WATCH_NETS names.
// NamedNetblock is a netblock SWW_WAF_ANOMALY_WATCHED_NETS names.
type NamedNetblock struct {
Name string
Netblock netip.Prefix
@@ -78,12 +78,12 @@ type NamedNetblock struct {
type Params struct {
// The thresholds of each scope: SWW_WAF_ANOMALY_CLIENT_*,
// SWW_WAF_ANOMALY_NET_*, SWW_WAF_ANOMALY_ASN_*, SWW_WAF_ANOMALY_TOTAL_* and
// SWW_WAF_WATCH_*.
Client, Net, ASN, Total, Watch Thresholds
// SWW_WAF_ANOMALY_WATCHED_NET_*.
Client, Net, ASN, Total, WatchedNet Thresholds
// NetV4Prefix and NetV6Prefix are the lengths of the netblock around a
// client (SWW_WAF_ANOMALY_NET_V4_PREFIX and SWW_WAF_ANOMALY_NET_V6_PREFIX).
NetV4Prefix, NetV6Prefix int
// NamedNetblocks are SWW_WAF_WATCH_NETS.
// NamedNetblocks are SWW_WAF_ANOMALY_WATCHED_NETS.
NamedNetblocks []NamedNetblock
// Alerts receive the anomaly alerts.
Alerts *alerts.Queue
@@ -119,7 +119,7 @@ type Request struct {
// ASN, ASName and Country are the client's as looked up, each "" when
// unknown.
ASN, ASName, Country string
// Bytes are the request's bytes, as SWW_WAF_BYTES_COUNT counts them.
// Bytes are the request's bytes, as SWW_WAF_BYTES_COUNTED counts them.
Bytes int64
}
@@ -271,7 +271,7 @@ func (c *Counters) scopesOf(r Request) []scope {
scope: ScopeWatchedNet,
netblock: named.Netblock,
name: named.Name,
}, p.Watch,
}, p.WatchedNet,
})
}
}
+1 -1
View File
@@ -26,7 +26,7 @@ func TestEachScopeHasACooldownOfItsOwn(t *testing.T) {
overAtTheSecond := anomaly.Thresholds{RequestsPerMinute: 1}
counters := anomaly.New(anomaly.Params{
Client: overAtTheSecond, Net: overAtTheSecond, ASN: overAtTheSecond,
Total: overAtTheSecond, Watch: overAtTheSecond,
Total: overAtTheSecond, WatchedNet: overAtTheSecond,
// The netblock around a client is the client's own, and two names
// name one netblock.
NetV4Prefix: 32,
+10 -10
View File
@@ -1,6 +1,6 @@
// Package bans is the ban ledger: the bans sww makes on the
// netblocks of clients that break a rate limit, a byte limit or the error
// burst, show a clear sign of attack or are listed by the CrowdSec
// netblocks of clients that break a rate limit, a byte limit or the refusal
// limit, show a clear sign of attack or are listed by the CrowdSec
// decision list, and
// those an admin makes, with their notes, as the "Bans" section of SPEC.md
// describes. The bans are kept in memory, and written to bans.json and
@@ -49,9 +49,9 @@ type Params struct {
// CrowdSec's decision, a broken limit counts as a repeat, which bans for
// repeatFactor times as long as that ban.
LimitBanRepeatWindow time.Duration
// MaxBanDuration is the longest ban for a broken limit; one that would
// be longer is permanent instead.
MaxBanDuration time.Duration
// A ban for a broken limit that would be longer than
// LimitBanPermanentAbove is permanent instead.
LimitBanPermanentAbove time.Duration
// AttackBanDuration is how long a first ban for a clear sign of attack
// lasts.
AttackBanDuration time.Duration
@@ -105,7 +105,7 @@ type Notes struct {
Country string `json:"country"`
// Kind, Limit, Window and Count are, for a ban for a broken limit,
// what the limit was on, "requests" for a rate limit, "bytes" for a
// byte limit or "refusals" for the error burst, the limit that was
// byte limit or "refusals" for the refusal limit, the limit that was
// broken, its window, "minute", "hour" or "day", and the count reached:
// the client's requests, bytes or refusals in the window, those of the
// request that broke the limit included.
@@ -306,7 +306,7 @@ func activeBan(bans []Ban, now time.Time) *Ban {
// last, other than one for a clear sign of attack or for CrowdSec's
// decision, or a lifted one, lasts
// repeatFactor times as long as that one. A ban that would be longer
// than MaxBanDuration is permanent instead. If a ban on netblock is still
// than LimitBanPermanentAbove is permanent instead. If a ban on netblock is still
// active, as when two of its requests break a limit at once, that ban is
// returned with false, and no other is made. The ledger fills in the
// notes' Refused and EarlierBans itself, and gives the ban the reason
@@ -932,16 +932,16 @@ func (l *Ledger) limitExpiry(held []Ban, now time.Time) time.Time {
if last != nil &&
now.Sub(last.Expires) <= l.params.LimitBanRepeatWindow {
lastLength := last.Expires.Sub(last.Start)
// This is repeatFactor * lastLength > MaxBanDuration, written so
// This is repeatFactor * lastLength > LimitBanPermanentAbove, written so
// that it cannot overflow.
if lastLength > l.params.MaxBanDuration/repeatFactor {
if lastLength > l.params.LimitBanPermanentAbove/repeatFactor {
return time.Time{}
}
length = repeatFactor * lastLength
}
if length > l.params.MaxBanDuration {
if length > l.params.LimitBanPermanentAbove {
return time.Time{}
}
+12 -12
View File
@@ -97,11 +97,11 @@ func TestRepeatWindowRunsOut(t *testing.T) {
}
}
func TestFirstBanLongerThanTheMaximumIsPermanent(t *testing.T) {
func TestFirstBanLongerThanLimitBanPermanentAboveIsPermanent(t *testing.T) {
t.Parallel()
params := defaultParams()
params.LimitBanDuration = params.MaxBanDuration + time.Hour
params.LimitBanDuration = params.LimitBanPermanentAbove + time.Hour
ledger := bans.New(params)
ban, _ := ledger.BanForLimit(
@@ -114,14 +114,14 @@ func TestFirstBanLongerThanTheMaximumIsPermanent(t *testing.T) {
}
}
func TestLongestBanSetFarOffDoesNotOverflow(t *testing.T) {
func TestLimitBanPermanentAboveSetFarOffDoesNotOverflow(t *testing.T) {
t.Parallel()
// With bans of up to 100,000 days, the 14th ban in a row, of 3^13
// hours, is within the maximum, and three times as long would not fit
// in a time.Duration. The 15th is permanent.
// With LimitBanPermanentAbove at 100,000 days, the 14th ban in a row, of
// 3^13 hours, is not above it, and three times as long would not fit in
// a time.Duration. The 15th is permanent.
params := defaultParams()
params.MaxBanDuration = 100000 * day
params.LimitBanPermanentAbove = 100000 * day
ledger := bans.New(params)
netblock := netip.MustParsePrefix("203.0.113.9/32")
now := midnight()
@@ -650,11 +650,11 @@ func TestLookupFillsTheNotesOfTheNetblocksBansWithoutOne(t *testing.T) {
// defaultParams are the params at the settings' defaults.
func defaultParams() bans.Params {
return bans.Params{
LimitBanDuration: time.Hour,
LimitBanRepeatWindow: day,
MaxBanDuration: 7 * day,
AttackBanDuration: 7 * day,
MaxBans: 5000,
LimitBanDuration: time.Hour,
LimitBanRepeatWindow: day,
LimitBanPermanentAbove: 7 * day,
AttackBanDuration: 7 * day,
MaxBans: 5000,
}
}
+30 -2
View File
@@ -16,6 +16,7 @@ import (
"os"
"strconv"
"strings"
"time"
"github.com/spf13/viper"
)
@@ -36,7 +37,9 @@ const (
var (
errNotSize = errors.New(
"is not a size such as 512K, 100M or 5G, or off")
errNotPositive = errors.New("must be more than zero, or off")
errNotPositive = errors.New("must be more than zero, or off")
errNotMilliseconds = errors.New(
"is not a whole number of milliseconds above zero, such as 2000")
errEmptyItem = errors.New("has an empty item in its list")
errNotNetblock = errors.New(
"is not a netblock such as 10.0.0.0/8, or an address")
@@ -62,7 +65,8 @@ func Command(v *viper.Viper) (string, error) {
}
}
// environment is where FromEnvironment reads the settings: it notes each
// environment is where WAFFromEnvironment and RProxyFromEnvironment read
// the settings: it notes each
// value for the log, and keeps the first error. It reads every setting
// through v, the viper NewViper returns, and honours each setting's _FILE
// form too.
@@ -162,6 +166,19 @@ func (e *environment) size(name, defaultValue string) int64 {
return size
}
// milliseconds reads a setting that is a whole number of milliseconds above
// zero.
func (e *environment) milliseconds(name, defaultValue string) time.Duration {
value := e.value(name, defaultValue)
duration, valid := parseMilliseconds(value)
if !valid {
e.check(name, fmt.Errorf("%q %w", value, errNotMilliseconds))
}
return duration
}
// parseSize reads a number of bytes with an optional K, M or G suffix, in
// powers of 1024 (1K is 1024 bytes), or off.
func parseSize(value string) (int64, error) {
@@ -198,6 +215,17 @@ func splitUnit(value string) (string, int64) {
}
}
// parseMilliseconds reads a whole number of milliseconds above zero, and
// reports whether it is one.
func parseMilliseconds(value string) (time.Duration, bool) {
n, err := strconv.ParseInt(value, 10, 64)
if err != nil || n <= 0 || n > math.MaxInt64/int64(time.Millisecond) {
return 0, false
}
return time.Duration(n) * time.Millisecond, true
}
// parseList splits a comma-separated list and trims the spaces around
// each item. An empty value is an empty list.
func parseList(value string) ([]string, error) {
+16 -38
View File
@@ -4,7 +4,6 @@ import (
"errors"
"fmt"
"log/slog"
"math"
"net/netip"
"net/url"
"strconv"
@@ -25,13 +24,15 @@ type RProxy struct {
TrustedProxies []netip.Prefix
// WAFBackend is waf's URL (SWW_RPROXY_WAF_BACKEND).
WAFBackend *url.URL
// Timeout is how long rproxy waits for waf's answer to a question,
// and to a report (SWW_RPROXY_TIMEOUT_MS). It cannot be off.
Timeout time.Duration
// DefaultDisposition is what is done with a request when waf cannot be
// connected to, does not answer at all, or does not answer within Timeout:
// allow or deny (SWW_RPROXY_DEFAULT_DISPOSITION).
DefaultDisposition string
// WAFBackendTimeout is how long rproxy waits for waf's answer to a
// question, and to a report (SWW_RPROXY_WAF_BACKEND_TIMEOUT_MS). It
// cannot be off.
WAFBackendTimeout time.Duration
// WAFBackendNoAnswerDisposition is what is done with a request when waf
// cannot be connected to, does not answer at all, or does not answer
// within WAFBackendTimeout: allow or deny
// (SWW_RPROXY_WAF_BACKEND_NO_ANSWER_DISPOSITION).
WAFBackendNoAnswerDisposition string
// MaxRequestSize is the largest request body (SWW_RPROXY_MAX_REQUEST_SIZE), and
// MaxResponseSize the largest response body (SWW_RPROXY_MAX_RESPONSE_SIZE).
MaxRequestSize int64
@@ -62,9 +63,7 @@ var (
"such as http://127.0.0.1:8081")
errNeedsWAFBackend = errors.New(
"is required: it is waf's URL, such as http://waf:8080")
errNotDisposition = errors.New("is not allow or deny")
errNotMilliseconds = errors.New(
"is not a whole number of milliseconds above zero, such as 2000")
errNotDisposition = errors.New("is not allow or deny")
errNotMillisecondsOrOff = errors.New(
"is not a whole number of milliseconds above zero, such as 60000, or off",
)
@@ -89,9 +88,12 @@ func RProxyFromEnvironment(v *viper.Viper) (*RProxy, error) {
privateRanges,
),
WAFBackend: env.wafBackend("SWW_RPROXY_WAF_BACKEND"),
Timeout: env.milliseconds("SWW_RPROXY_TIMEOUT_MS", "2000"),
DefaultDisposition: env.disposition(
"SWW_RPROXY_DEFAULT_DISPOSITION",
WAFBackendTimeout: env.milliseconds(
"SWW_RPROXY_WAF_BACKEND_TIMEOUT_MS",
"2000",
),
WAFBackendNoAnswerDisposition: env.disposition(
"SWW_RPROXY_WAF_BACKEND_NO_ANSWER_DISPOSITION",
"allow",
),
MaxRequestSize: env.size("SWW_RPROXY_MAX_REQUEST_SIZE", "100M"),
@@ -173,19 +175,6 @@ func (e *environment) disposition(name, defaultValue string) string {
return value
}
// milliseconds reads a setting that is a whole number of milliseconds above
// zero.
func (e *environment) milliseconds(name, defaultValue string) time.Duration {
value := e.value(name, defaultValue)
duration, valid := parseMilliseconds(value)
if !valid {
e.check(name, fmt.Errorf("%q %w", value, errNotMilliseconds))
}
return duration
}
// millisecondsOrOff reads a setting that is a whole number of milliseconds
// above zero, or off, which is 0.
func (e *environment) millisecondsOrOff(
@@ -204,17 +193,6 @@ func (e *environment) millisecondsOrOff(
return duration
}
// parseMilliseconds reads a whole number of milliseconds above zero, and
// reports whether it is one.
func parseMilliseconds(value string) (time.Duration, bool) {
n, err := strconv.ParseInt(value, 10, 64)
if err != nil || n <= 0 || n > math.MaxInt64/int64(time.Millisecond) {
return 0, false
}
return time.Duration(n) * time.Millisecond, true
}
// parseUpstreamURL reads the app's URL: http or https, a host and an
// optional port from 1 to 65535, and nothing else, since the request's
// own path and query go to the app unchanged.
+37 -35
View File
@@ -15,20 +15,20 @@ import (
// rproxy's settings, by name, and their values in these tests.
const (
wafBackend = "SWW_RPROXY_WAF_BACKEND"
rproxyTimeout = "SWW_RPROXY_TIMEOUT_MS"
rproxyDefault = "SWW_RPROXY_DEFAULT_DISPOSITION"
rproxyListen = "SWW_RPROXY_LISTEN_ADDR"
rproxyUpstream = "SWW_RPROXY_UPSTREAM_URL"
rproxyTrusted = "SWW_RPROXY_TRUSTED_PROXIES"
rproxyMaxRequest = "SWW_RPROXY_MAX_REQUEST_SIZE"
rproxyMaxResponse = "SWW_RPROXY_MAX_RESPONSE_SIZE"
rproxyRequestTime = "SWW_RPROXY_MAX_REQUEST_TIME_MS"
rproxyAnswerTime = "SWW_RPROXY_MAX_RESPONSE_TIME_MS"
backendURL = "http://waf:8080"
rproxyDefaultAddr = ":8080"
rproxyDefaultApp = "http://127.0.0.1:8081"
notASize = "a lot"
wafBackend = "SWW_RPROXY_WAF_BACKEND"
wafBackendTimeout = "SWW_RPROXY_WAF_BACKEND_TIMEOUT_MS"
rproxyNoAnswer = "SWW_RPROXY_WAF_BACKEND_NO_ANSWER_DISPOSITION"
rproxyListen = "SWW_RPROXY_LISTEN_ADDR"
rproxyUpstream = "SWW_RPROXY_UPSTREAM_URL"
rproxyTrusted = "SWW_RPROXY_TRUSTED_PROXIES"
rproxyMaxRequest = "SWW_RPROXY_MAX_REQUEST_SIZE"
rproxyMaxResponse = "SWW_RPROXY_MAX_RESPONSE_SIZE"
rproxyRequestTime = "SWW_RPROXY_MAX_REQUEST_TIME_MS"
rproxyAnswerTime = "SWW_RPROXY_MAX_RESPONSE_TIME_MS"
backendURL = "http://waf:8080"
rproxyNoAnswerAddr = ":8080"
rproxyNoAnswerApp = "http://127.0.0.1:8081"
notASize = "a lot"
)
// rproxyConfig reads rproxy's settings from env.
@@ -44,8 +44,8 @@ func TestRProxyDefaults(t *testing.T) {
t.Fatalf("settings: %v", err)
}
if cfg.ListenAddr != rproxyDefaultAddr ||
cfg.UpstreamURL.String() != rproxyDefaultApp ||
if cfg.ListenAddr != rproxyNoAnswerAddr ||
cfg.UpstreamURL.String() != rproxyNoAnswerApp ||
cfg.WAFBackend.String() != backendURL {
t.Errorf(
"addresses %q, %v, %v",
@@ -55,11 +55,12 @@ func TestRProxyDefaults(t *testing.T) {
)
}
if cfg.Timeout != 2*time.Second || cfg.DefaultDisposition != "allow" {
if cfg.WAFBackendTimeout != 2*time.Second ||
cfg.WAFBackendNoAnswerDisposition != "allow" {
t.Errorf(
"timeout %v, default disposition %q",
cfg.Timeout,
cfg.DefaultDisposition,
"waf backend timeout %v, no-answer disposition %q",
cfg.WAFBackendTimeout,
cfg.WAFBackendNoAnswerDisposition,
)
}
@@ -90,8 +91,8 @@ func TestRProxySettingsAreRead(t *testing.T) {
rproxyListen: ":9000",
rproxyUpstream: "http://app:3000",
rproxyTrusted: "192.0.2.0/24",
rproxyTimeout: "150",
rproxyDefault: "deny",
wafBackendTimeout: "150",
rproxyNoAnswer: "deny",
rproxyMaxRequest: "1K",
rproxyMaxResponse: off,
rproxyRequestTime: off,
@@ -107,8 +108,8 @@ func TestRProxySettingsAreRead(t *testing.T) {
t.Errorf("addresses %+v", cfg)
}
if cfg.Timeout != 150*time.Millisecond ||
cfg.DefaultDisposition != "deny" ||
if cfg.WAFBackendTimeout != 150*time.Millisecond ||
cfg.WAFBackendNoAnswerDisposition != "deny" ||
cfg.MaxRequestSize != 1024 ||
cfg.MaxResponseSize != 0 ||
cfg.MaxRequestTime != 0 ||
@@ -126,13 +127,13 @@ func TestRProxyInvalidSettingsAreRefused(t *testing.T) {
{wafBackend, ""},
{wafBackend, "waf:8080"},
{wafBackend, "http://waf:8080/path"},
{rproxyTimeout, "0"},
{rproxyTimeout, "-1"},
{rproxyTimeout, off},
{rproxyTimeout, "2s"},
{rproxyTimeout, "1.25"},
{rproxyTimeout, "99999999999999999"},
{rproxyDefault, "maybe"},
{wafBackendTimeout, "0"},
{wafBackendTimeout, "-1"},
{wafBackendTimeout, off},
{wafBackendTimeout, "2s"},
{wafBackendTimeout, "1.25"},
{wafBackendTimeout, "99999999999999999"},
{rproxyNoAnswer, "maybe"},
{rproxyListen, "8080"},
{rproxyUpstream, "app"},
{rproxyUpstream, "127.0.0.1:8081"},
@@ -219,11 +220,11 @@ func TestRProxyUnreadableSettingFileStopsTheStart(t *testing.T) {
for _, path := range []string{filepath.Join(dir, "missing"), dir} {
_, err := rproxyConfig(map[string]string{
wafBackend: backendURL,
rproxyTimeout + "_FILE": path,
wafBackend: backendURL,
wafBackendTimeout + "_FILE": path,
})
want := rproxyTimeout + "_FILE: cannot be read: "
want := wafBackendTimeout + "_FILE: cannot be read: "
if err == nil || !strings.HasPrefix(err.Error(), want) {
t.Errorf("%s: error %v, want one starting %s", path, err, want)
}
@@ -246,7 +247,8 @@ func TestRProxySettingsAreLoggedByName(t *testing.T) {
}
for _, name := range []string{
rproxyListen, rproxyUpstream, rproxyTrusted, wafBackend, rproxyTimeout, rproxyDefault,
rproxyListen, rproxyUpstream, rproxyTrusted, wafBackend, wafBackendTimeout,
rproxyNoAnswer,
rproxyMaxRequest, rproxyMaxResponse, rproxyRequestTime, rproxyAnswerTime,
} {
if !slices.Contains(names, name) {
+1 -1
View File
@@ -11,7 +11,7 @@ import (
func TestTokenSetEmptyInTheEnvironmentStopsTheStart(t *testing.T) {
t.Setenv(adminToken, "")
_, err := config.FromEnvironment(config.NewViper())
_, err := config.WAFFromEnvironment(config.NewViper())
want := adminToken + ": is shorter than 32 characters"
if err == nil || err.Error() != want {
+227 -192
View File
@@ -27,9 +27,9 @@ import (
"sneak.berlin/go/smallwebwaf/internal/remotelog"
)
// Config is the settings of `sww waf`. A time or size limit of
// WAF is the settings of `sww waf`. A time or size limit of
// zero is off.
type Config struct {
type WAF struct {
// ListenAddr is where waf listens (SWW_WAF_LISTEN_ADDR).
ListenAddr string
// InstanceName is the name every log line and alert gives as instance,
@@ -70,25 +70,27 @@ type Config struct {
// BytesLimitPerMinute, BytesLimitPerHour and BytesLimitPerDay are the
// most bytes a client's requests may carry in a minute, an hour and a
// day (SWW_WAF_BYTES_LIMIT_PER_MINUTE, SWW_WAF_BYTES_LIMIT_PER_HOUR and
// SWW_WAF_BYTES_LIMIT_PER_DAY). BytesCount is which body bytes count
// toward them (SWW_WAF_BYTES_COUNT): response, request or both.
// SWW_WAF_BYTES_LIMIT_PER_DAY). BytesCounted is which body bytes count
// toward them and the byte anomaly thresholds (SWW_WAF_BYTES_COUNTED):
// response, request or both.
BytesLimitPerMinute int64
BytesLimitPerHour int64
BytesLimitPerDay int64
BytesCount string
BytesCounted string
// LookupSource is where each client's AS number and country are
// looked up (SWW_WAF_LOOKUP_SOURCE): geojs, file, or off for nowhere.
// LookupDBPath is the lookup database, the IPinfo Lite file looked up
// in while LookupSource is file (SWW_WAF_LOOKUP_DB_PATH), and "" for any
// other source. A request waits up to LookupTimeout for its client's
// first answer from GeoJS while a setting needs it
// (SWW_WAF_LOOKUP_TIMEOUT), which cannot be off. AddLookupHeaders is true
// when the app is passed the client's AS number and country in headers
// other source. A request waits up to ASNAndCountryLookupTimeout for
// its client's first answer from GeoJS while a setting needs it
// (SWW_WAF_ASN_AND_COUNTRY_LOOKUP_TIMEOUT_MS), which cannot be off.
// AddLookupHeaders is true when the app is passed the client's AS
// number and country in headers
// (SWW_WAF_ADD_LOOKUP_HEADERS).
LookupSource string
LookupDBPath string
LookupTimeout time.Duration
AddLookupHeaders bool
LookupSource string
LookupDBPath string
ASNAndCountryLookupTimeout time.Duration
AddLookupHeaders bool
// ExclusivelyAllowedCountries, when not empty, are the only countries
// whose clients are let through (SWW_WAF_EXCLUSIVELY_ALLOWED_COUNTRIES),
// two-letter codes in capitals, as GeoJS gives them.
@@ -105,21 +107,22 @@ type Config struct {
// RouteWatchURL is where the routewatch instance is that adds the asn=
// and prefix= tags, "" for none (SWW_WAF_ROUTEWATCH_URL).
RouteWatchURL string
// The biased thresholds. ASNBytesPercent and CountryBytesPercent give
// those they list a percentage of the byte limits in place of the one
// TagLimitPercent gives their asn= or country= tag
// (SWW_WAF_ASN_BYTES_PERCENT and SWW_WAF_COUNTRY_BYTES_PERCENT). Each holds
// percentages from 0 to 100, by AS number, written as AS64496, or by
// country, a two-letter code in capitals, as the lookup gives them.
// UnknownLimitPercent is the percentage of every limit a client without
// a country gets (SWW_WAF_UNKNOWN_LIMIT_PERCENT). ASNLimitPercentURL is
// The biased thresholds. ASNBytesLimitPercent and
// CountryBytesLimitPercent give those they list a percentage of the byte
// limits in place of the one TagLimitPercent gives their asn= or
// country= tag (SWW_WAF_ASN_BYTES_LIMIT_PERCENT and
// SWW_WAF_COUNTRY_BYTES_LIMIT_PERCENT). Each holds percentages from 0 to
// 100, by AS number, written as AS64496, or by country, a two-letter
// code in capitals, as the lookup gives them. UnknownCountryLimitPercent
// is the percentage of every limit a client without a country gets
// (SWW_WAF_UNKNOWN_COUNTRY_LIMIT_PERCENT). ASNLimitPercentURL is
// where a file of AS:percent lines is fetched from, whose percentages
// count like asn= tags in TagLimitPercent (SWW_WAF_ASN_LIMIT_PERCENT_URL),
// "" while it is unset.
ASNBytesPercent map[string]int64
CountryBytesPercent map[string]int64
UnknownLimitPercent int64
ASNLimitPercentURL string
ASNBytesLimitPercent map[string]int64
CountryBytesLimitPercent map[string]int64
UnknownCountryLimitPercent int64
ASNLimitPercentURL string
// BlocklistURLs are where the blocklists are fetched from
// (SWW_WAF_BLOCKLIST_URLS). Each list, and ASNLimitPercentURL's, is
// fetched again BlocklistRefresh after it was last fetched or tried
@@ -139,21 +142,23 @@ type Config struct {
// of AbuseIPDBMinScore or more is a hit (SWW_WAF_ABUSEIPDB_MIN_SCORE), and
// at most AbuseIPDBDailyBudget checks are made a day
// (SWW_WAF_ABUSEIPDB_DAILY_BUDGET).
// ReputationAction is what is done with a client a zone's verdict lists,
// or whose score is a hit (SWW_WAF_REPUTATION_ACTION): deny, limit or log;
// for limit, ReputationLimitPercent is the percentage of every limit it
// gets. A verdict or a score is used for ReputationCacheTTL after it was
// fetched (SWW_WAF_REPUTATION_CACHE_TTL), and a query or a check may take
// ReputationTimeout (SWW_WAF_REPUTATION_TIMEOUT). Neither can be off.
DNSBLZones []string
DNSBLResolver netip.AddrPort
AbuseIPDBKey string
AbuseIPDBMinScore int64
AbuseIPDBDailyBudget int
ReputationAction string
ReputationLimitPercent int64
ReputationCacheTTL time.Duration
ReputationTimeout time.Duration
// DNSBLAbuseIPDBAction is what is done with a client a zone's verdict
// lists, or whose score is a hit (SWW_WAF_DNSBL_ABUSEIPDB_ACTION): deny,
// limit or log; for limit, DNSBLAbuseIPDBLimitPercent is the percentage
// of every limit it gets. A verdict or a score is used for
// IPReputationCacheTTL after it was fetched
// (SWW_WAF_IP_REPUTATION_CACHE_TTL), and a query or a check may take
// DNSBLAbuseIPDBTimeout (SWW_WAF_DNSBL_ABUSEIPDB_TIMEOUT_MS). Neither
// can be off.
DNSBLZones []string
DNSBLResolver netip.AddrPort
AbuseIPDBKey string
AbuseIPDBMinScore int64
AbuseIPDBDailyBudget int
DNSBLAbuseIPDBAction string
DNSBLAbuseIPDBLimitPercent int64
IPReputationCacheTTL time.Duration
DNSBLAbuseIPDBTimeout time.Duration
// CrowdSecDecisionsURL is where the decision list of the CrowdSec
// engine whose local API SWW_WAF_CROWDSEC_LAPI_URL names is fetched from:
// that URL with v1/decisions added to its path, "" while it is unset and
@@ -171,11 +176,11 @@ type Config struct {
// (SWW_WAF_LIMIT_BAN_DURATION). A limit broken again within
// LimitBanRepeatWindow after the last ban ended bans for three times
// as long as that ban (SWW_WAF_LIMIT_BAN_REPEAT_WINDOW), and a ban that
// would be longer than MaxBanDuration is permanent instead
// (SWW_WAF_MAX_BAN_DURATION). None of them can be off.
LimitBanDuration time.Duration
LimitBanRepeatWindow time.Duration
MaxBanDuration time.Duration
// would be longer than LimitBanPermanentAbove is permanent instead
// (SWW_WAF_LIMIT_BAN_PERMANENT_ABOVE). None of them can be off.
LimitBanDuration time.Duration
LimitBanRepeatWindow time.Duration
LimitBanPermanentAbove time.Duration
// AttackBanDuration is the ban for a first clear sign of attack
// (SWW_WAF_ATTACK_BAN_DURATION). It cannot be off.
AttackBanDuration time.Duration
@@ -185,13 +190,13 @@ type Config struct {
// client that a ban covers (SWW_WAF_BAN_SCOPE_V4_PREFIX).
BanScopeV4Prefix int
// StateDir is the directory of the state files, an absolute path
// (SWW_WAF_STATE_DIR). bans.json is written StateWriteDelay after a ban
// is made (SWW_WAF_STATE_WRITE_DELAY), and every state and cache file
// every StateCounterInterval (SWW_WAF_STATE_COUNTER_INTERVAL). Neither
// (SWW_WAF_STATE_DIR). bans.json is written BansWriteDelay after a ban
// is made (SWW_WAF_BANS_WRITE_DELAY), and every state and cache file
// every StateWriteInterval (SWW_WAF_STATE_WRITE_INTERVAL). Neither
// can be off.
StateDir string
StateWriteDelay time.Duration
StateCounterInterval time.Duration
StateDir string
BansWriteDelay time.Duration
StateWriteInterval time.Duration
// CacheDir is the directory of the cache files, which keep answers
// from outside services, an absolute path (SWW_WAF_CACHE_DIR).
CacheDir string
@@ -207,32 +212,38 @@ type Config struct {
AdminToken string
// MetricsToken is the bearer token a scraper sends for the metrics
// (SWW_WAF_METRICS_TOKEN), "" while it is unset and the metrics are off.
// MetricsTopN is how many AS numbers and how many countries get series
// of their own in the metrics (SWW_WAF_METRICS_TOP_N).
MetricsToken string
MetricsTopN int
// RulesDir is the directory of the rule files (SWW_WAF_RULES_DIR), read
// unless RulesEnabled is false (SWW_WAF_RULES_ENABLED).
RulesDir string
RulesEnabled bool
// WAFMode is what the Core Rule Set does (SWW_WAF_CORE_RULE_SET_MODE): WAFModeOff,
// WAFModeDetect or WAFModeBlock. WAFParanoiaLevel is its paranoia
// level, from 1 to 4 (SWW_WAF_CORE_RULE_SET_PARANOIA_LEVEL), and
// WAFAnomalyThreshold the anomaly score at which a request is a match
// (SWW_WAF_CORE_RULE_SET_ANOMALY_THRESHOLD), 0 while it is off. WAFDisabledRules
// are the ids of its rules switched off (SWW_WAF_CORE_RULE_SET_DISABLED_RULES),
// WAFExemptPaths the path prefixes it does not inspect
// (SWW_WAF_CORE_RULE_SET_EXEMPT_PATHS), and WAFBodyLimit the most of a request body
// it reads (SWW_WAF_CORE_RULE_SET_BODY_LIMIT), 0 while it is off and it reads none.
// MetricsBusiestCountriesAndASNs is how many AS numbers and how many
// countries get series of their own in the metrics
// (SWW_WAF_METRICS_BUSIEST_COUNTRIES_AND_ASNS).
MetricsToken string
MetricsBusiestCountriesAndASNs int
// RuleFilesDir is the directory of the rule files
// (SWW_WAF_RULE_FILES_DIR), read unless RuleFilesEnabled is false
// (SWW_WAF_RULE_FILES_ENABLED).
RuleFilesDir string
RuleFilesEnabled bool
// CoreRuleSetMode is what the Core Rule Set does
// (SWW_WAF_CORE_RULE_SET_MODE): CoreRuleSetModeOff,
// CoreRuleSetModeDetect or CoreRuleSetModeBlock.
// CoreRuleSetParanoiaLevel is its paranoia level, from 1 to 4
// (SWW_WAF_CORE_RULE_SET_PARANOIA_LEVEL), and CoreRuleSetScoreThreshold
// the anomaly score at which a request is a match
// (SWW_WAF_CORE_RULE_SET_SCORE_THRESHOLD), 0 while it is off.
// CoreRuleSetDisabledRules are the ids of its rules switched off
// (SWW_WAF_CORE_RULE_SET_DISABLED_RULES), CoreRuleSetExemptPaths the
// path prefixes it does not inspect (SWW_WAF_CORE_RULE_SET_EXEMPT_PATHS),
// and CoreRuleSetBodyInspectionSize the most of a request body it reads
// (SWW_WAF_CORE_RULE_SET_BODY_INSPECTION_SIZE), 0 while it is off and it
// reads none.
// CoreRuleSetExclusionsFile is the path of the file of its exclusions
// (SWW_WAF_CORE_RULE_SET_EXCLUSIONS_FILE), "" while there is none.
WAFMode string
WAFParanoiaLevel int
WAFAnomalyThreshold int
WAFDisabledRules []int
WAFExemptPaths []string
WAFBodyLimit int64
CoreRuleSetExclusionsFile string
CoreRuleSetMode string
CoreRuleSetParanoiaLevel int
CoreRuleSetScoreThreshold int
CoreRuleSetDisabledRules []int
CoreRuleSetExemptPaths []string
CoreRuleSetBodyInspectionSize int64
CoreRuleSetExclusionsFile string
// Apps are the names of the apps each hostname serves, by hostname in
// lowercase (SWW_WAF_APPS): a request gets only its hostname's apps' fixes
// to the Core Rule Set. Apps is nil while SWW_WAF_APPS is unset, and every
@@ -241,25 +252,26 @@ type Config struct {
// TrapPaths are the paths a request for which is a clear sign of
// attack (SWW_WAF_TRAP_PATHS), each starting with / and without a ?.
TrapPaths []string
// ErrorBurstThreshold is the most requests of a client within a minute
// RefusalsLimitPerMinute is the most requests of a client within a minute
// that waf may refuse after a rule file or Core Rule Set match; one
// more breaks a limit (SWW_WAF_ERROR_BURST_THRESHOLD). 0 is off.
ErrorBurstThreshold int64
// more breaks a limit (SWW_WAF_REFUSALS_LIMIT_PER_MINUTE). 0 is off.
RefusalsLimitPerMinute int64
// LogRemoteURL is where every line on stdout is also sent
// (SWW_WAF_LOG_REMOTE_URL), nil while it is unset and nothing is sent.
// LogRemoteTLSCAs are the certificates a syslog+tls endpoint's
// certificate must chain to (SWW_WAF_LOG_REMOTE_TLS_CA_FILE), nil while
// it is unset and the host's own are used. LogRemoteBuffer is the most
// lines held while they wait to be sent (SWW_WAF_LOG_REMOTE_BUFFER).
// it is unset and the host's own are used. LogRemoteBufferLines is the
// most lines held while they wait to be sent
// (SWW_WAF_LOG_REMOTE_BUFFER_LINES).
// LogRemoteFacility is the number of the syslog facility
// (SWW_WAF_LOG_REMOTE_FACILITY), and LogRemoteAppName the APP-NAME
// (SWW_WAF_LOG_REMOTE_APP_NAME, by default InstanceName), of the records
// the lines are sent in.
LogRemoteURL *url.URL
LogRemoteTLSCAs *x509.CertPool
LogRemoteBuffer int
LogRemoteFacility int
LogRemoteAppName string
LogRemoteURL *url.URL
LogRemoteTLSCAs *x509.CertPool
LogRemoteBufferLines int
LogRemoteFacility int
LogRemoteAppName string
// AlertWebhookURL is where each alert is posted as JSON
// (SWW_WAF_ALERT_WEBHOOK_URL), nil while it is unset. AlertWebhookHeaders
// are sent with each (SWW_WAF_ALERT_WEBHOOK_HEADERS).
@@ -285,19 +297,19 @@ type Config struct {
// and bytes a minute and an hour per client (SWW_WAF_ANOMALY_CLIENT_*),
// per netblock around a client (SWW_WAF_ANOMALY_NET_*), per AS number
// (SWW_WAF_ANOMALY_ASN_*), for the whole service (SWW_WAF_ANOMALY_TOTAL_*)
// and per named netblock (SWW_WAF_WATCH_*), each 0 while it is off.
// AnomalyNetV4Prefix and AnomalyNetV6Prefix are the lengths of the
// netblock around a client (SWW_WAF_ANOMALY_NET_V4_PREFIX and
// SWW_WAF_ANOMALY_NET_V6_PREFIX), and WatchNets the named netblocks
// (SWW_WAF_WATCH_NETS).
// and per watched netblock (SWW_WAF_ANOMALY_WATCHED_NET_*), each 0 while
// it is off. AnomalyNetV4Prefix and AnomalyNetV6Prefix are the lengths
// of the netblock around a client (SWW_WAF_ANOMALY_NET_V4_PREFIX and
// SWW_WAF_ANOMALY_NET_V6_PREFIX), and AnomalyWatchedNets the watched
// netblocks (SWW_WAF_ANOMALY_WATCHED_NETS).
AnomalyClient anomaly.Thresholds
AnomalyNet anomaly.Thresholds
AnomalyASN anomaly.Thresholds
AnomalyTotal anomaly.Thresholds
AnomalyWatch anomaly.Thresholds
AnomalyWatchedNet anomaly.Thresholds
AnomalyNetV4Prefix int
AnomalyNetV6Prefix int
WatchNets []anomaly.NamedNetblock
AnomalyWatchedNets []anomaly.NamedNetblock
// settings are the values read, as given or by default, and the
// files they were read from, for the log line at start.
@@ -306,12 +318,12 @@ type Config struct {
// The values of SWW_WAF_CORE_RULE_SET_MODE.
const (
// WAFModeOff runs no request through the Core Rule Set.
WAFModeOff = off
// WAFModeDetect logs and alerts a match, and refuses nothing.
WAFModeDetect = "detect"
// WAFModeBlock refuses a match with 403.
WAFModeBlock = "block"
// CoreRuleSetModeOff runs no request through the Core Rule Set.
CoreRuleSetModeOff = off
// CoreRuleSetModeDetect logs and alerts a match, and refuses nothing.
CoreRuleSetModeDetect = "detect"
// CoreRuleSetModeBlock refuses a match with 403.
CoreRuleSetModeBlock = "block"
)
// fileSource is the SWW_WAF_LOOKUP_SOURCE that looks clients up in the
@@ -379,10 +391,10 @@ var (
"is not a name, = and a netblock, such as office=203.0.113.0/24")
errNotAbsolutePath = errors.New(
"is not an absolute path, such as /var/lib/sww")
errShortToken = errors.New("is shorter than 32 characters")
errNotMode = errors.New("is not enforce or observe")
errNotBytesCount = errors.New("is not response, request or both")
errNotPathPrefix = errors.New(
errShortToken = errors.New("is shorter than 32 characters")
errNotMode = errors.New("is not enforce or observe")
errNotBytesCounted = 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",
@@ -396,9 +408,11 @@ var (
errNotApp = errors.New(
"is not an app sww has fixes for; the apps are",
)
errNotWAFMode = errors.New("is not off, detect or block")
errNotParanoiaLevel = errors.New("is not a paranoia level, from 1 to 4")
errNotRuleID = errors.New(
errNotCoreRuleSetMode = errors.New("is not off, detect or block")
errNotParanoiaLevel = errors.New(
"is not a paranoia level, from 1 to 4",
)
errNotRuleID = errors.New(
"is not the id of a Core Rule Set rule, a whole number such as 942100",
)
errSetupRuleID = errors.New(
@@ -501,7 +515,7 @@ var (
)
)
// FromEnvironment reads the settings through v, normally the viper
// WAFFromEnvironment reads the settings through v, normally the viper
// NewViper returns, bound to the process's environment. A setting may
// instead be given as a file: the variable named by the setting's name
// with _FILE added names the file, which is read now (see lookup). A
@@ -509,9 +523,9 @@ var (
// invalid is an error that names it.
//
//nolint:funlen // one line for each setting, a list that grows with them
func FromEnvironment(v *viper.Viper) (*Config, error) {
func WAFFromEnvironment(v *viper.Viper) (*WAF, error) {
env := &environment{v: v}
cfg := &Config{
cfg := &WAF{
ListenAddr: env.address("SWW_WAF_LISTEN_ADDR"),
InstanceName: env.instanceName(),
Observe: env.observe("SWW_WAF_MODE", "enforce"),
@@ -545,15 +559,15 @@ func FromEnvironment(v *viper.Viper) (*Config, error) {
),
BytesLimitPerHour: env.size("SWW_WAF_BYTES_LIMIT_PER_HOUR", "50G"),
BytesLimitPerDay: env.size("SWW_WAF_BYTES_LIMIT_PER_DAY", "100G"),
BytesCount: env.bytesCount("SWW_WAF_BYTES_COUNT", "both"),
BytesCounted: env.bytesCounted("SWW_WAF_BYTES_COUNTED", "both"),
LookupSource: env.lookupSource(
"SWW_WAF_LOOKUP_SOURCE",
"geojs",
),
LookupDBPath: env.value("SWW_WAF_LOOKUP_DB_PATH", ""),
LookupTimeout: env.durationNotOff(
"SWW_WAF_LOOKUP_TIMEOUT",
"800ms",
ASNAndCountryLookupTimeout: env.milliseconds(
"SWW_WAF_ASN_AND_COUNTRY_LOOKUP_TIMEOUT_MS",
"800",
),
AddLookupHeaders: env.boolean("SWW_WAF_ADD_LOOKUP_HEADERS", "false"),
ExclusivelyAllowedCountries: env.countries(
@@ -562,16 +576,16 @@ func FromEnvironment(v *viper.Viper) (*Config, error) {
TagDeny: env.tags("SWW_WAF_TAG_DENY"),
TagLimitPercent: env.tagPercents("SWW_WAF_TAG_LIMIT_PERCENT"),
RouteWatchURL: env.routeWatchURL("SWW_WAF_ROUTEWATCH_URL"),
ASNBytesPercent: env.percents(
"SWW_WAF_ASN_BYTES_PERCENT",
ASNBytesLimitPercent: env.percents(
"SWW_WAF_ASN_BYTES_LIMIT_PERCENT",
ParseASN,
),
CountryBytesPercent: env.percents(
"SWW_WAF_COUNTRY_BYTES_PERCENT",
CountryBytesLimitPercent: env.percents(
"SWW_WAF_COUNTRY_BYTES_LIMIT_PERCENT",
parseCountry,
),
UnknownLimitPercent: env.percent(
"SWW_WAF_UNKNOWN_LIMIT_PERCENT",
UnknownCountryLimitPercent: env.percent(
"SWW_WAF_UNKNOWN_COUNTRY_LIMIT_PERCENT",
"100",
),
ASNLimitPercentURL: env.listURL("SWW_WAF_ASN_LIMIT_PERCENT_URL"),
@@ -585,13 +599,13 @@ func FromEnvironment(v *viper.Viper) (*Config, error) {
"SWW_WAF_ABUSEIPDB_DAILY_BUDGET",
"900",
),
ReputationCacheTTL: env.durationNotOff(
"SWW_WAF_REPUTATION_CACHE_TTL",
IPReputationCacheTTL: env.durationNotOff(
"SWW_WAF_IP_REPUTATION_CACHE_TTL",
"24h",
),
ReputationTimeout: env.durationNotOff(
"SWW_WAF_REPUTATION_TIMEOUT",
"2s",
DNSBLAbuseIPDBTimeout: env.milliseconds(
"SWW_WAF_DNSBL_ABUSEIPDB_TIMEOUT_MS",
"2000",
),
CrowdSecDecisionsURL: env.crowdSecDecisionsURL(
"SWW_WAF_CROWDSEC_LAPI_URL",
@@ -606,8 +620,8 @@ func FromEnvironment(v *viper.Viper) (*Config, error) {
"SWW_WAF_LIMIT_BAN_REPEAT_WINDOW",
"24h",
),
MaxBanDuration: env.durationNotOff(
"SWW_WAF_MAX_BAN_DURATION",
LimitBanPermanentAbove: env.durationNotOff(
"SWW_WAF_LIMIT_BAN_PERMANENT_ABOVE",
"7d",
),
AttackBanDuration: env.durationNotOff(
@@ -620,12 +634,12 @@ func FromEnvironment(v *viper.Viper) (*Config, error) {
"SWW_WAF_STATE_DIR",
"/var/lib/sww",
),
StateWriteDelay: env.durationNotOff(
"SWW_WAF_STATE_WRITE_DELAY",
BansWriteDelay: env.durationNotOff(
"SWW_WAF_BANS_WRITE_DELAY",
"10s",
),
StateCounterInterval: env.durationNotOff(
"SWW_WAF_STATE_COUNTER_INTERVAL",
StateWriteInterval: env.durationNotOff(
"SWW_WAF_STATE_WRITE_INTERVAL",
"15m",
),
CacheDir: env.absolutePath(
@@ -636,48 +650,62 @@ func FromEnvironment(v *viper.Viper) (*Config, error) {
"SWW_WAF_LOG_REQUEST_HEADERS",
"accept,accept-language,accept-encoding,content-type,origin,range",
),
LogLevel: env.logLevel("SWW_WAF_LOG_LEVEL", "info"),
LogLevel: env.logLevel(
"SWW_WAF_LOG_LEVEL",
"info",
),
AdminToken: env.token("SWW_WAF_ADMIN_TOKEN"),
MetricsToken: env.token("SWW_WAF_METRICS_TOKEN"),
MetricsTopN: env.numberNotOff("SWW_WAF_METRICS_TOP_N", "50"),
RulesDir: env.value("SWW_WAF_RULES_DIR", "/etc/sww/rules.d"),
RulesEnabled: env.boolean("SWW_WAF_RULES_ENABLED", "true"),
WAFMode: env.wafMode(
"SWW_WAF_CORE_RULE_SET_MODE",
WAFModeBlock,
MetricsBusiestCountriesAndASNs: env.numberNotOff(
"SWW_WAF_METRICS_BUSIEST_COUNTRIES_AND_ASNS",
"50",
),
WAFParanoiaLevel: env.paranoiaLevel(
RuleFilesDir: env.value(
"SWW_WAF_RULE_FILES_DIR",
"/etc/sww/rules.d",
),
RuleFilesEnabled: env.boolean(
"SWW_WAF_RULE_FILES_ENABLED",
"true",
),
CoreRuleSetMode: env.coreRuleSetMode(
"SWW_WAF_CORE_RULE_SET_MODE",
CoreRuleSetModeBlock,
),
CoreRuleSetParanoiaLevel: env.paranoiaLevel(
"SWW_WAF_CORE_RULE_SET_PARANOIA_LEVEL",
"1",
),
WAFAnomalyThreshold: env.numberOrOff(
"SWW_WAF_CORE_RULE_SET_ANOMALY_THRESHOLD",
CoreRuleSetScoreThreshold: env.numberOrOff(
"SWW_WAF_CORE_RULE_SET_SCORE_THRESHOLD",
"5",
),
WAFDisabledRules: env.ruleIDs("SWW_WAF_CORE_RULE_SET_DISABLED_RULES",
"920340,920420,920440,920640,930130,930140"),
WAFExemptPaths: env.pathPrefixes(
CoreRuleSetDisabledRules: env.ruleIDs(
"SWW_WAF_CORE_RULE_SET_DISABLED_RULES",
"920340,920420,920440,920640,930130,930140",
),
CoreRuleSetExemptPaths: env.pathPrefixes(
"SWW_WAF_CORE_RULE_SET_EXEMPT_PATHS",
"",
),
WAFBodyLimit: env.wafBodyLimit(
"SWW_WAF_CORE_RULE_SET_BODY_LIMIT",
CoreRuleSetBodyInspectionSize: env.coreRuleSetBodyInspectionSize(
"SWW_WAF_CORE_RULE_SET_BODY_INSPECTION_SIZE",
off,
),
CoreRuleSetExclusionsFile: env.filePath(
"SWW_WAF_CORE_RULE_SET_EXCLUSIONS_FILE"),
Apps: env.apps("SWW_WAF_APPS"),
TrapPaths: env.trapPaths("SWW_WAF_TRAP_PATHS"),
ErrorBurstThreshold: env.count(
"SWW_WAF_ERROR_BURST_THRESHOLD",
RefusalsLimitPerMinute: env.count(
"SWW_WAF_REFUSALS_LIMIT_PER_MINUTE",
"30",
),
LogRemoteURL: env.logRemoteURL("SWW_WAF_LOG_REMOTE_URL"),
LogRemoteTLSCAs: env.certificates(
"SWW_WAF_LOG_REMOTE_TLS_CA_FILE",
),
LogRemoteBuffer: env.numberNotOff(
"SWW_WAF_LOG_REMOTE_BUFFER",
LogRemoteBufferLines: env.numberNotOff(
"SWW_WAF_LOG_REMOTE_BUFFER_LINES",
"10000",
),
LogRemoteFacility: env.facility(
@@ -700,11 +728,11 @@ func FromEnvironment(v *viper.Viper) (*Config, error) {
"SWW_WAF_ALERT_MAX_PER_HOUR",
"60",
),
AnomalyClient: env.thresholds("SWW_WAF_ANOMALY_CLIENT_"),
AnomalyNet: env.thresholds("SWW_WAF_ANOMALY_NET_"),
AnomalyASN: env.thresholds("SWW_WAF_ANOMALY_ASN_"),
AnomalyTotal: env.thresholds("SWW_WAF_ANOMALY_TOTAL_"),
AnomalyWatch: env.thresholds("SWW_WAF_WATCH_"),
AnomalyClient: env.thresholds("SWW_WAF_ANOMALY_CLIENT_"),
AnomalyNet: env.thresholds("SWW_WAF_ANOMALY_NET_"),
AnomalyASN: env.thresholds("SWW_WAF_ANOMALY_ASN_"),
AnomalyTotal: env.thresholds("SWW_WAF_ANOMALY_TOTAL_"),
AnomalyWatchedNet: env.thresholds("SWW_WAF_ANOMALY_WATCHED_NET_"),
AnomalyNetV4Prefix: env.v4Prefix(
"SWW_WAF_ANOMALY_NET_V4_PREFIX",
"24",
@@ -713,15 +741,17 @@ func FromEnvironment(v *viper.Viper) (*Config, error) {
"SWW_WAF_ANOMALY_NET_V6_PREFIX",
"48",
),
WatchNets: env.namedNetblocks("SWW_WAF_WATCH_NETS"),
AnomalyWatchedNets: env.namedNetblocks(
"SWW_WAF_ANOMALY_WATCHED_NETS",
),
}
cfg.LogRemoteAppName = env.appName("SWW_WAF_LOG_REMOTE_APP_NAME",
cfg.InstanceName, cfg.LogRemoteURL != nil)
cfg.BlocklistAction, cfg.BlocklistLimitPercent = env.action(
"SWW_WAF_BLOCKLIST_ACTION", "deny")
cfg.ReputationAction, cfg.ReputationLimitPercent = env.action(
"SWW_WAF_REPUTATION_ACTION", "limit:25")
cfg.DNSBLAbuseIPDBAction, cfg.DNSBLAbuseIPDBLimitPercent = env.action(
"SWW_WAF_DNSBL_ABUSEIPDB_ACTION", "limit:25")
env.checkInstanceNameForNtfy(cfg.InstanceName, cfg.AlertNtfyURL != nil)
env.checkLookupDBPath(cfg)
@@ -741,7 +771,7 @@ func FromEnvironment(v *viper.Viper) (*Config, error) {
}
// WAFListenAddr reads only SWW_WAF_LISTEN_ADDR, which may be given as a
// file, as FromEnvironment does. The health check in waf's container needs
// file, as WAFFromEnvironment does. The health check in waf's container needs
// no other setting, so it reads no other, nor a file that another names.
func WAFListenAddr(v *viper.Viper) (string, error) {
env := &environment{v: v}
@@ -751,18 +781,18 @@ func WAFListenAddr(v *viper.Viper) (string, error) {
}
// InstanceName reads only SWW_WAF_INSTANCE_NAME, which may be given as a
// file, as FromEnvironment does, so that the line saying a setting is
// file, as WAFFromEnvironment does, so that the line saying a setting is
// invalid carries it too. A file that cannot be read gives the default
// here, and FromEnvironment then stops the start over it.
// here, and WAFFromEnvironment then stops the start over it.
func InstanceName(v *viper.Viper) string {
env := &environment{v: v}
return env.instanceName()
}
// LogValue makes a Config log as each setting's name with the value it was
// LogValue makes a WAF log as each setting's name with the value it was
// given, or its default.
func (c *Config) LogValue() slog.Value {
func (c *WAF) LogValue() slog.Value {
return slog.GroupValue(c.settings...)
}
@@ -795,10 +825,14 @@ func (e *environment) duration(name, defaultValue string) time.Duration {
return duration
}
// wafBodyLimit reads the setting that is the most of a request body the
// Core Rule Set reads.
func (e *environment) wafBodyLimit(name, defaultValue string) int64 {
limit, err := parseWAFBodyLimit(e.value(name, defaultValue))
// coreRuleSetBodyInspectionSize reads the setting that is the most of a
// request body the Core Rule Set reads.
func (e *environment) coreRuleSetBodyInspectionSize(
name, defaultValue string,
) int64 {
limit, err := parseCoreRuleSetBodyInspectionSize(
e.value(name, defaultValue),
)
e.check(name, err)
return limit
@@ -812,12 +846,12 @@ func (e *environment) count(name, defaultValue string) int64 {
return count
}
// bytesCount reads the setting that is which body bytes count toward the
// byte limits: response, request or both.
func (e *environment) bytesCount(name, defaultValue string) string {
// bytesCounted reads the setting that is which body bytes count toward the
// byte limits and the byte anomaly thresholds: response, request or both.
func (e *environment) bytesCounted(name, defaultValue string) string {
value := e.value(name, defaultValue)
if value != "response" && value != "request" && value != "both" {
e.check(name, fmt.Errorf("%q %w", value, errNotBytesCount))
e.check(name, fmt.Errorf("%q %w", value, errNotBytesCounted))
}
return value
@@ -859,12 +893,13 @@ func (e *environment) trapPaths(name string) []string {
return paths
}
// wafMode reads the setting that is what the Core Rule Set does: off,
// coreRuleSetMode reads the setting that is what the Core Rule Set does: off,
// detect or block.
func (e *environment) wafMode(name, defaultValue string) string {
func (e *environment) coreRuleSetMode(name, defaultValue string) string {
mode := e.value(name, defaultValue)
if mode != WAFModeOff && mode != WAFModeDetect && mode != WAFModeBlock {
e.check(name, fmt.Errorf("%q %w", mode, errNotWAFMode))
if mode != CoreRuleSetModeOff && mode != CoreRuleSetModeDetect &&
mode != CoreRuleSetModeBlock {
e.check(name, fmt.Errorf("%q %w", mode, errNotCoreRuleSetMode))
}
return mode
@@ -1078,7 +1113,7 @@ func (e *environment) lookupSource(name, defaultValue string) string {
// checkLookupDBPath refuses SWW_WAF_LOOKUP_SOURCE=file without
// SWW_WAF_LOOKUP_DB_PATH, and SWW_WAF_LOOKUP_DB_PATH with any other source:
// one source at a time.
func (e *environment) checkLookupDBPath(cfg *Config) {
func (e *environment) checkLookupDBPath(cfg *WAF) {
switch {
case cfg.LookupSource == fileSource && cfg.LookupDBPath == "":
e.check("SWW_WAF_LOOKUP_SOURCE", fmt.Errorf(
@@ -1100,9 +1135,9 @@ func (e *environment) checkLookupDBPath(cfg *Config) {
// up: the country list, SWW_WAF_ADD_LOOKUP_HEADERS, the tag settings that name
// an asn= or country= tag, the biased thresholds,
// SWW_WAF_ASN_LIMIT_PERCENT_URL among them, of which
// SWW_WAF_UNKNOWN_LIMIT_PERCENT needs them only below 100, where it lowers a
// SWW_WAF_UNKNOWN_COUNTRY_LIMIT_PERCENT needs them only below 100, where it lowers a
// limit, and the anomaly thresholds per AS number.
func (e *environment) checkCountriesAndLookups(cfg *Config) {
func (e *environment) checkCountriesAndLookups(cfg *WAF) {
for _, country := range cfg.ExclusivelyAllowedCountries {
if slices.Contains(cfg.TagDeny, CountryTagPrefix+country) {
e.check("SWW_WAF_EXCLUSIVELY_ALLOWED_COUNTRIES",
@@ -1129,9 +1164,9 @@ func (e *environment) checkCountriesAndLookups(cfg *Config) {
{"SWW_WAF_TAG_DENY", slices.ContainsFunc(cfg.TagDeny, onlyLookupGives)},
{"SWW_WAF_TAG_LIMIT_PERCENT", slices.ContainsFunc(
slices.Collect(maps.Keys(cfg.TagLimitPercent)), onlyLookupGives)},
{"SWW_WAF_ASN_BYTES_PERCENT", len(cfg.ASNBytesPercent) > 0},
{"SWW_WAF_COUNTRY_BYTES_PERCENT", len(cfg.CountryBytesPercent) > 0},
{"SWW_WAF_UNKNOWN_LIMIT_PERCENT", cfg.UnknownLimitPercent < 100},
{"SWW_WAF_ASN_BYTES_LIMIT_PERCENT", len(cfg.ASNBytesLimitPercent) > 0},
{"SWW_WAF_COUNTRY_BYTES_LIMIT_PERCENT", len(cfg.CountryBytesLimitPercent) > 0},
{"SWW_WAF_UNKNOWN_COUNTRY_LIMIT_PERCENT", cfg.UnknownCountryLimitPercent < 100},
{"SWW_WAF_ASN_LIMIT_PERCENT_URL", cfg.ASNLimitPercentURL != ""},
{"SWW_WAF_ANOMALY_ASN_REQUESTS_PER_MINUTE", cfg.AnomalyASN.RequestsPerMinute > 0},
{"SWW_WAF_ANOMALY_ASN_REQUESTS_PER_HOUR", cfg.AnomalyASN.RequestsPerHour > 0},
@@ -1150,7 +1185,7 @@ func (e *environment) checkCountriesAndLookups(cfg *Config) {
// checkASNLimitPercentURL refuses SWW_WAF_ASN_LIMIT_PERCENT_URL naming a
// blocklist too: the file at a URL is fetched as one list or the other.
func (e *environment) checkASNLimitPercentURL(cfg *Config) {
func (e *environment) checkASNLimitPercentURL(cfg *WAF) {
if slices.Contains(cfg.BlocklistURLs, cfg.ASNLimitPercentURL) {
e.check("SWW_WAF_ASN_LIMIT_PERCENT_URL",
fmt.Errorf("%q %w", cfg.ASNLimitPercentURL, errInBlocklistURLs))
@@ -1160,7 +1195,7 @@ func (e *environment) checkASNLimitPercentURL(cfg *Config) {
// checkCrowdSec refuses SWW_WAF_CROWDSEC_LAPI_URL without
// SWW_WAF_CROWDSEC_LAPI_KEY, the key without the URL, and a decision list
// that is fetched as another list too.
func (e *environment) checkCrowdSec(cfg *Config) {
func (e *environment) checkCrowdSec(cfg *WAF) {
decisionsURL := cfg.CrowdSecDecisionsURL
switch {
@@ -1186,7 +1221,7 @@ func (e *environment) checkCrowdSec(cfg *Config) {
// checkTagLists refuses a list of SWW_WAF_TAG_LISTS whose URL is fetched as
// another list too: the file at a URL is read as one kind of list.
func (e *environment) checkTagLists(cfg *Config) {
func (e *environment) checkTagLists(cfg *WAF) {
for _, list := range cfg.TagLists {
if slices.Contains(cfg.BlocklistURLs, list.URL) ||
list.URL == cfg.ASNLimitPercentURL || list.URL == cfg.CrowdSecDecisionsURL {
@@ -1201,7 +1236,7 @@ func (e *environment) checkTagLists(cfg *Config) {
// checkTags refuses a tag of SWW_WAF_TAG_DENY or SWW_WAF_TAG_LIMIT_PERCENT that would
// never apply: crowdsec, whose request is refused before tags are acted on, and
// any tag these settings cannot give a request.
func (e *environment) checkTags(cfg *Config) {
func (e *environment) checkTags(cfg *WAF) {
for _, tag := range cfg.TagDeny {
e.check("SWW_WAF_TAG_DENY", checkTag(cfg, tag))
}
@@ -1212,7 +1247,7 @@ func (e *environment) checkTags(cfg *Config) {
}
// checkTag returns an error when tag would never apply, as checkTags says.
func checkTag(cfg *Config, tag string) error {
func checkTag(cfg *WAF, tag string) error {
if tag == CrowdSecTag {
return fmt.Errorf("%q %w", tag, errCrowdSecTag)
}
@@ -1225,17 +1260,17 @@ func checkTag(cfg *Config, tag string) error {
}
// requestCanGetTag reports whether anything cfg sets up can give a request tag.
func requestCanGetTag(cfg *Config, tag string) bool {
func requestCanGetTag(cfg *WAF, tag string) bool {
isListName := func(list TagList) bool { return list.Name == tag }
if isLookupTag(tag) || slices.ContainsFunc(cfg.TagLists, isListName) {
return true
}
if tag == ExploitSignatureTag {
coreRuleSetOn := cfg.WAFMode != WAFModeOff &&
cfg.WAFAnomalyThreshold != 0
coreRuleSetOn := cfg.CoreRuleSetMode != CoreRuleSetModeOff &&
cfg.CoreRuleSetScoreThreshold != 0
return cfg.RulesEnabled || coreRuleSetOn
return cfg.RuleFilesEnabled || coreRuleSetOn
}
if tag == AbuseIPDBTag {
@@ -1256,14 +1291,14 @@ func requestCanGetTag(cfg *Config, tag string) bool {
// TagsNeedLookup reports whether SWW_WAF_TAG_DENY or SWW_WAF_TAG_LIMIT_PERCENT
// names an asn= or country= tag.
func (c *Config) TagsNeedLookup() bool {
func (c *WAF) TagsNeedLookup() bool {
return slices.ContainsFunc(c.TagDeny, isLookupTag) ||
tagLimitNeedsLookups(c)
}
// tagLimitNeedsLookups reports whether SWW_WAF_TAG_LIMIT_PERCENT names an asn=
// or country= tag.
func tagLimitNeedsLookups(cfg *Config) bool {
func tagLimitNeedsLookups(cfg *WAF) bool {
for tag := range cfg.TagLimitPercent {
if isLookupTag(tag) {
return true
@@ -1639,10 +1674,10 @@ func durationOrDays(value string) (time.Duration, error) {
return time.Duration(n) * day, nil
}
// parseWAFBodyLimit reads the most of a request body the Core Rule Set
// parseCoreRuleSetBodyInspectionSize reads the most of a request body the Core Rule Set
// reads: a size as parseSize reads it, or off, but at most 64K, since waf
// gets no more of a body than that.
func parseWAFBodyLimit(value string) (int64, error) {
func parseCoreRuleSetBodyInspectionSize(value string) (int64, error) {
limit, err := parseSize(value)
if err != nil {
return 0, err
File diff suppressed because it is too large. Load diff
+33 -31
View File
@@ -1,9 +1,9 @@
// Package coreruleset runs the OWASP Core Rule Set 4.25.0, through Coraza, on the
// method, the URL with its query and the headers of a request, and on its
// body while SWW_WAF_CORE_RULE_SET_BODY_LIMIT is set, with the five changes sww
// makes to it, the fixes for the apps SWW_WAF_APPS names, and the exclusions in
// SWW_WAF_CORE_RULE_SET_EXCLUSIONS_FILE, as "Attack detection" under "Settings"
// in SPEC.md describes them. It reads no response.
// body while SWW_WAF_CORE_RULE_SET_BODY_INSPECTION_SIZE is set, with the
// five changes sww makes to it, the fixes for the apps SWW_WAF_APPS names,
// and the exclusions in SWW_WAF_CORE_RULE_SET_EXCLUSIONS_FILE, as "Attack
// detection" under "Settings" in SPEC.md describes them. It reads no response.
//
// sww writes only to its state directory, so Coraza is built with
// its no_fs_access tag, as the Dockerfile and script/build build it: of a
@@ -34,8 +34,8 @@ import (
// directives are the Core Rule Set as sww runs it, with the
// paranoia level for %d, bodyDirectives for the first %s while
// SWW_WAF_CORE_RULE_SET_BODY_LIMIT is set, the exclusions file for the second,
// and the directives of every app in apps for the third.
// SWW_WAF_CORE_RULE_SET_BODY_INSPECTION_SIZE is set, the exclusions file for
// the second, and the directives of every app in apps for the third.
// Each rule sww adds has an id from 900000 to 900999, the ids the
// Core Rule Set keeps for the rules that set it up, which
// SWW_WAF_CORE_RULE_SET_DISABLED_RULES and the exclusions file refuse, so that no
@@ -43,7 +43,7 @@ import (
// without the spaces at the start of the next.
const directives = `
# The engine only detects. sww compares the request's anomaly
# score with SWW_WAF_CORE_RULE_SET_ANOMALY_THRESHOLD itself, in block and detect mode
# score with SWW_WAF_CORE_RULE_SET_SCORE_THRESHOLD itself, in block and detect mode
# alike. It reads no body, unless bodyDirectives switch that on.
SecRuleEngine DetectionOnly
SecRequestBodyAccess Off
@@ -224,9 +224,10 @@ type Params struct {
// DisabledRules are the ids of the rules switched off
// (SWW_WAF_CORE_RULE_SET_DISABLED_RULES).
DisabledRules []int
// BodyLimit is the most of a request body the Core Rule Set reads
// (SWW_WAF_CORE_RULE_SET_BODY_LIMIT), 0 while it is off and it reads none.
BodyLimit int64
// BodyInspectionSize is the most of a request body the Core Rule Set
// reads (SWW_WAF_CORE_RULE_SET_BODY_INSPECTION_SIZE), 0 while it is off
// and it reads none.
BodyInspectionSize int64
// ExclusionsFile is the path of a file of exclusions loaded before the
// Core Rule Set's rules (SWW_WAF_CORE_RULE_SET_EXCLUSIONS_FILE), "" while
// there is none.
@@ -336,8 +337,8 @@ func checkExclusions(exclusions string) error {
// CoreRuleSet is the Core Rule Set, ready to inspect requests. It is safe
// for concurrent use.
type CoreRuleSet struct {
engine coraza.WAF
bodyLimit int64
engine coraza.WAF
bodyInspectionSize int64
// appsByHostname are the names of the apps each hostname serves, by
// hostname in lowercase (SWW_WAF_APPS), nil while SWW_WAF_APPS is unset.
appsByHostname map[string][]string
@@ -345,14 +346,14 @@ type CoreRuleSet struct {
// New returns the Core Rule Set with the five changes and the apps' fixes,
// at params' paranoia level, without the rules it switches off, reading
// request bodies up to params' limit, and with params' exclusions file
// loaded before the Core Rule Set's rules. An exclusions file is an error
// if it cannot be read or does not parse, or if checkExclusions refuses
// it.
// request bodies up to params' body inspection size, and with params'
// exclusions file loaded before the Core Rule Set's rules. An exclusions
// file is an error if it cannot be read or does not parse, or if
// checkExclusions refuses it.
func New(params Params) (*CoreRuleSet, error) {
body := ""
if params.BodyLimit > 0 {
body = fmt.Sprintf(bodyDirectives, params.BodyLimit)
if params.BodyInspectionSize > 0 {
body = fmt.Sprintf(bodyDirectives, params.BodyInspectionSize)
}
exclusions := ""
@@ -396,9 +397,9 @@ func New(params Params) (*CoreRuleSet, error) {
}
return &CoreRuleSet{
engine: engine,
bodyLimit: params.BodyLimit,
appsByHostname: params.Apps,
engine: engine,
bodyInspectionSize: params.BodyInspectionSize,
appsByHostname: params.Apps,
}, nil
}
@@ -481,18 +482,19 @@ func (c *CoreRuleSet) Inspect(
}
// readBody reads body, the body of the request in tx, which has run on
// the request's headers, while SWW_WAF_CORE_RULE_SET_BODY_LIMIT is set and the body is
// of a kind the Core Rule Set reads: form data and multipart, of which it
// reads the first c.bodyLimit bytes, and JSON and XML, which it reads only
// when they are no longer than that, since they cannot be read in part.
// readBody reads one byte past the limit, to tell which they are, gives
// the Core Rule Set what it reads, and returns what it read and the error
// that ended the reading early, if one did.
// the request's headers, while SWW_WAF_CORE_RULE_SET_BODY_INSPECTION_SIZE is
// set and the body is of a kind the Core Rule Set reads: form data and
// multipart, of which it reads the first c.bodyInspectionSize bytes, and
// JSON and XML, which it reads only when they are no longer than that,
// since they cannot be read in part.
// readBody reads one byte past c.bodyInspectionSize, to tell which they
// are, gives the Core Rule Set what it reads, and returns what it read and
// the error that ended the reading early, if one did.
func (c *CoreRuleSet) readBody(
tx types.Transaction,
body io.Reader,
) ([]byte, error) {
if c.bodyLimit == 0 {
if c.bodyInspectionSize == 0 {
return nil, nil
}
@@ -509,9 +511,9 @@ func (c *CoreRuleSet) readBody(
return nil, nil
}
read, err := io.ReadAll(io.LimitReader(body, c.bodyLimit+1))
read, err := io.ReadAll(io.LimitReader(body, c.bodyInspectionSize+1))
if inPart || (err == nil && int64(len(read)) <= c.bodyLimit) {
if inPart || (err == nil && int64(len(read)) <= c.bodyInspectionSize) {
// Coraza holds what it reads of the body in memory, up to the
// limit, so this cannot fail.
_, _, _ = tx.WriteRequestBody(read)
+49 -36
View File
@@ -1089,7 +1089,8 @@ var findMisfiringPasswords = sync.OnceValues(func() ([]string, error) {
const workers, perBody = 16, 10
crs, err := loaderFor(coreruleset.Params{
ParanoiaLevel: 1, DisabledRules: defaultDisabledRules, BodyLimit: bodyLimit,
ParanoiaLevel: 1, DisabledRules: defaultDisabledRules,
BodyInspectionSize: bodyInspectionSize,
})()
if err != nil {
return nil, err
@@ -1166,15 +1167,18 @@ func misfiringPasswords(t *testing.T) []string {
func wantRandomPasswordsPass(t *testing.T, r request, name string) {
t.Helper()
const threshold = 5 // SWW_WAF_CORE_RULE_SET_ANOMALY_THRESHOLD's default
const threshold = 5 // SWW_WAF_CORE_RULE_SET_SCORE_THRESHOLD's default
crs := readingBodies(t)
misfiring := misfiringPasswords(t)
names := []string{name}
body := passwordBody(t, r, names, misfiring...)
if len(body) > bodyLimit {
t.Fatalf("a body of %d bytes is cut at bodyLimit", len(body))
if len(body) > bodyInspectionSize {
t.Fatalf(
"a body of %d bytes is cut at bodyInspectionSize",
len(body),
)
}
if got, _ := inspectBody(t, crs, r, body); got.Score < threshold {
@@ -1197,7 +1201,7 @@ func wantRandomPasswordsPass(t *testing.T, r request, name string) {
func wantPasswordAttacksPass(t *testing.T, r request, names ...string) {
t.Helper()
const threshold = 5 // SWW_WAF_CORE_RULE_SET_ANOMALY_THRESHOLD's default
const threshold = 5 // SWW_WAF_CORE_RULE_SET_SCORE_THRESHOLD's default
for _, attack := range []string{
"' OR '1'='1", pathTraversal, "; cat /etc/passwd",
@@ -1217,7 +1221,7 @@ func wantPasswordAttacksPass(t *testing.T, r request, names ...string) {
func wantPasswordAttacksRefused(t *testing.T, r request, name string) {
t.Helper()
const threshold = 5 // SWW_WAF_CORE_RULE_SET_ANOMALY_THRESHOLD's default
const threshold = 5 // SWW_WAF_CORE_RULE_SET_SCORE_THRESHOLD's default
for _, attack := range []string{scriptTag, "javascript:alert(1)"} {
body := passwordBody(t, r, []string{name}, attack)
@@ -1307,7 +1311,7 @@ func TestGiteaPasswordsPass(t *testing.T) {
func TestPasswordFieldsStayCheckedInTheQuery(t *testing.T) {
t.Parallel()
const threshold = 5 // SWW_WAF_CORE_RULE_SET_ANOMALY_THRESHOLD's default
const threshold = 5 // SWW_WAF_CORE_RULE_SET_SCORE_THRESHOLD's default
crs := readingBodies(t)
@@ -1364,7 +1368,7 @@ func TestPasswordFieldsStayCheckedInTheQuery(t *testing.T) {
func TestPasswordExclusionsAreNarrow(t *testing.T) {
t.Parallel()
const threshold = 5 // SWW_WAF_CORE_RULE_SET_ANOMALY_THRESHOLD's default
const threshold = 5 // SWW_WAF_CORE_RULE_SET_SCORE_THRESHOLD's default
crs := readingBodies(t)
@@ -1400,7 +1404,7 @@ func TestPasswordExclusionsAreNarrow(t *testing.T) {
func TestGiteaAttacksAreRefused(t *testing.T) {
t.Parallel()
const threshold = 5 // SWW_WAF_CORE_RULE_SET_ANOMALY_THRESHOLD's default
const threshold = 5 // SWW_WAF_CORE_RULE_SET_SCORE_THRESHOLD's default
crs := atDefaults(t)
@@ -1480,7 +1484,7 @@ func TestGiteaSearchOnAUsersOrAnOrganisationsPageLetsLsThrough(
}
// A command after a ";" in q is still refused on each of these paths.
const threshold = 5 // SWW_WAF_CORE_RULE_SET_ANOMALY_THRESHOLD's default
const threshold = 5 // SWW_WAF_CORE_RULE_SET_SCORE_THRESHOLD's default
for _, target := range []string{
"/alice?tab=repositories&q=",
@@ -1505,12 +1509,12 @@ const (
)
// readingBodiesUpTo64K returns the Core Rule Set as sww runs it by default,
// but reading bodies up to the largest SWW_WAF_CORE_RULE_SET_BODY_LIMIT.
// but reading bodies up to the largest SWW_WAF_CORE_RULE_SET_BODY_INSPECTION_SIZE.
func readingBodiesUpTo64K(t *testing.T) *coreruleset.CoreRuleSet {
t.Helper()
return shared(t, coreruleset.Params{
ParanoiaLevel: 1, DisabledRules: defaultDisabledRules, BodyLimit: 64<<10 - 1,
ParanoiaLevel: 1, DisabledRules: defaultDisabledRules, BodyInspectionSize: 64<<10 - 1,
})
}
@@ -1572,7 +1576,7 @@ func wordpressAttackTargets(attack string) []string {
func TestWordPressAttacksAreRefused(t *testing.T) {
t.Parallel()
const threshold = 5 // SWW_WAF_CORE_RULE_SET_ANOMALY_THRESHOLD's default
const threshold = 5 // SWW_WAF_CORE_RULE_SET_SCORE_THRESHOLD's default
defaults, bodies := atDefaults(t), readingBodiesUpTo64K(t)
@@ -2310,8 +2314,9 @@ func TestWordPressExclusionsAreNarrow(t *testing.T) {
wantBody(t, crs, signIn, body, matched(930120), body)
}
// bodyLimit is SWW_WAF_CORE_RULE_SET_BODY_LIMIT in the tests that read bodies.
const bodyLimit = 8 << 10
// bodyInspectionSize is SWW_WAF_CORE_RULE_SET_BODY_INSPECTION_SIZE in the
// tests that read bodies.
const bodyInspectionSize = 8 << 10
// The Content-Type headers of the kinds of body the Core Rule Set reads.
const (
@@ -2334,12 +2339,13 @@ const pathTraversal = "../../etc/passwd"
const shellCommand = "; wget http://example.com/x.sh"
// readingBodies returns the Core Rule Set as sww runs it by
// default, but reading bodies up to bodyLimit.
// default, but reading bodies up to bodyInspectionSize.
func readingBodies(t *testing.T) *coreruleset.CoreRuleSet {
t.Helper()
return shared(t, coreruleset.Params{
ParanoiaLevel: 1, DisabledRules: defaultDisabledRules, BodyLimit: bodyLimit,
ParanoiaLevel: 1, DisabledRules: defaultDisabledRules,
BodyInspectionSize: bodyInspectionSize,
})
}
@@ -2393,7 +2399,7 @@ func wantBody(
}
}
func TestBodiesAreReadOnlyWhileBodyLimitIsSet(t *testing.T) {
func TestBodiesAreReadOnlyWhileBodyInspectionSizeIsSet(t *testing.T) {
t.Parallel()
off, on := atDefaults(t), readingBodies(t)
@@ -2413,7 +2419,7 @@ func TestFormDataAndMultipartAreReadUpToTheLimit(t *testing.T) {
t.Parallel()
crs := readingBodies(t)
pad := strings.Repeat("a", bodyLimit)
pad := strings.Repeat("a", bodyInspectionSize)
for _, tc := range []struct{ header, attackFirst, attackLast string }{
{
@@ -2426,20 +2432,20 @@ func TestFormDataAndMultipartAreReadUpToTheLimit(t *testing.T) {
},
} {
wantBody(t, crs, post(tc.header), tc.attackFirst, matched(942100),
tc.attackFirst[:bodyLimit+1])
tc.attackFirst[:bodyInspectionSize+1])
wantBody(
t,
crs,
post(tc.header),
tc.attackLast,
coreruleset.Result{},
tc.attackLast[:bodyLimit+1],
tc.attackLast[:bodyInspectionSize+1],
)
}
// To the byte: a system file's path is found when it ends at the limit,
// and not when its last letter is past it, which is still read.
atLimit := padded("pad=", "&q=/etc/passwd", bodyLimit)
atLimit := padded("pad=", "&q=/etc/passwd", bodyInspectionSize)
wantBody(
t,
crs,
@@ -2449,7 +2455,7 @@ func TestFormDataAndMultipartAreReadUpToTheLimit(t *testing.T) {
atLimit,
)
pastLimit := padded("pad=", "&q=/etc/passwd", bodyLimit+1)
pastLimit := padded("pad=", "&q=/etc/passwd", bodyInspectionSize+1)
wantBody(
t,
crs,
@@ -2469,10 +2475,10 @@ func TestJSONAndXMLAreReadOnlyWhenNoLargerThanTheLimit(t *testing.T) {
{jsonBody, `{"q":"` + injection + `","pad":"`, `"}`},
{xmlBody, "<r><q>" + injection + "</q><pad>", "</pad></r>"},
} {
fits := padded(tc.head, tc.tail, bodyLimit)
fits := padded(tc.head, tc.tail, bodyInspectionSize)
wantBody(t, crs, post(tc.header), fits, matched(942100), fits)
larger := padded(tc.head, tc.tail, bodyLimit+1)
larger := padded(tc.head, tc.tail, bodyInspectionSize+1)
wantBody(
t,
crs,
@@ -2529,7 +2535,7 @@ func TestContentEncodingIsRefusedOnTheKindsOfBodyTheCoreRuleSetReads(
// Whatever its size: a JSON body larger than the limit is not read, but
// Content-Encoding on it is refused all the same.
larger := strings.Repeat("a", bodyLimit+1)
larger := strings.Repeat("a", bodyInspectionSize+1)
wantBody(t, crs, post(jsonBody, gzip), larger, matched(920450), larger)
// It is allowed on a body of any other kind, and on every body while no
@@ -2748,7 +2754,7 @@ func giteaBranchNamesInBodies() []nameInBody {
func TestGiteaNamesInBodiesStillRefuseAttacks(t *testing.T) {
t.Parallel()
const threshold = 5 // SWW_WAF_CORE_RULE_SET_ANOMALY_THRESHOLD's default
const threshold = 5 // SWW_WAF_CORE_RULE_SET_SCORE_THRESHOLD's default
crs := readingBodies(t)
@@ -2849,7 +2855,7 @@ func TestBodyCorazaCannotParseIsAMatch(t *testing.T) {
// that has no colon, before the attack. They do so padded past the
// limit too, which cuts them in the padding.
noColon := "--b\r\nContent-Disposition form-data; name=\"a\"\r\n\r\n1\r\n"
pad := field("pad", strings.Repeat("a", bodyLimit))
pad := field("pad", strings.Repeat("a", bodyInspectionSize))
for _, tc := range []struct{ header, head string }{
{multipart + "; boundary=c", ""},
@@ -2867,7 +2873,7 @@ func TestBodyCorazaCannotParseIsAMatch(t *testing.T) {
body = tc.head + field("q", injection) + pad + end
wantBody(t, crs, post(tc.header), body, matched(900440, 900450),
body[:bodyLimit+1])
body[:bodyInspectionSize+1])
}
}
@@ -2880,7 +2886,10 @@ func TestMultipartBodyCutBeforeAPartHeadersColonIsAMatch(t *testing.T) {
cut := "--b\r\nContent-Di"
first := field(
"pad",
strings.Repeat("a", bodyLimit-len(field("pad", ""))-len(cut)),
strings.Repeat(
"a",
bodyInspectionSize-len(field("pad", ""))-len(cut),
),
)
body := first + cut + "sposition: form-data; name=\"q\"\r\n\r\n1\r\n" + end
@@ -2890,7 +2899,7 @@ func TestMultipartBodyCutBeforeAPartHeadersColonIsAMatch(t *testing.T) {
post(multipart),
body,
matched(900440, 900450),
body[:bodyLimit+1],
body[:bodyInspectionSize+1],
)
}
@@ -2909,12 +2918,15 @@ func TestMultipartBodyCutBeforeALineFeedInAPartsHeadersIsAMatch(
for _, cut := range []int{len(headerLine), len(headerLine + "\n\r")} {
first := field(
"pad",
strings.Repeat("a", bodyLimit-len(field("pad", ""))-cut),
strings.Repeat(
"a",
bodyInspectionSize-len(field("pad", ""))-cut,
),
)
body := first + field("q", "1") + end
wantBody(t, crs, post(multipart), body, matched(900440, 900450),
body[:bodyLimit+1])
body[:bodyInspectionSize+1])
}
}
@@ -2929,7 +2941,8 @@ func TestCorazaWritesNoFile(t *testing.T) {
// The test loads its own Core Rule Set, since loading is part of what
// it checks.
crs, err := coreruleset.New(coreruleset.Params{
ParanoiaLevel: 1, DisabledRules: defaultDisabledRules, BodyLimit: bodyLimit,
ParanoiaLevel: 1, DisabledRules: defaultDisabledRules,
BodyInspectionSize: bodyInspectionSize,
})
if err != nil {
t.Fatalf("load the Core Rule Set: %v", err)
@@ -2941,11 +2954,11 @@ func TestCorazaWritesNoFile(t *testing.T) {
wantBody(t, crs, post(multipart), body, matched(942100), body)
}
func TestBodyLimitOf1GLoads(t *testing.T) {
func TestBodyInspectionSizeOf1GLoads(t *testing.T) {
t.Parallel()
_, err := coreruleset.New(
coreruleset.Params{ParanoiaLevel: 1, BodyLimit: 1 << 30},
coreruleset.Params{ParanoiaLevel: 1, BodyInspectionSize: 1 << 30},
)
if err != nil {
t.Errorf("load the Core Rule Set reading bodies up to 1G: %v", err)
+1 -1
View File
@@ -4,7 +4,7 @@
# request, before the rules it switches off, so this file is loaded before the
# Core Rule Set's rules. Each comment names a request a rule refused. An
# exclusion on a field of a body applies only when the query has no parameter
# of that name, so it matters only while SWW_WAF_CORE_RULE_SET_BODY_LIMIT is
# of that name, so it matters only while SWW_WAF_CORE_RULE_SET_BODY_INSPECTION_SIZE is
# set. The exclusions above the comment "The fields below are in bodies" also
# cover a body field of the same name whatever the query holds, and only those
# below it need a query without that name.
+1 -1
View File
@@ -9,7 +9,7 @@ import (
"sneak.berlin/go/smallwebwaf/internal/coreruleset"
)
// drupalThreshold is SWW_WAF_CORE_RULE_SET_ANOMALY_THRESHOLD's default.
// drupalThreshold is SWW_WAF_CORE_RULE_SET_SCORE_THRESHOLD's default.
const drupalThreshold = 5
// drupalAddArticle is Drupal's form for a new article.
+1 -1
View File
@@ -132,7 +132,7 @@ SecRule REQUEST_FILENAME "@streq /admin/repos" \
# They skip these rules:
# 932120: fix-powershell-module
# 932260: feat/docker-support
# They are read only while SWW_WAF_CORE_RULE_SET_BODY_LIMIT is set, and skip
# They are read only while SWW_WAF_CORE_RULE_SET_BODY_INSPECTION_SIZE is set, and skip
# nothing when the query has a parameter of the same name.
SecRule REQUEST_FILENAME "@rx ^/[^/]+/[^/]+/settings/rename_branch$" \
"id:900666,phase:1,pass,nolog,chain"
+2 -2
View File
@@ -91,7 +91,7 @@ var forgejoAttacks = []string{
func TestForgejoAttacksAreRefusedInItsSearches(t *testing.T) {
t.Parallel()
const threshold = 5 // SWW_WAF_CORE_RULE_SET_ANOMALY_THRESHOLD's default
const threshold = 5 // SWW_WAF_CORE_RULE_SET_SCORE_THRESHOLD's default
defaults, bodies := atDefaults(t), readingBodies(t)
@@ -113,7 +113,7 @@ func TestForgejoAttacksAreRefusedInItsSearches(t *testing.T) {
func TestForgejoExclusionsAreNarrow(t *testing.T) {
t.Parallel()
const threshold = 5 // SWW_WAF_CORE_RULE_SET_ANOMALY_THRESHOLD's default
const threshold = 5 // SWW_WAF_CORE_RULE_SET_SCORE_THRESHOLD's default
crs := atDefaults(t)
+1 -1
View File
@@ -3,7 +3,7 @@
# rules it switches off, so this file is loaded before the Core Rule Set's
# rules. Each comment names a request a rule refused. An exclusion on a field of
# a body applies only when the query has no parameter of that name, so it
# matters only while SWW_WAF_CORE_RULE_SET_BODY_LIMIT is set.
# matters only while SWW_WAF_CORE_RULE_SET_BODY_INSPECTION_SIZE is set.
#
# Ghost's admin and its integrations call the Admin API under /ghost/api/admin/,
# themes and Ghost's own scripts call the Content API under /ghost/api/content/,
+1 -1
View File
@@ -11,7 +11,7 @@ import (
"sneak.berlin/go/smallwebwaf/internal/coreruleset"
)
// ghostThreshold is SWW_WAF_CORE_RULE_SET_ANOMALY_THRESHOLD's default.
// ghostThreshold is SWW_WAF_CORE_RULE_SET_SCORE_THRESHOLD's default.
const ghostThreshold = 5
// Paths of Ghost's Admin API, Content API and members' API.
+1 -1
View File
@@ -394,7 +394,7 @@ SecRule REQUEST_FILENAME "@rx ^/[^/]+/[^/]+/lastcommit/" \
"id:900633,phase:1,pass,nolog,ctl:ruleRemoveTargetById=932260;ARGS:refSubUrl"
# The requests below name a file or a branch in a body field, which is read only
# while SWW_WAF_CORE_RULE_SET_BODY_LIMIT is set. Each field skips the rules that
# while SWW_WAF_CORE_RULE_SET_BODY_INSPECTION_SIZE is set. Each field skips the rules that
# refused it.
# Each field below that names a branch skips these rules:
# 932120: fix-powershell-module
+3 -3
View File
@@ -85,7 +85,7 @@ func TestGiteaAdminFormsLetHardValuesThroughInTheBody(t *testing.T) {
func TestGiteaFormsThatAdoptARepositoryLetItsNameThrough(t *testing.T) {
t.Parallel()
const threshold = 5 // SWW_WAF_CORE_RULE_SET_ANOMALY_THRESHOLD's default
const threshold = 5 // SWW_WAF_CORE_RULE_SET_SCORE_THRESHOLD's default
crs := readingBodies(t)
@@ -146,7 +146,7 @@ func TestGiteaFormsThatAdoptARepositoryLetItsNameThrough(t *testing.T) {
func TestGiteaAttacksAreRefusedInItsSearches(t *testing.T) {
t.Parallel()
const threshold = 5 // SWW_WAF_CORE_RULE_SET_ANOMALY_THRESHOLD's default
const threshold = 5 // SWW_WAF_CORE_RULE_SET_SCORE_THRESHOLD's default
defaults, bodies := atDefaults(t), readingBodies(t)
@@ -170,7 +170,7 @@ func TestGiteaAttacksAreRefusedInItsSearches(t *testing.T) {
func TestGiteaSearchExclusionsAreNarrow(t *testing.T) {
t.Parallel()
const threshold = 5 // SWW_WAF_CORE_RULE_SET_ANOMALY_THRESHOLD's default
const threshold = 5 // SWW_WAF_CORE_RULE_SET_SCORE_THRESHOLD's default
crs := atDefaults(t)
+1 -1
View File
@@ -5,7 +5,7 @@
# file is loaded before the Core Rule Set's rules. Each comment names a request
# a rule refused. An exclusion on a field of a body applies only when the query
# has no parameter of that name, so it matters only while
# SWW_WAF_CORE_RULE_SET_BODY_LIMIT is set.
# SWW_WAF_CORE_RULE_SET_BODY_INSPECTION_SIZE is set.
#
# Gitea serves /api/v1/ too, and Drupal serves /admin/. So only Mastodon's own
# resources under them count as Mastodon's paths.
+1 -1
View File
@@ -12,7 +12,7 @@ import (
"sneak.berlin/go/smallwebwaf/internal/coreruleset"
)
// mastodonThreshold is SWW_WAF_CORE_RULE_SET_ANOMALY_THRESHOLD's default.
// mastodonThreshold is SWW_WAF_CORE_RULE_SET_SCORE_THRESHOLD's default.
const mastodonThreshold = 5
// mastodonStatuses is where Mastodon's apps and web app post.
+1 -1
View File
@@ -5,7 +5,7 @@
# request, before the rules it switches off, so this file is loaded before the
# Core Rule Set's rules. Each comment names a request a rule refused. An
# exclusion on a field of a body applies only when the query has no parameter of
# that name, so it matters only while SWW_WAF_CORE_RULE_SET_BODY_LIMIT is set.
# that name, so it matters only while SWW_WAF_CORE_RULE_SET_BODY_INSPECTION_SIZE is set.
# MediaWiki's paths are matched at the site root and under /w/.
#
# WordPress serves /index.php too. So /index.php counts as MediaWiki's only
+5 -3
View File
@@ -9,7 +9,7 @@ import (
"sneak.berlin/go/smallwebwaf/internal/coreruleset"
)
// mediawikiThreshold is SWW_WAF_CORE_RULE_SET_ANOMALY_THRESHOLD's default.
// mediawikiThreshold is SWW_WAF_CORE_RULE_SET_SCORE_THRESHOLD's default.
const mediawikiThreshold = 5
// mediawiki is MediaWiki's name in SWW_WAF_APPS.
@@ -76,7 +76,8 @@ func TestMediaWikiWatchlistFixAppliesOnlyToAHostnameThatServesMediaWiki(
t.Parallel()
crs := shared(t, coreruleset.Params{
ParanoiaLevel: 1, DisabledRules: defaultDisabledRules, BodyLimit: bodyLimit,
ParanoiaLevel: 1, DisabledRules: defaultDisabledRules,
BodyInspectionSize: bodyInspectionSize,
Apps: map[string][]string{
giteaHost: {gitea},
otherHost: {mediawiki},
@@ -120,7 +121,8 @@ func TestMediaWikiPasswordFixesApplyOnlyToAHostnameThatServesMediaWiki(
t.Parallel()
crs := shared(t, coreruleset.Params{
ParanoiaLevel: 1, DisabledRules: defaultDisabledRules, BodyLimit: bodyLimit,
ParanoiaLevel: 1, DisabledRules: defaultDisabledRules,
BodyInspectionSize: bodyInspectionSize,
Apps: map[string][]string{
giteaHost: {gitea},
otherHost: {mediawiki},
+1 -1
View File
@@ -5,7 +5,7 @@
# switches off, so this file is loaded before the Core Rule Set's rules. Each
# comment names a request a rule refused. An exclusion on a field of a JSON body
# applies only when the query has no parameter of that name, so it matters only
# while SWW_WAF_CORE_RULE_SET_BODY_LIMIT is set. Nextcloud's pages are matched
# while SWW_WAF_CORE_RULE_SET_BODY_INSPECTION_SIZE is set. Nextcloud's pages are matched
# at the site root and under /index.php/, where a Nextcloud without pretty URLs
# serves them.
#
+4 -3
View File
@@ -13,7 +13,7 @@ import (
"sneak.berlin/go/smallwebwaf/internal/coreruleset"
)
// nextcloudThreshold is SWW_WAF_CORE_RULE_SET_ANOMALY_THRESHOLD's default.
// nextcloudThreshold is SWW_WAF_CORE_RULE_SET_SCORE_THRESHOLD's default.
const nextcloudThreshold = 5
// nextcloudSignIn is the sign-in page.
@@ -102,7 +102,8 @@ func TestNextcloudPasswordInValuePassesOnlyOnHostnamesThatServeNextcloud(
t.Parallel()
crs := shared(t, coreruleset.Params{
ParanoiaLevel: 1, DisabledRules: defaultDisabledRules, BodyLimit: bodyLimit,
ParanoiaLevel: 1, DisabledRules: defaultDisabledRules,
BodyInspectionSize: bodyInspectionSize,
Apps: map[string][]string{
giteaHost: {gitea},
otherHost: {nextcloud},
@@ -528,7 +529,7 @@ func TestNextcloudPasswordFieldsPass(t *testing.T) {
func TestNextcloudUserPasswordInValuePasses(t *testing.T) {
t.Parallel()
const threshold = 5 // SWW_WAF_CORE_RULE_SET_ANOMALY_THRESHOLD's default
const threshold = 5 // SWW_WAF_CORE_RULE_SET_SCORE_THRESHOLD's default
crs := readingBodies(t)
target := nextcloudUsers + "/alice"
+4 -4
View File
@@ -57,7 +57,7 @@ func giteaRequests(t *testing.T) []request {
func TestOrdinaryGiteaRequestsPass(t *testing.T) {
t.Parallel()
const threshold = 5 // SWW_WAF_CORE_RULE_SET_ANOMALY_THRESHOLD's default
const threshold = 5 // SWW_WAF_CORE_RULE_SET_SCORE_THRESHOLD's default
requests := giteaRequests(t)
giteaOnly := map[string][]string{giteaHost: {gitea}}
@@ -133,7 +133,7 @@ func recordedRequests(t *testing.T, name string) []recordedRequest {
func wantRecordedRequestsPass(t *testing.T, name, app string) {
t.Helper()
const threshold = 5 // SWW_WAF_CORE_RULE_SET_ANOMALY_THRESHOLD's default
const threshold = 5 // SWW_WAF_CORE_RULE_SET_SCORE_THRESHOLD's default
requests := recordedRequests(t, name)
@@ -144,7 +144,7 @@ func wantRecordedRequestsPass(t *testing.T, name, app string) {
defaults := servingApps(t, apps)
bodies := shared(t, coreruleset.Params{
ParanoiaLevel: 1, DisabledRules: defaultDisabledRules,
BodyLimit: bodyLimit, Apps: apps,
BodyInspectionSize: bodyInspectionSize, Apps: apps,
})
for _, recorded := range requests {
@@ -214,7 +214,7 @@ func TestOrdinaryDrupalRequestsPass(t *testing.T) {
func TestOrdinaryForgejoRequestsPass(t *testing.T) {
t.Parallel()
const threshold = 5 // SWW_WAF_CORE_RULE_SET_ANOMALY_THRESHOLD's default
const threshold = 5 // SWW_WAF_CORE_RULE_SET_SCORE_THRESHOLD's default
requests := recordedRequests(t, "forgejo-requests.txt")
forgejoOnly := map[string][]string{"example.com": {forgejo}}
+1 -1
View File
@@ -5,7 +5,7 @@
# before the rules it switches off, so this file is loaded before the Core Rule
# Set's rules. Each comment names a request a rule refused. An exclusion on a
# field of a body applies only when the query has no parameter of that name, so
# it matters only while SWW_WAF_CORE_RULE_SET_BODY_LIMIT is set.
# it matters only while SWW_WAF_CORE_RULE_SET_BODY_INSPECTION_SIZE is set.
#
# The apps encrypt the names, notes, addresses and file names of vault items,
# folders, collections and sends before they send them. These values pass
+6 -3
View File
@@ -13,7 +13,7 @@ import (
"sneak.berlin/go/smallwebwaf/internal/coreruleset"
)
// vaultwardenThreshold is SWW_WAF_CORE_RULE_SET_ANOMALY_THRESHOLD's default.
// vaultwardenThreshold is SWW_WAF_CORE_RULE_SET_SCORE_THRESHOLD's default.
const vaultwardenThreshold = 5
// Paths of Vaultwarden's API and admin page.
@@ -456,8 +456,11 @@ func TestVaultwardenMasterPasswordHashesPass(t *testing.T) {
},
} {
body := passwordBody(t, sent.r, sent.names, hashes...)
if len(body) > bodyLimit {
t.Fatalf("a body of %d bytes is cut at bodyLimit", len(body))
if len(body) > bodyInspectionSize {
t.Fatalf(
"a body of %d bytes is cut at bodyInspectionSize",
len(body),
)
}
if got, _ := inspectBody(t, crs, sent.r, body); len(
+1 -1
View File
@@ -3,7 +3,7 @@
# the rules it switches off, so this file is loaded before the Core Rule Set's
# rules. Each comment names a request the rule refused. An exclusion on a field
# of a body applies only when the query has no parameter of that name, so it
# matters only while SWW_WAF_CORE_RULE_SET_BODY_LIMIT is set.
# matters only while SWW_WAF_CORE_RULE_SET_BODY_INSPECTION_SIZE is set.
# Every rule below is skipped unless Inspect has set tx.app_wordpress. It sets
# it while SWW_WAF_APPS is unset, or lists wordpress for the request's hostname.
+1 -1
View File
@@ -66,7 +66,7 @@ type Params struct {
URL string
// Timeout is how long a request waits for its client's first answer,
// and how long a request to GeoJS may take before it is abandoned
// (SWW_WAF_LOOKUP_TIMEOUT).
// (SWW_WAF_ASN_AND_COUNTRY_LOOKUP_TIMEOUT_MS).
Timeout time.Duration
// Wait is true when a setting needs each request's answer before the
// request goes on. Otherwise no request waits for one.
+19 -17
View File
@@ -13,14 +13,15 @@ const other = "other"
// busiest are the metrics by one thing the lookup finds of the client,
// its country or its AS number, for requests whose client's is known.
// The topN busiest countries or AS numbers, by their requests since the
// start, have series of their own, and the others are counted under
// other, so that there are never more than topN + 1 series. One that
// drops out of the busiest loses its series, and its next requests are
// counted under other; one that becomes one of them gets a series that
// counts from then on. Each series therefore only ever goes up.
// The busiestCount busiest countries or AS numbers, by their requests
// since the start, have series of their own, and the others are counted
// under other, so that there are never more than busiestCount + 1 series.
// One that drops out of the busiest loses its series, and its next
// requests are counted under other; one that becomes one of them gets a
// series that counts from then on. Each series therefore only ever goes
// up.
type busiest struct {
topN int
busiestCount int
requests *prometheus.CounterVec
requestBytes *prometheus.CounterVec
@@ -38,9 +39,9 @@ type busiest struct {
}
// newCountries returns the metrics by country, with series of their own
// for the topN busiest countries.
func newCountries(topN int) *busiest {
countries := newBusiest(topN, "country", "the client's country")
// for the busiestCount busiest countries.
func newCountries(busiestCount int) *busiest {
countries := newBusiest(busiestCount, "country", "the client's country")
countries.refused = counterVec(
"sww_country_list_refusals_total",
"Requests SWW_WAF_EXCLUSIVELY_ALLOWED_COUNTRIES refused, by the client's country.",
@@ -51,18 +52,19 @@ func newCountries(topN int) *busiest {
}
// newASNs returns the metrics by AS number, with series of their own for
// the topN busiest AS numbers.
func newASNs(topN int) *busiest {
return newBusiest(topN, "asn", "the client's AS number")
// the busiestCount busiest AS numbers.
func newASNs(busiestCount int) *busiest {
return newBusiest(busiestCount, "asn", "the client's AS number")
}
// newBusiest returns the metrics by label, which is described as
// description, with series of their own for the topN busiest values.
func newBusiest(topN int, label, description string) *busiest {
// description, with series of their own for the busiestCount busiest
// values.
func newBusiest(busiestCount int, label, description string) *busiest {
by := []string{label}
return &busiest{
topN: topN,
busiestCount: busiestCount,
requests: counterVec("sww_"+label+"_requests_total",
"Requests, by "+description+".", by),
requestBytes: counterVec("sww_"+label+"_request_bytes_total",
@@ -131,7 +133,7 @@ func (b *busiest) label(value string) string {
return value
}
if len(b.top) < b.topN {
if len(b.top) < b.busiestCount {
b.top[value] = true
return value
+8 -8
View File
@@ -61,12 +61,12 @@ type Metrics struct {
}
// New returns the metrics, with the Go runtime's and the process's own.
// topN is how many countries and how many AS numbers get series of their
// own (SWW_WAF_METRICS_TOP_N). Every metric carries instanceName
// (SWW_WAF_INSTANCE_NAME) as its label instance.
// busiestCount is how many countries and how many AS numbers get series of
// their own (SWW_WAF_METRICS_BUSIEST_COUNTRIES_AND_ASNS). Every metric
// carries instanceName (SWW_WAF_INSTANCE_NAME) as its label instance.
//
//nolint:funlen // a few lines for each metric, a list that grows with them
func New(topN int, instanceName string) *Metrics {
func New(busiestCount int, instanceName string) *Metrics {
byStatus := []string{"status_class", "action"}
byFile := []string{"file"}
registry := prometheus.NewRegistry()
@@ -102,7 +102,7 @@ func New(topN int, instanceName string) *Metrics {
}),
rateLimitHits: counterVec(
"sww_rate_limit_hits_total",
"Requests that broke a rate limit, a byte limit or the error burst, by "+
"Requests that broke a rate limit, a byte limit or the refusal limit, by "+
"its window and its kind, requests, bytes or refusals.",
[]string{"window", "kind"},
),
@@ -114,8 +114,8 @@ func New(topN int, instanceName string) *Metrics {
"and the rule's id.",
[]string{"mode", "rule_id"},
),
countries: newCountries(topN),
asns: newASNs(topN),
countries: newCountries(busiestCount),
asns: newASNs(busiestCount),
GeoJSRequests: prometheus.NewCounter(prometheus.CounterOpts{
Name: "sww_geojs_requests_total",
Help: "Requests to GeoJS.",
@@ -465,7 +465,7 @@ func (m *Metrics) RequestReported(
}
// LimitHit counts a request that broke a rate limit, a byte limit or the
// error burst, by the window and the kind of hit.
// refusal limit, by the window and the kind of hit.
func (m *Metrics) LimitHit(hit ratelimit.Hit) {
m.rateLimitHits.WithLabelValues(hit.Window, hit.Kind).Inc()
}
+12 -12
View File
@@ -25,7 +25,7 @@ const (
KindRequests = "requests"
// KindBytes is a byte limit, on a client's bytes.
KindBytes = "bytes"
// KindRefusals is the error burst, on a client's requests sww
// KindRefusals is the refusal limit, on a client's requests sww
// refused after a rule file match or for a missing or wrong token.
KindRefusals = "refusals"
)
@@ -54,7 +54,7 @@ type Limiter struct {
// Client is a client in the table, as clients.json holds it: its buckets
// of requests and of bytes in each window, its buckets of refusals in the
// minute, which the error burst counts, and its history.
// minute, which the refusal limit counts, and its history.
//
//nolint:tagliatelle // the state files use snake_case, as the request log does
type Client struct {
@@ -121,7 +121,7 @@ type Responses struct {
//nolint:tagliatelle // the state files use snake_case, as the request log does
type Offences struct {
// Limit is its requests that broke a rate limit, a byte limit or the
// error burst, Attack those that were a clear sign of attack, a match
// refusal limit, Attack those that were a clear sign of attack, a match
// of a ban rule or a request for a trap path, BlockedByBlockRule those a
// block rule refused, and BlockedByCoreRuleSet those the Core Rule Set
// refused.
@@ -147,7 +147,7 @@ type Request struct {
RequestBytes int64
ResponseBytes int64
// BrokeLimit is true for a request that broke a rate limit, a byte
// limit or the error burst, Attack for one that matched a ban rule or
// limit or the refusal limit, Attack for one that matched a ban rule or
// asked for a trap path, BlockedByBlockRule for one a block rule refused,
// and BlockedByCoreRuleSet for one the Core Rule Set refused.
BrokeLimit bool
@@ -185,11 +185,11 @@ func New(limits Limits, maxClients int) *Limiter {
}
}
// Hit is a request that takes a client over a rate limit or the error
// burst, or whose bytes take it over a byte limit.
// Hit is a request that takes a client over a rate limit or the refusal
// limit, or whose bytes take it over a byte limit.
type Hit struct {
// Kind is KindRequests for a rate limit, KindBytes for a byte limit,
// KindRefusals for the error burst.
// KindRefusals for the refusal limit.
Kind string
// Window is "minute", "hour" or "day".
Window string
@@ -263,10 +263,10 @@ func (l *Limiter) CountBytes(
// CountRefusal counts a request from client at now that sww
// refused after a rule file or Core Rule Set match or for a missing or
// wrong token, and reports whether the client's refusals in the minute
// that ends at now, this one included, are more than threshold, which
// breaks the error burst, and the hit.
// that ends at now, this one included, are more than limit, which breaks
// the refusal limit, and the hit.
func (l *Limiter) CountRefusal(
client netip.Prefix, now time.Time, threshold int64,
client netip.Prefix, now time.Time, limit int64,
) (Hit, bool) {
l.mu.Lock()
defer l.mu.Unlock()
@@ -275,11 +275,11 @@ func (l *Limiter) CountRefusal(
hit := Hit{
Kind: KindRefusals,
Window: "minute",
Limit: threshold,
Limit: limit,
Count: count,
}
return hit, count > float64(threshold)
return hit, count > float64(limit)
}
// Reset sets client's counts of requests, of bytes and of refusals in
+6 -4
View File
@@ -351,7 +351,9 @@ func TestResetSetsTheBytesBackToZero(t *testing.T) {
wantBytesCount(t, limiter, client, start, 1000, "")
}
func TestRefusalsOverTheThresholdInAMinuteBreakTheErrorBurst(t *testing.T) {
func TestRefusalsOverTheLimitInAMinuteBreakTheRefusalLimit(
t *testing.T,
) {
t.Parallel()
limiter := ratelimit.New(ratelimit.Limits{}, tableSize)
@@ -361,7 +363,7 @@ func TestRefusalsOverTheThresholdInAMinuteBreakTheErrorBurst(t *testing.T) {
for range limit {
if _, over := limiter.CountRefusal(client, start, limit); over {
t.Fatalf(
"a refusal within the threshold of %d broke the error burst",
"a refusal within the limit of %d broke the refusal limit",
limit,
)
}
@@ -374,7 +376,7 @@ func TestRefusalsOverTheThresholdInAMinuteBreakTheErrorBurst(t *testing.T) {
}
if !over || hit != want {
t.Errorf(
"one over the threshold broke it: %t, with %+v; want %+v",
"one over the limit broke it: %t, with %+v; want %+v",
over,
hit,
want,
@@ -382,7 +384,7 @@ func TestRefusalsOverTheThresholdInAMinuteBreakTheErrorBurst(t *testing.T) {
}
// Half a minute into the next, half of those four still count, 2, and
// this one: 3, within the threshold.
// this one: 3, within the limit.
hit, over = limiter.CountRefusal(
client,
start.Add(time.Minute+time.Minute/2),
+4 -4
View File
@@ -62,9 +62,9 @@ type Params struct {
// RootCAs are the certificates a SchemeTLS endpoint's certificate
// must chain to (SWW_WAF_LOG_REMOTE_TLS_CA_FILE), nil for the host's.
RootCAs *x509.CertPool
// Buffer is the most lines held while they wait to be sent
// (SWW_WAF_LOG_REMOTE_BUFFER).
Buffer int
// BufferLines is the most lines held while they wait to be sent
// (SWW_WAF_LOG_REMOTE_BUFFER_LINES).
BufferLines int
// Facility is the number of the records' syslog facility
// (SWW_WAF_LOG_REMOTE_FACILITY), and AppName their APP-NAME
// (SWW_WAF_LOG_REMOTE_APP_NAME).
@@ -106,7 +106,7 @@ func New(params Params) *Sender {
// id and the structured data have no value.
beforeTime: "<" + strconv.Itoa(priority) + ">1 ",
afterTime: " " + hostname + " " + params.AppName + " - - - ",
records: make(chan []byte, params.Buffer),
records: make(chan []byte, params.BufferLines),
}
}
+5 -5
View File
@@ -129,7 +129,7 @@ func TestStalledEndpointHoldsUpNoWriteAndOldestRecordsAreDropped(
endpoint := listen(t)
endpointParams := params(remotelog.SchemeTLS, endpoint.Addr())
endpointParams.RootCAs = roots
endpointParams.Buffer = 3
endpointParams.BufferLines = 3
sender, _, _ := run(t, endpointParams)
// The sender connects, and its TLS handshake waits for an answer
@@ -402,10 +402,10 @@ func (o *output) text() string {
// appName.
func params(scheme string, addr net.Addr) remotelog.Params {
return remotelog.Params{
URL: &url.URL{Scheme: scheme, Host: addr.String()},
Buffer: 10,
Facility: local0,
AppName: appName,
URL: &url.URL{Scheme: scheme, Host: addr.String()},
BufferLines: 10,
Facility: local0,
AppName: appName,
}
}
+2 -2
View File
@@ -65,8 +65,8 @@ type AbuseIPDBParams struct {
MinScore int64
DailyBudget int
// CacheTTL is how long a score is used after it was fetched
// (SWW_WAF_REPUTATION_CACHE_TTL), and Timeout how long a check may take
// (SWW_WAF_REPUTATION_TIMEOUT).
// (SWW_WAF_IP_REPUTATION_CACHE_TTL), and Timeout how long a check may
// take (SWW_WAF_DNSBL_ABUSEIPDB_TIMEOUT_MS).
CacheTTL time.Duration
Timeout time.Duration
// Now tells the time, normally time.Now in UTC.
+2 -2
View File
@@ -60,8 +60,8 @@ type DNSBLParams struct {
// host's, as /etc/resolv.conf names it.
Resolver netip.AddrPort
// CacheTTL is how long a verdict is used after it was fetched
// (SWW_WAF_REPUTATION_CACHE_TTL), and Timeout how long a query may take
// (SWW_WAF_REPUTATION_TIMEOUT).
// (SWW_WAF_IP_REPUTATION_CACHE_TTL), and Timeout how long a query may
// take (SWW_WAF_DNSBL_ABUSEIPDB_TIMEOUT_MS).
CacheTTL time.Duration
Timeout time.Duration
// Now tells the time, normally time.Now in UTC.
+3 -2
View File
@@ -40,8 +40,9 @@ const (
// zone and otherZone are the DNSBL zones the tests name.
zone = "dnsbl.example"
otherZone = "other.example"
// cacheTTL is the tests' SWW_WAF_REPUTATION_CACHE_TTL, and timeout their
// SWW_WAF_REPUTATION_TIMEOUT: a second, the least time /etc/resolv.conf
// cacheTTL is the tests' SWW_WAF_IP_REPUTATION_CACHE_TTL, and timeout
// their SWW_WAF_DNSBL_ABUSEIPDB_TIMEOUT_MS: a second, the least time
// /etc/resolv.conf
// can have Go's resolver wait for one server, so that it is the
// DNSBL's own timeout that ends a query, whatever that file says.
cacheTTL = 24 * time.Hour
+10 -8
View File
@@ -44,13 +44,14 @@ const (
ActionBlockedByBlockRule = "blocked_by_block_rule"
// ActionBlockedByCoreRuleSet is a request refused because the Core Rule
// Set scored it at or over SWW_WAF_CORE_RULE_SET_ANOMALY_THRESHOLD.
// Set scored it at or over SWW_WAF_CORE_RULE_SET_SCORE_THRESHOLD.
ActionBlockedByCoreRuleSet = "blocked_by_core_rule_set"
// ActionClientDenied is a request refused because its client is in
// SWW_WAF_DENY_NETS, in a blocklist while SWW_WAF_BLOCKLIST_ACTION is deny,
// or listed by a DNSBL zone, or scored a hit by AbuseIPDB, while
// SWW_WAF_REPUTATION_ACTION is deny, or that carries a tag in SWW_WAF_TAG_DENY.
// SWW_WAF_DNSBL_ABUSEIPDB_ACTION is deny, or that carries a tag in
// SWW_WAF_TAG_DENY.
ActionClientDenied = "client_denied"
// ActionCountryDenied is a request refused because its client's country
@@ -62,12 +63,12 @@ const (
)
// OffenceLimit is the offence a request line names for a request that
// broke a rate limit or the error burst, or whose bytes broke a byte
// broke a rate limit or the refusal limit, or whose bytes broke a byte
// limit.
const OffenceLimit = "limit"
// LimitHitMinuteRefusals is the limit_hit a request line names for a request
// that broke the error burst.
// that broke the refusal limit.
const LimitHitMinuteRefusals = "minute_refusals"
// The waf_line a request line names, which says which of
@@ -180,7 +181,7 @@ type Line struct {
// LimitHit is the window whose limit the request went over, named as
// Counts names its count: minute, hour or day for a rate limit, and
// minute_bytes, hour_bytes or day_bytes for a byte limit; or
// LimitHitMinuteRefusals for the error burst.
// LimitHitMinuteRefusals for the refusal limit.
LimitHit string `json:"limit_hit,omitempty"`
// Reputation are the URLs of the blocklists that list the client, then
// that of the CrowdSec decision list when it does, then the DNSBL zones
@@ -233,10 +234,11 @@ type RProxyLine struct {
DurationRequestMS float64 `json:"duration_request_ms"`
DurationResponseMS float64 `json:"duration_response_ms"`
// Disposition is the valid disposition waf answered or, while
// DefaultDisposition is true, the value of SWW_RPROXY_DEFAULT_DISPOSITION. It is ""
// when neither gave one.
// WAFBackendNoAnswer is true, the value of
// SWW_RPROXY_WAF_BACKEND_NO_ANSWER_DISPOSITION. It is "" when neither
// gave one.
Disposition string `json:"disposition,omitempty"`
DefaultDisposition bool `json:"default_disposition,omitempty"`
WAFBackendNoAnswer bool `json:"waf_backend_no_answer,omitempty"`
}
// Write writes line to w as one JSON line marked "type":"request".
+2 -2
View File
@@ -407,7 +407,7 @@ func TestAMethodNotPassedOnIsAnswered405(t *testing.T) {
line := out.requestLine(t)
if line["status"] != float64(http.StatusMethodNotAllowed) ||
line["disposition"] != nil || line["default_disposition"] != nil {
line["disposition"] != nil || line["waf_backend_no_answer"] != nil {
t.Errorf("line %v, want status 405 and no disposition", line)
}
@@ -490,7 +490,7 @@ func TestARequestThatCannotGoToWAFAsSentIsRefused(t *testing.T) {
line := out.requestLine(t)
if line["status"] != float64(http.StatusBadRequest) ||
line["disposition"] != nil || line["default_disposition"] != nil {
line["disposition"] != nil || line["waf_backend_no_answer"] != nil {
t.Errorf("line %v, want status 400 and no disposition", line)
}
+6 -5
View File
@@ -61,12 +61,13 @@ type request struct {
start time.Time
// disposition is what is done with the request: waf's valid disposition,
// or SWW_RPROXY_DEFAULT_DISPOSITION. It is "" when neither gave one.
// or SWW_RPROXY_WAF_BACKEND_NO_ANSWER_DISPOSITION. It is "" when neither
// gave one.
disposition string
// answered is true when waf gave disposition, which makes a report due,
// and byDefault when SWW_RPROXY_DEFAULT_DISPOSITION did.
answered bool
byDefault bool
// and wafNoAnswer when SWW_RPROXY_WAF_BACKEND_NO_ANSWER_DISPOSITION did.
answered bool
wafNoAnswer bool
// asn and country are waf's X-Client-ASN and X-Client-Country, for the
// app.
asn, country string
@@ -450,7 +451,7 @@ func (rq *request) finish(ctx context.Context) {
),
DurationResponseMS: requestlog.Milliseconds(end.Sub(requestEnd)),
Disposition: rq.disposition,
DefaultDisposition: rq.byDefault,
WAFBackendNoAnswer: rq.wafNoAnswer,
},
)
if err != nil {
+8 -5
View File
@@ -1,9 +1,10 @@
// Package rproxy is the reverse proxy in front of one app that asks waf
// about each request. It passes a request waf allows to the app and the
// app's answer back, within the size and time limits. Any other answer from
// waf goes to the visitor as waf gave it. SWW_RPROXY_DEFAULT_DISPOSITION
// decides only when waf cannot be connected to, does not answer at all, or
// does not answer within SWW_RPROXY_TIMEOUT_MS. rproxy itself refuses a request with a
// waf goes to the visitor as waf gave it.
// SWW_RPROXY_WAF_BACKEND_NO_ANSWER_DISPOSITION decides only when waf cannot
// be connected to, does not answer at all, or does not answer within
// SWW_RPROXY_WAF_BACKEND_TIMEOUT_MS. rproxy itself refuses a request with a
// method it does not pass on, or one it cannot put to waf as the visitor
// sent it. It keeps nothing between requests and writes nothing to disk. "rproxy" in
// SPEC.md says what it does.
@@ -108,7 +109,8 @@ func New(params Params) *Server {
}
// WaitForReports returns once the reports being sent have gone or failed,
// which SWW_RPROXY_TIMEOUT_MS bounds. Any report after this call is dropped: that of
// which SWW_RPROXY_WAF_BACKEND_TIMEOUT_MS bounds. Any report after this call
// is dropped: that of
// a WebSocket that closes while rproxy stops, for one.
func (s *Server) WaitForReports() {
s.h.reportsMu.Lock()
@@ -217,7 +219,8 @@ func (h *handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
// passToApp passes the request on to the app, unless a limit or its path keeps
// it from the app. waf answers a path under ownEndpointPrefix itself, so
// such a request gets here only when SWW_RPROXY_DEFAULT_DISPOSITION allowed it.
// such a request gets here only when
// SWW_RPROXY_WAF_BACKEND_NO_ANSWER_DISPOSITION allowed it.
// ctx is the request's own context.
func (rq *request) passToApp(ctx context.Context) {
maxBytes := rq.h.config.MaxRequestSize
+54 -41
View File
@@ -44,13 +44,13 @@ const (
// The settings the tests set.
const (
timeoutSetting = "SWW_RPROXY_TIMEOUT_MS"
defaultDisposition = "SWW_RPROXY_DEFAULT_DISPOSITION"
trustedProxies = "SWW_RPROXY_TRUSTED_PROXIES"
maxRequestSize = "SWW_RPROXY_MAX_REQUEST_SIZE"
maxResponseSize = "SWW_RPROXY_MAX_RESPONSE_SIZE"
maxRequestTime = "SWW_RPROXY_MAX_REQUEST_TIME_MS"
maxResponseTime = "SWW_RPROXY_MAX_RESPONSE_TIME_MS"
wafBackendTimeout = "SWW_RPROXY_WAF_BACKEND_TIMEOUT_MS"
noAnswerSetting = "SWW_RPROXY_WAF_BACKEND_NO_ANSWER_DISPOSITION"
trustedProxies = "SWW_RPROXY_TRUSTED_PROXIES"
maxRequestSize = "SWW_RPROXY_MAX_REQUEST_SIZE"
maxResponseSize = "SWW_RPROXY_MAX_RESPONSE_SIZE"
maxRequestTime = "SWW_RPROXY_MAX_REQUEST_TIME_MS"
maxResponseTime = "SWW_RPROXY_MAX_RESPONSE_TIME_MS"
)
// The headers the tests set and look at.
@@ -774,7 +774,7 @@ func TestDenyGivesTheVisitorWAFsAnswer(t *testing.T) {
line := out.requestLine(t)
if line["disposition"] != dispositionDeny ||
line["default_disposition"] != nil {
line["waf_backend_no_answer"] != nil {
t.Errorf("line %v, want waf's deny", line)
}
}
@@ -956,7 +956,8 @@ func answeringWith(answer http.HandlerFunc) func(t *testing.T) string {
}
}
// noAnswers are the ways waf can leave the decision to the default.
// noAnswers are the ways waf can leave the decision to
// SWW_RPROXY_WAF_BACKEND_NO_ANSWER_DISPOSITION.
func noAnswers() map[string]func(t *testing.T) string {
return map[string]func(t *testing.T) string{
"no connection": func(*testing.T) string { return nowhere },
@@ -1008,7 +1009,8 @@ func TestAnAnswerOtherThanAllowGoesToTheVisitorAsWAFGaveIt(t *testing.T) {
{"an unknown disposition", "maybe", "who knows\n", http.StatusOK},
} {
for _, setting := range []string{dispositionAllow, dispositionDeny} {
t.Run(tc.name+" with the default "+setting, func(t *testing.T) {
subtest := tc.name + " with " + noAnswerSetting + "=" + setting
t.Run(subtest, func(t *testing.T) {
t.Parallel()
standIn := startWAF(
@@ -1031,7 +1033,7 @@ func TestAnAnswerOtherThanAllowGoesToTheVisitorAsWAFGaveIt(t *testing.T) {
standIn.URL,
app.URL,
map[string]string{
defaultDisposition: setting,
noAnswerSetting: setting,
},
)
@@ -1047,9 +1049,9 @@ func TestAnAnswerOtherThanAllowGoesToTheVisitorAsWAFGaveIt(t *testing.T) {
line := out.requestLine(t)
if line["status"] != float64(tc.status) ||
line["default_disposition"] != nil {
line["waf_backend_no_answer"] != nil {
t.Errorf(
"line %v, want status %d and no default",
"line %v, want status %d and no waf_backend_no_answer",
line,
tc.status,
)
@@ -1109,7 +1111,7 @@ func TestAnAnswerOf1MiBGoesToTheVisitor(t *testing.T) {
_, _ = w.Write(make([]byte, size))
})
addr, _ := startRProxy(t, standIn.URL, nowhere, map[string]string{
timeoutSetting: "60000",
wafBackendTimeout: "60000",
})
got := get(t, addr, "/")
@@ -1202,7 +1204,8 @@ func TestAnAnswerThatCannotBePassedOnIsAnswered502(t *testing.T) {
"a 101 that holds its connection open": answeringWith(switchAndHold),
} {
for _, setting := range []string{dispositionAllow, dispositionDeny} {
t.Run(name+" with the default "+setting, func(t *testing.T) {
subtest := name + " with " + noAnswerSetting + "=" + setting
t.Run(subtest, func(t *testing.T) {
t.Parallel()
app := startApp(t, func(http.ResponseWriter, *http.Request) {
@@ -1213,8 +1216,8 @@ func TestAnAnswerThatCannotBePassedOnIsAnswered502(t *testing.T) {
backend(t),
app.URL,
map[string]string{
timeoutSetting: "60000",
defaultDisposition: setting,
wafBackendTimeout: "60000",
noAnswerSetting: setting,
},
)
@@ -1224,7 +1227,7 @@ func TestAnAnswerThatCannotBePassedOnIsAnswered502(t *testing.T) {
line := out.requestLine(t)
if line["status"] != float64(http.StatusBadGateway) ||
line["disposition"] != nil || line["default_disposition"] != nil {
line["disposition"] != nil || line["waf_backend_no_answer"] != nil {
t.Errorf(
"line %v, want status 502 and no disposition",
line,
@@ -1242,7 +1245,7 @@ func TestAnAnswerThatCannotBePassedOnGetsNoReport(t *testing.T) {
standIn := startWAF(t, overOneMiB(dispositionDeny))
addr, out := startRProxy(t, standIn.URL, nowhere, map[string]string{
timeoutSetting: "60000",
wafBackendTimeout: "60000",
})
if got := get(t, addr, "/"); got.status != http.StatusBadGateway {
@@ -1253,7 +1256,9 @@ func TestAnAnswerThatCannotBePassedOnGetsNoReport(t *testing.T) {
standIn.wantNoReport(t)
}
func TestWithoutAnAnswerTheDefaultDispositionDecides(t *testing.T) {
func TestWithoutAnAnswerTheWAFBackendNoAnswerDispositionDecides(
t *testing.T,
) {
t.Parallel()
for name, backend := range noAnswers() {
@@ -1266,7 +1271,7 @@ func TestWithoutAnAnswerTheDefaultDispositionDecides(t *testing.T) {
backend(t),
app.URL,
map[string]string{
timeoutSetting: "200",
wafBackendTimeout: "200",
},
)
@@ -1274,8 +1279,11 @@ func TestWithoutAnAnswerTheDefaultDispositionDecides(t *testing.T) {
line := out.requestLine(t)
if line["disposition"] != dispositionAllow ||
line["default_disposition"] != true {
t.Errorf("line %v, want allow by default", line)
line["waf_backend_no_answer"] != true {
t.Errorf(
"line %v, want allow and waf_backend_no_answer",
line,
)
}
})
@@ -1290,8 +1298,8 @@ func TestWithoutAnAnswerTheDefaultDispositionDecides(t *testing.T) {
backend(t),
app.URL,
map[string]string{
timeoutSetting: "200",
defaultDisposition: dispositionDeny,
wafBackendTimeout: "200",
noAnswerSetting: dispositionDeny,
},
)
@@ -1302,18 +1310,21 @@ func TestWithoutAnAnswerTheDefaultDispositionDecides(t *testing.T) {
line := out.requestLine(t)
if line["disposition"] != dispositionDeny ||
line["default_disposition"] != true {
t.Errorf("line %v, want deny by default", line)
line["waf_backend_no_answer"] != true {
t.Errorf(
"line %v, want deny and waf_backend_no_answer",
line,
)
}
})
}
}
func TestAVisitorThatGoesAwayWhileWAFIsAskedGetsNoDefault(t *testing.T) {
func TestAVisitorThatGoesAwayWhileWAFIsAskedGetsNoDisposition(t *testing.T) {
t.Parallel()
for _, disposition := range []string{dispositionAllow, dispositionDeny} {
t.Run("with the default "+disposition, func(t *testing.T) {
t.Run(noAnswerSetting+"="+disposition, func(t *testing.T) {
t.Parallel()
standIn := startWAF(
@@ -1330,8 +1341,8 @@ func TestAVisitorThatGoesAwayWhileWAFIsAskedGetsNoDefault(t *testing.T) {
standIn.URL,
app.URL,
map[string]string{
timeoutSetting: "60000",
defaultDisposition: disposition,
wafBackendTimeout: "60000",
noAnswerSetting: disposition,
},
)
@@ -1343,7 +1354,7 @@ func TestAVisitorThatGoesAwayWhileWAFIsAskedGetsNoDefault(t *testing.T) {
line := out.requestLine(t)
if line["status"] != 0.0 || line["disposition"] != nil ||
line["default_disposition"] != nil {
line["waf_backend_no_answer"] != nil {
t.Errorf("line %v, want status 0 and no disposition", line)
}
@@ -1356,7 +1367,9 @@ func TestAVisitorThatGoesAwayWhileWAFIsAskedGetsNoDefault(t *testing.T) {
}
}
func TestRProxyWarnsWhenTheDefaultDecides(t *testing.T) {
func TestRProxyWarnsWhenTheWAFBackendNoAnswerDispositionDecides(
t *testing.T,
) {
t.Parallel()
app := startApp(t, greet)
@@ -1374,7 +1387,7 @@ func TestSendsNoReportWithoutAnAnswer(t *testing.T) {
})
app := startApp(t, greet)
addr, out := startRProxy(t, standIn.URL, app.URL, map[string]string{
timeoutSetting: "200",
wafBackendTimeout: "200",
})
wantGreeting(t, get(t, addr, "/"))
@@ -1438,7 +1451,7 @@ func TestTheVisitorDoesNotWaitForTheReport(t *testing.T) {
app := startApp(t, greet)
addr, _ := startRProxy(t, backend, app.URL, map[string]string{
timeoutSetting: "60000",
wafBackendTimeout: "60000",
})
t.Cleanup(func() { close(release) })
@@ -1481,7 +1494,7 @@ func TestAReportWAFLeavesUnansweredIsDroppedAtTheTimeLimit(t *testing.T) {
// The time limit is long enough for a loaded host to answer the question before
// it runs out.
addr, out := startRProxy(t, backend, app.URL, map[string]string{
timeoutSetting: "3000",
wafBackendTimeout: "3000",
})
t.Cleanup(func() { close(release) })
@@ -1511,7 +1524,7 @@ func TestAReportAnswered101IsDroppedAtOnce(t *testing.T) {
backend := startWAFAnsweringReports(t, allow, switchAndHold)
app := startApp(t, greet)
addr, out := startRProxy(t, backend, app.URL, map[string]string{
timeoutSetting: "60000",
wafBackendTimeout: "60000",
})
wantGreeting(t, get(t, addr, "/"))
@@ -1599,7 +1612,7 @@ func TestWaitForReportsWaitsForAReportBeingSent(t *testing.T) {
backend.URL,
app.URL,
map[string]string{
timeoutSetting: "5000",
wafBackendTimeout: "5000",
},
)
@@ -1892,10 +1905,10 @@ func TestLogLineGivesTheRequestAsSent(t *testing.T) {
}
}
if line["default_disposition"] != nil {
if line["waf_backend_no_answer"] != nil {
t.Errorf(
"default_disposition is %v, want it left out",
line["default_disposition"],
"waf_backend_no_answer is %v, want it left out",
line["waf_backend_no_answer"],
)
}
+12 -11
View File
@@ -67,15 +67,16 @@ func (rq *request) handleNoUsableAnswer(ctx context.Context, err error) {
rq.answer(http.StatusBadGateway)
default:
// waf cannot be connected to, does not answer at all, or does not answer
// within SWW_RPROXY_TIMEOUT_MS. A request that keeps waf busy past
// SWW_RPROXY_TIMEOUT_MS, like any request while waf is overloaded, gets
// SWW_RPROXY_DEFAULT_DISPOSITION here. deny closes this, at the cost of answering 503
// whenever waf is slow. Making such requests hard to send is
// within SWW_RPROXY_WAF_BACKEND_TIMEOUT_MS. A request that keeps waf
// busy past SWW_RPROXY_WAF_BACKEND_TIMEOUT_MS, like any request while
// waf is overloaded, gets SWW_RPROXY_WAF_BACKEND_NO_ANSWER_DISPOSITION
// here. deny closes this, at the cost of answering 503 whenever waf is
// slow. Making such requests hard to send is
// https://git.eeqj.de/sneak/smallwebwaf/issues/182.
rq.h.processLog.Warn("waf gave no answer", "error", err.Error())
rq.disposition = rq.h.config.DefaultDisposition
rq.byDefault = true
rq.disposition = rq.h.config.WAFBackendNoAnswerDisposition
rq.wafNoAnswer = true
if rq.disposition == waf.DispositionAllow {
rq.passToApp(ctx)
@@ -86,10 +87,10 @@ func (rq *request) handleNoUsableAnswer(ctx context.Context, err error) {
}
// ask sends waf the question, and returns its answer, which must have come
// whole within SWW_RPROXY_TIMEOUT_MS. It ends when ctx does, as when the visitor goes
// away.
// whole within SWW_RPROXY_WAF_BACKEND_TIMEOUT_MS. It ends when ctx does, as
// when the visitor goes away.
func (rq *request) ask(ctx context.Context) (*wafAnswer, error) {
ctx, cancel := context.WithTimeout(ctx, rq.h.config.Timeout)
ctx, cancel := context.WithTimeout(ctx, rq.h.config.WAFBackendTimeout)
defer cancel()
question, err := rq.newQuestion(ctx)
@@ -242,14 +243,14 @@ func (h *handler) sendReport(ctx context.Context, report *waf.Report) {
}
// postReport posts report to waf, and waits for its answer for up to
// SWW_RPROXY_TIMEOUT_MS.
// SWW_RPROXY_WAF_BACKEND_TIMEOUT_MS.
func (h *handler) postReport(ctx context.Context, report *waf.Report) error {
body, err := json.Marshal(report)
if err != nil {
return fmt.Errorf("encode the report: %w", err)
}
ctx, cancel := context.WithTimeout(ctx, h.config.Timeout)
ctx, cancel := context.WithTimeout(ctx, h.config.WAFBackendTimeout)
defer cancel()
req, err := http.NewRequestWithContext( //nolint:gosec // a setting's URL
+6 -6
View File
@@ -1,5 +1,5 @@
// Package rules reads the rule files: the plain text files in
// SWW_WAF_RULES_DIR, one rule to a line, that each request is checked
// SWW_WAF_RULE_FILES_DIR, one rule to a line, that each request is checked
// against, as the "Rule files" section of SPEC.md describes. They are read
// at start, and again once the directory has had no change for a short
// time after one is edited, added or removed.
@@ -40,7 +40,7 @@ const (
// extension ends the name of every rule file.
const extension = ".rules"
// quietTime is how long SWW_WAF_RULES_DIR must go without a change before
// quietTime is how long SWW_WAF_RULE_FILES_DIR must go without a change before
// the rule files are read again, so that a file still being written, such
// as one saved in place, appended to or copied in with scp, is read only
// once whole.
@@ -103,9 +103,9 @@ type Rule struct {
// Params are what Load needs.
type Params struct {
// Dir is the directory of the rule files (SWW_WAF_RULES_DIR).
// Dir is the directory of the rule files (SWW_WAF_RULE_FILES_DIR).
Dir string
// Enabled is SWW_WAF_RULES_ENABLED: while it is false, no file is read
// Enabled is SWW_WAF_RULE_FILES_ENABLED: while it is false, no file is read
// and no rule loaded.
Enabled bool
// ProcessLog receives how many rules were read, and the error in a
@@ -275,7 +275,7 @@ func read(dir string) ([]Rule, string, error) {
entries, err := os.ReadDir(dir)
if err != nil {
return nil, dir, fmt.Errorf(
"SWW_WAF_RULES_DIR cannot be read: %w",
"SWW_WAF_RULE_FILES_DIR cannot be read: %w",
err,
)
}
@@ -310,7 +310,7 @@ func readFile(
rules []Rule,
places map[string]string,
) ([]Rule, error) {
//nolint:gosec // a rule file, in SWW_WAF_RULES_DIR
//nolint:gosec // a rule file, in SWW_WAF_RULE_FILES_DIR
data, err := os.ReadFile(path)
if err != nil {
return nil, err
+2 -2
View File
@@ -323,7 +323,7 @@ func TestDirectoryThatDoesNotExistStopsTheStart(t *testing.T) {
dir := filepath.Join(t.TempDir(), "rules.d")
wantRefused(t, dir, "SWW_WAF_RULES_DIR cannot be read: open "+dir+
wantRefused(t, dir, "SWW_WAF_RULE_FILES_DIR cannot be read: open "+dir+
": no such file or directory")
}
@@ -352,7 +352,7 @@ func TestEmptyDirectoryLoadsNoRulesAndSaysSo(t *testing.T) {
func TestRuleFilesOffReadNothing(t *testing.T) {
t.Parallel()
// SWW_WAF_RULES_DIR does not exist, which would stop the start.
// SWW_WAF_RULE_FILES_DIR does not exist, which would stop the start.
params, _ := newParams(filepath.Join(t.TempDir(), "rules.d"))
params.Enabled = false
+9 -9
View File
@@ -123,11 +123,11 @@ type Params struct {
// CacheDir that of the cache files (SWW_WAF_CACHE_DIR).
Dir string
CacheDir string
// WriteDelay is how long after a ban is made bans.json is written
// (SWW_WAF_STATE_WRITE_DELAY), and CounterInterval how often every file
// is (SWW_WAF_STATE_COUNTER_INTERVAL).
WriteDelay time.Duration
CounterInterval time.Duration
// BansWriteDelay is how long after a ban is made bans.json is written
// (SWW_WAF_BANS_WRITE_DELAY), and WriteInterval how often every file
// is (SWW_WAF_STATE_WRITE_INTERVAL).
BansWriteDelay time.Duration
WriteInterval time.Duration
// Ledger, Limiter, GeoJS, Lists, DNSBL, AbuseIPDB, RouteWatch, Alerts and
// Anomalies hold the state. RouteWatch is nil while SWW_WAF_ROUTEWATCH_URL is
// unset, and routewatch.json then holds no answers. Alerts also receive a
@@ -318,13 +318,13 @@ func Load(params Params) (*Files, error) {
return f, nil
}
// Run writes bans.json WriteDelay after a ban is made, with every ban
// made in between, and every file every CounterInterval, until ctx is
// Run writes bans.json BansWriteDelay after a ban is made, with every ban
// made in between, and every file every WriteInterval, until ctx is
// done. A write that fails is logged, raised as a file_error alert, and
// the file is written again at its next write. Each write takes in an
// admin's edit of its file first, as writeFile describes.
func (f *Files) Run(ctx context.Context) {
interval := time.NewTicker(f.params.CounterInterval)
interval := time.NewTicker(f.params.WriteInterval)
defer interval.Stop()
var bansDue <-chan time.Time // nil while no ban waits to be written
@@ -335,7 +335,7 @@ func (f *Files) Run(ctx context.Context) {
return
case <-f.params.Ledger.Changed():
if bansDue == nil {
bansDue = time.After(f.params.WriteDelay)
bansDue = time.After(f.params.BansWriteDelay)
}
case <-bansDue:
bansDue = nil
+20 -20
View File
@@ -1273,13 +1273,13 @@ func TestUnwritableDirectoryStopsTheStart(t *testing.T) {
// returns once Run waits for its next write, so that every write due by
// then is on disk.
func TestBansWrittenOnceWriteDelayAfterABan(t *testing.T) {
func TestBansWrittenOnceBansWriteDelayAfterABan(t *testing.T) {
t.Parallel()
synctest.Test(t, func(t *testing.T) {
dir := t.TempDir()
params := newParams(dir)
params.WriteDelay = 10 * time.Second
params.BansWriteDelay = 10 * time.Second
run(t, load(t, params).Run)
// A second ban, made while the first waits to be written, puts the
@@ -1318,19 +1318,19 @@ func TestBansWrittenOnceWriteDelayAfterABan(t *testing.T) {
// the second ban. The other files wait for the interval, an hour
// away.
removeFiles(t, dir, bansJSON)
time.Sleep(params.WriteDelay)
time.Sleep(params.BansWriteDelay)
synctest.Wait()
wantFiles(t, dir)
})
}
func TestEveryFileWrittenEveryCounterInterval(t *testing.T) {
func TestEveryFileWrittenEveryWriteInterval(t *testing.T) {
t.Parallel()
synctest.Test(t, func(t *testing.T) {
dir := t.TempDir()
params := newParams(dir)
params.CounterInterval = time.Minute
params.WriteInterval = time.Minute
run(t, load(t, params).Run)
// The files are removed once written, so that each interval shows
@@ -1354,21 +1354,21 @@ func TestEditJustBeforeAScheduledWriteSurvivesIt(t *testing.T) {
synctest.Test(t, func(t *testing.T) {
dir := t.TempDir()
params := newParams(dir)
params.WriteDelay = 10 * time.Second
params.BansWriteDelay = 10 * time.Second
run(t, load(t, params).Run)
// A ban, and bans.json written with it.
params.Ledger.BanForLimit(netip.MustParsePrefix("203.0.113.9/32"),
midnight(), bans.Notes{})
time.Sleep(params.WriteDelay)
time.Sleep(params.BansWriteDelay)
synctest.Wait()
// A second ban is to be written WriteDelay later. Just before
// A second ban is to be written BansWriteDelay later. Just before
// then, an admin saves bans.json with the first ban lifted and
// another added.
params.Ledger.BanForLimit(netip.MustParsePrefix("203.0.113.10/32"),
midnight(), bans.Notes{})
time.Sleep(params.WriteDelay - time.Nanosecond)
time.Sleep(params.BansWriteDelay - time.Nanosecond)
synctest.Wait()
edit(t, dir, bansJSON, permanentBansJSON)
@@ -1399,7 +1399,7 @@ func TestWriteThatFailsWhileRunningRaisesAFileErrorAlertOncePerCooldown(
synctest.Test(t, func(t *testing.T) {
dir := t.TempDir()
params := newParams(dir)
params.CounterInterval = time.Minute
params.WriteInterval = time.Minute
run(t, load(t, params).Run)
// A directory in the way of the temporary file of clients.json, a
@@ -2404,16 +2404,16 @@ func newParams(dir string) state.Params {
})
return state.Params{
Dir: dir,
CacheDir: dir,
WriteDelay: time.Hour,
CounterInterval: time.Hour,
Dir: dir,
CacheDir: dir,
BansWriteDelay: time.Hour,
WriteInterval: time.Hour,
Ledger: bans.New(bans.Params{
LimitBanDuration: time.Hour,
LimitBanRepeatWindow: 24 * time.Hour,
MaxBanDuration: 7 * 24 * time.Hour,
AttackBanDuration: 7 * 24 * time.Hour,
MaxBans: 5000,
LimitBanDuration: time.Hour,
LimitBanRepeatWindow: 24 * time.Hour,
LimitBanPermanentAbove: 7 * 24 * time.Hour,
AttackBanDuration: 7 * 24 * time.Hour,
MaxBans: 5000,
}),
Limiter: ratelimit.New(ratelimit.Limits{}, 20000),
GeoJS: lookup.New(lookup.Params{
@@ -2447,7 +2447,7 @@ func newParams(dir string) state.Params {
RequestsPerMinute: 1000,
BytesPerMinute: 1 << 30,
},
Watch: anomaly.Thresholds{RequestsPerHour: 1000},
WatchedNet: anomaly.Thresholds{RequestsPerHour: 1000},
NetV4Prefix: 24,
NetV6Prefix: 48,
NamedNetblocks: []anomaly.NamedNetblock{
+9 -4
View File
@@ -33,9 +33,9 @@ func TestHealthCheckInWAFsContainer(t *testing.T) {
out := &output{}
exited := make(chan int, 1)
settings := map[string]string{
listenAddr: localhost + ":0",
stateDir: t.TempDir(),
rulesDir: t.TempDir(),
listenAddr: localhost + ":0",
stateDir: t.TempDir(),
ruleFilesDir: t.TempDir(),
}
go func() {
@@ -120,7 +120,12 @@ func TestHealthCheckInRProxysContainer(t *testing.T) {
// Another setting does not fail it, even an invalid one, and neither does
// SWW_RPROXY_WAF_BACKEND being unset.
wantHealthCheck(t, settings("SWW_RPROXY_TIMEOUT_MS", "soon"), 0, "")
wantHealthCheck(
t,
settings("SWW_RPROXY_WAF_BACKEND_TIMEOUT_MS", "soon"),
0,
"",
)
// Another test can take a closed server's port at once, so the app's
// address here is a multicast address, to which TCP cannot connect.
+2 -1
View File
@@ -15,7 +15,8 @@ import (
// RunRProxy is `sww rproxy`: it reads rproxy's settings, then serves
// requests until ctx is done. Then it gives the requests in progress
// shutdownTimeout to finish, and the reports being sent SWW_RPROXY_TIMEOUT_MS. It
// shutdownTimeout to finish, and the reports being sent
// SWW_RPROXY_WAF_BACKEND_TIMEOUT_MS. It
// returns the process's exit status, 1 when rproxy cannot start. Its process
// messages name the host's name as instance, and it writes those at info or
// more severe: rproxy has no setting for either.
+13 -12
View File
@@ -31,8 +31,8 @@ const (
rproxyListenAddr = "SWW_RPROXY_LISTEN_ADDR"
// rproxyUpstreamURL is the setting that says where the app is.
rproxyUpstreamURL = "SWW_RPROXY_UPSTREAM_URL"
// rproxyTimeout is the setting that says how long rproxy waits for waf.
rproxyTimeout = "SWW_RPROXY_TIMEOUT_MS"
// wafBackendTimeout is the setting that says how long rproxy waits for waf.
wafBackendTimeout = "SWW_RPROXY_WAF_BACKEND_TIMEOUT_MS"
// bannedProbe is a path the default rule file bans a client for.
bannedProbe = "/.env"
// otherClient is a client the tests do not ban.
@@ -135,9 +135,9 @@ func wafSettings(t *testing.T) map[string]string {
t.Helper()
return map[string]string{
listenAddr: localhost + ":0",
stateDir: t.TempDir(),
rulesDir: filepath.Join("..", "..", "share", "rules.d"),
listenAddr: localhost + ":0",
stateDir: t.TempDir(),
ruleFilesDir: filepath.Join("..", "..", "share", "rules.d"),
}
}
@@ -241,12 +241,13 @@ func TestTheSiteAnswersWhileWAFIsStoppedAndStartedAgain(t *testing.T) {
forwardTo("")
stop()
// With waf down, the site answers, and rproxy says the default did it.
// With waf down, the site answers, and rproxy says
// SWW_RPROXY_WAF_BACKEND_NO_ANSWER_DISPOSITION did it.
wantStatus(t, site+"/after-stop", placed, http.StatusOK)
line := out.line(t, "path", "/after-stop")
if line["disposition"] != waf.DispositionAllow ||
line["default_disposition"] != true {
line["waf_backend_no_answer"] != true {
t.Errorf("rproxy's line is %v", line)
}
@@ -475,7 +476,7 @@ func TestATargetThatCannotGoToWAFIsRefusedForABannedClient(t *testing.T) {
}
line := out.line(t, "path", "admin")
if line["disposition"] != nil || line["default_disposition"] != nil {
if line["disposition"] != nil || line["waf_backend_no_answer"] != nil {
t.Errorf("rproxy's line is %v, want no disposition", line)
}
}
@@ -496,7 +497,7 @@ func TestAClientWithAZoneIsTheClientWithout(t *testing.T) {
line := out.line(t, "path", bannedProbe)
if line["client_ip"] != "2001:db8::1" ||
line["default_disposition"] != nil {
line["waf_backend_no_answer"] != nil {
t.Errorf("rproxy's line is %v", line)
}
}
@@ -691,7 +692,7 @@ func TestRProxyStoppingWaitsForAReportBeingSent(t *testing.T) {
env := map[string]string{
rproxyListenAddr: localhost + ":0",
"SWW_RPROXY_UPSTREAM_URL": startApp(t),
rproxyTimeout: "60000",
wafBackendTimeout: "60000",
wafBackend: backend.URL,
}
out := &output{}
@@ -855,8 +856,8 @@ func TestRProxyInvalidSettingStopsTheStart(t *testing.T) {
}{
{"no backend", wafBackend, nil},
{
"another invalid", rproxyTimeout,
map[string]string{wafBackend: "http://waf:8080", rproxyTimeout: "soon"},
"another invalid", wafBackendTimeout,
map[string]string{wafBackend: "http://waf:8080", wafBackendTimeout: "soon"},
},
} {
t.Run(tc.name, func(t *testing.T) {
+29 -29
View File
@@ -116,7 +116,7 @@ func RunWAF(ctx context.Context, params Params) int {
processLog := requestlog.NewProcessLogger(params.Stdout,
config.InstanceName(params.Viper), slog.LevelError)
cfg, err := config.FromEnvironment(params.Viper)
cfg, err := config.WAFFromEnvironment(params.Viper)
if err != nil {
return cannotStart(processLog, "invalid setting", err)
}
@@ -159,7 +159,7 @@ func RunWAF(ctx context.Context, params Params) int {
func runWAFWithSettings(
ctx context.Context,
version string,
cfg *config.Config,
cfg *config.WAF,
stdout io.Writer,
remote *remotelog.Sender,
processLog *slog.Logger,
@@ -170,8 +170,8 @@ func runWAFWithSettings(
alertQueue := newAlertQueue(cfg, now, processLog)
ruleFiles, err := rules.Load(rules.Params{
Dir: cfg.RulesDir,
Enabled: cfg.RulesEnabled,
Dir: cfg.RuleFilesDir,
Enabled: cfg.RuleFilesEnabled,
ProcessLog: processLog,
Alerts: alertQueue,
})
@@ -246,7 +246,7 @@ func cannotStart(processLog *slog.Logger, message string, err error) int {
// after reading the lookup database while SWW_WAF_LOOKUP_SOURCE is file. A lookup
// database that cannot be read is an error.
func newServer(
cfg *config.Config, stdout io.Writer, processLog *slog.Logger,
cfg *config.WAF, stdout io.Writer, processLog *slog.Logger,
now func() time.Time, ruleFiles *rules.Files, alertQueue *alerts.Queue,
crs *coreruleset.CoreRuleSet,
) (*waf.Server, error) {
@@ -293,33 +293,33 @@ func newServer(
// loadStateFiles reads the state and cache files into the parts of server and
// into alertQueue, as state.Load does.
func loadStateFiles(
cfg *config.Config, server *waf.Server, alertQueue *alerts.Queue,
cfg *config.WAF, server *waf.Server, alertQueue *alerts.Queue,
now func() time.Time, processLog *slog.Logger,
) (*state.Files, error) {
return state.Load(state.Params{
Dir: cfg.StateDir,
CacheDir: cfg.CacheDir,
WriteDelay: cfg.StateWriteDelay,
CounterInterval: cfg.StateCounterInterval,
Ledger: server.Ledger,
Limiter: server.Limiter,
GeoJS: server.GeoJS,
Lists: server.Lists,
DNSBL: server.DNSBL,
AbuseIPDB: server.AbuseIPDB,
RouteWatch: server.RouteWatch,
Alerts: alertQueue,
Anomalies: server.Anomalies,
Now: now,
ProcessLog: processLog,
Metrics: server.Metrics,
Dir: cfg.StateDir,
CacheDir: cfg.CacheDir,
BansWriteDelay: cfg.BansWriteDelay,
WriteInterval: cfg.StateWriteInterval,
Ledger: server.Ledger,
Limiter: server.Limiter,
GeoJS: server.GeoJS,
Lists: server.Lists,
DNSBL: server.DNSBL,
AbuseIPDB: server.AbuseIPDB,
RouteWatch: server.RouteWatch,
Alerts: alertQueue,
Anomalies: server.Anomalies,
Now: now,
ProcessLog: processLog,
Metrics: server.Metrics,
})
}
// newAlertQueue returns the queue of the alerts to the webhook, Slack and
// ntfy, with the settings for them.
func newAlertQueue(
cfg *config.Config, now func() time.Time, processLog *slog.Logger,
cfg *config.WAF, now func() time.Time, processLog *slog.Logger,
) *alerts.Queue {
return alerts.New(alerts.Params{
WebhookURL: cfg.AlertWebhookURL,
@@ -338,13 +338,13 @@ func newAlertQueue(
// newRemoteLogSender returns a sender of the log lines to
// SWW_WAF_LOG_REMOTE_URL, with the settings for it.
func newRemoteLogSender(cfg *config.Config) *remotelog.Sender {
func newRemoteLogSender(cfg *config.WAF) *remotelog.Sender {
return remotelog.New(remotelog.Params{
URL: cfg.LogRemoteURL,
RootCAs: cfg.LogRemoteTLSCAs,
Buffer: cfg.LogRemoteBuffer,
Facility: cfg.LogRemoteFacility,
AppName: cfg.LogRemoteAppName,
URL: cfg.LogRemoteURL,
RootCAs: cfg.LogRemoteTLSCAs,
BufferLines: cfg.LogRemoteBufferLines,
Facility: cfg.LogRemoteFacility,
AppName: cfg.LogRemoteAppName,
})
}
+1 -1
View File
@@ -66,7 +66,7 @@ func stopHeldSender(t *testing.T, release time.Duration) time.Duration {
Scheme: remotelog.SchemeTCP,
Host: "127.0.0.1:65536",
},
Buffer: 1,
BufferLines: 1,
})
stopSending := startSending(t.Context(), sender,
+70 -66
View File
@@ -34,20 +34,20 @@ const (
// testVersion is the version the tests give sww.
testVersion = "test"
// localhost is where the tests listen.
localhost = "127.0.0.1"
listenAddr = "SWW_WAF_LISTEN_ADDR"
stateDir = "SWW_WAF_STATE_DIR"
stateWriteDelay = "SWW_WAF_STATE_WRITE_DELAY"
stateCounterInterval = "SWW_WAF_STATE_COUNTER_INTERVAL"
cacheDir = "SWW_WAF_CACHE_DIR"
rateLimitPerDay = "SWW_WAF_RATE_LIMIT_PER_DAY"
rateLimitExemptNets = "SWW_WAF_RATE_LIMIT_EXEMPT_NETS"
rulesDir = "SWW_WAF_RULES_DIR"
lookupSource = "SWW_WAF_LOOKUP_SOURCE"
lookupDBPath = "SWW_WAF_LOOKUP_DB_PATH"
instanceName = "SWW_WAF_INSTANCE_NAME"
adminToken = "SWW_WAF_ADMIN_TOKEN" //nolint:gosec // the setting's name
metricsToken = "SWW_WAF_METRICS_TOKEN" //nolint:gosec // the setting's name
localhost = "127.0.0.1"
listenAddr = "SWW_WAF_LISTEN_ADDR"
stateDir = "SWW_WAF_STATE_DIR"
bansWriteDelay = "SWW_WAF_BANS_WRITE_DELAY"
stateWriteInterval = "SWW_WAF_STATE_WRITE_INTERVAL"
cacheDir = "SWW_WAF_CACHE_DIR"
rateLimitPerDay = "SWW_WAF_RATE_LIMIT_PER_DAY"
rateLimitExemptNets = "SWW_WAF_RATE_LIMIT_EXEMPT_NETS"
ruleFilesDir = "SWW_WAF_RULE_FILES_DIR"
lookupSource = "SWW_WAF_LOOKUP_SOURCE"
lookupDBPath = "SWW_WAF_LOOKUP_DB_PATH"
instanceName = "SWW_WAF_INSTANCE_NAME"
adminToken = "SWW_WAF_ADMIN_TOKEN" //nolint:gosec // the setting's name
metricsToken = "SWW_WAF_METRICS_TOKEN" //nolint:gosec // the setting's name
// adminSecret is the SWW_WAF_ADMIN_TOKEN the tests set.
adminSecret = "fedcba9876543210fedcba9876543210"
// instance is the SWW_WAF_INSTANCE_NAME the tests set where they look at
@@ -246,9 +246,9 @@ func TestAddressInUseStopsTheStart(t *testing.T) {
out := &output{}
status := run(t.Context(), map[string]string{
listenAddr: taken.Addr().String(),
stateDir: t.TempDir(),
rulesDir: t.TempDir(),
listenAddr: taken.Addr().String(),
stateDir: t.TempDir(),
ruleFilesDir: t.TempDir(),
}, out)
if status != 1 {
t.Errorf("exit status %d, want 1", status)
@@ -268,9 +268,9 @@ func TestServesUntilToldToStop(t *testing.T) {
go func() {
exited <- run(ctx, map[string]string{
listenAddr: localhost + ":0",
stateDir: dir,
rulesDir: filepath.Join("..", "..", "share", "rules.d"),
listenAddr: localhost + ":0",
stateDir: dir,
ruleFilesDir: filepath.Join("..", "..", "share", "rules.d"),
}, out)
}()
@@ -320,7 +320,7 @@ func TestLogLevelHoldsBackTheLessSevereProcessLines(t *testing.T) {
exited <- run(ctx, map[string]string{
listenAddr: localhost + ":0",
stateDir: t.TempDir(),
rulesDir: t.TempDir(),
ruleFilesDir: t.TempDir(),
"SWW_WAF_BLOCKLIST_URLS": lists.URL + "/tor.txt",
"SWW_WAF_LOG_LEVEL": "warn",
}, out)
@@ -361,7 +361,7 @@ func TestEveryLogLineAndMetricCarriesTheInstanceName(t *testing.T) {
env := map[string]string{
listenAddr: localhost + ":0",
stateDir: t.TempDir(),
rulesDir: t.TempDir(),
ruleFilesDir: t.TempDir(),
metricsToken: token,
instanceName: instance,
}
@@ -398,12 +398,12 @@ func TestStateKeptAcrossRestarts(t *testing.T) {
env := map[string]string{
listenAddr: localhost + ":0",
stateDir: t.TempDir(),
rulesDir: t.TempDir(),
ruleFilesDir: t.TempDir(),
rateLimitPerDay: "2",
// Neither comes due in the test: the files are written as
// sww stops.
stateWriteDelay: "1h",
stateCounterInterval: "1h",
bansWriteDelay: "1h",
stateWriteInterval: "1h",
}
// The two requests a day allows, and a stop.
@@ -434,7 +434,7 @@ func TestBanRefusesItsNetblockAfterARestartWithAnotherScope(t *testing.T) {
env := map[string]string{
listenAddr: localhost + ":0",
stateDir: t.TempDir(),
rulesDir: t.TempDir(),
ruleFilesDir: t.TempDir(),
rateLimitPerDay: "1",
scope: "24",
}
@@ -478,13 +478,13 @@ func TestBanAddedAndLiftedByEditingBansJSON(t *testing.T) {
dir := t.TempDir()
env := map[string]string{
listenAddr: localhost + ":0",
stateDir: dir,
rulesDir: t.TempDir(),
listenAddr: localhost + ":0",
stateDir: dir,
ruleFilesDir: t.TempDir(),
// No write comes due in the test, so only the watch on the
// directory can take the edits in.
stateWriteDelay: "1h",
stateCounterInterval: "1h",
bansWriteDelay: "1h",
stateWriteInterval: "1h",
}
runUntilStopped(t, env, func(url string) {
@@ -508,14 +508,14 @@ func TestBanAddedAndLiftedThroughTheEndpointsKeptInBansJSON(t *testing.T) {
dir := t.TempDir()
env := map[string]string{
listenAddr: localhost + ":0",
stateDir: dir,
rulesDir: t.TempDir(),
adminToken: adminSecret,
listenAddr: localhost + ":0",
stateDir: dir,
ruleFilesDir: t.TempDir(),
adminToken: adminSecret,
// Neither comes due in the test: the files are written as
// sww stops.
stateWriteDelay: "1h",
stateCounterInterval: "1h",
bansWriteDelay: "1h",
stateWriteInterval: "1h",
}
runUntilStopped(t, env, func(url string) {
@@ -551,9 +551,9 @@ func TestRuleFileAddedWhileRunningTakesEffect(t *testing.T) {
dir := t.TempDir()
env := map[string]string{
listenAddr: localhost + ":0",
stateDir: t.TempDir(),
rulesDir: dir,
listenAddr: localhost + ":0",
stateDir: t.TempDir(),
ruleFilesDir: dir,
// The requests sent until the rule takes effect must not break a
// rate limit, whose ban would refuse them too.
"SWW_WAF_RATE_LIMIT_PER_MINUTE": "off",
@@ -602,13 +602,17 @@ func TestRuleFileThatDoesNotParseStopsTheStart(t *testing.T) {
)
}
func TestRulesDirThatDoesNotExistStopsTheStart(t *testing.T) {
func TestRuleFilesDirThatDoesNotExistStopsTheStart(t *testing.T) {
t.Parallel()
dir := filepath.Join(t.TempDir(), "rules.d")
wantRulesRefused(t, dir, "SWW_WAF_RULES_DIR cannot be read: open "+dir+
": no such file or directory")
wantRulesRefused(
t,
dir,
"SWW_WAF_RULE_FILES_DIR cannot be read: open "+dir+
": no such file or directory",
)
}
func TestCoreRuleSetExclusionsFileThatCannotBeReadStopsTheStart(
@@ -620,7 +624,7 @@ func TestCoreRuleSetExclusionsFileThatCannotBeReadStopsTheStart(
out := &output{}
status := run(t.Context(), map[string]string{
listenAddr: localhost + ":0", stateDir: t.TempDir(), rulesDir: t.TempDir(),
listenAddr: localhost + ":0", stateDir: t.TempDir(), ruleFilesDir: t.TempDir(),
"SWW_WAF_CORE_RULE_SET_EXCLUSIONS_FILE": path,
}, out)
if status != 1 {
@@ -652,8 +656,8 @@ func TestCoreRuleSetExclusionsFileThatCannotBeUsedStopsTheStartWhileTheCoreRuleS
out := &output{}
status := run(t.Context(), map[string]string{
listenAddr: localhost + ":0", stateDir: t.TempDir(), rulesDir: t.TempDir(),
"SWW_WAF_CORE_RULE_SET_MODE": config.WAFModeOff,
listenAddr: localhost + ":0", stateDir: t.TempDir(), ruleFilesDir: t.TempDir(),
"SWW_WAF_CORE_RULE_SET_MODE": config.CoreRuleSetModeOff,
"SWW_WAF_CORE_RULE_SET_EXCLUSIONS_FILE": path,
}, out)
if status != 1 {
@@ -675,7 +679,7 @@ func TestLookupDatabaseReplacedWhileRunningTakesEffect(t *testing.T) {
env := map[string]string{
listenAddr: localhost + ":0",
stateDir: t.TempDir(),
rulesDir: t.TempDir(),
ruleFilesDir: t.TempDir(),
metricsToken: token,
instanceName: instance,
lookupSource: "file",
@@ -788,7 +792,7 @@ func TestBlocklistTriesAndCopiesKeptInListsJSONAcrossRestarts(t *testing.T) {
listenAddr: localhost + ":0",
stateDir: t.TempDir(),
cacheDir: cache,
rulesDir: t.TempDir(),
ruleFilesDir: t.TempDir(),
metricsToken: token,
instanceName: instance,
"SWW_WAF_BLOCKLIST_URLS": torURL,
@@ -874,7 +878,7 @@ func TestCrowdSecDecisionListKeptInListsJSONAcrossARestart(t *testing.T) {
listenAddr: localhost + ":0",
stateDir: dir,
cacheDir: cache,
rulesDir: t.TempDir(),
ruleFilesDir: t.TempDir(),
"SWW_WAF_CROWDSEC_LAPI_URL": engine.URL,
"SWW_WAF_CROWDSEC_LAPI_KEY": bouncerKey,
}
@@ -957,7 +961,7 @@ func TestLookupDatabaseThatCannotBeReadStopsTheStart(t *testing.T) {
out := &output{}
status := run(ctx, map[string]string{
listenAddr: localhost + ":0", stateDir: t.TempDir(), rulesDir: t.TempDir(),
listenAddr: localhost + ":0", stateDir: t.TempDir(), ruleFilesDir: t.TempDir(),
lookupSource: "file", lookupDBPath: path,
}, out)
if status != 1 {
@@ -992,7 +996,7 @@ func TestEveryLineIsAlsoSentToTheRemoteLogEndpoint(t *testing.T) {
env := map[string]string{
listenAddr: localhost + ":0",
stateDir: t.TempDir(),
rulesDir: t.TempDir(),
ruleFilesDir: t.TempDir(),
"SWW_WAF_LOG_REMOTE_URL": "syslog+tcp://" + endpoint.Addr().String(),
}
@@ -1049,14 +1053,14 @@ func TestStalledRemoteLogEndpointHoldsUpNoRequest(t *testing.T) {
}()
env := map[string]string{
listenAddr: localhost + ":0",
stateDir: t.TempDir(),
rulesDir: t.TempDir(),
listenAddr: localhost + ":0",
stateDir: t.TempDir(),
ruleFilesDir: t.TempDir(),
"SWW_WAF_LOG_REMOTE_URL": "syslog+tls://" + endpoint.Addr().
String(),
"SWW_WAF_LOG_REMOTE_BUFFER": "1",
metricsToken: token,
instanceName: instance,
"SWW_WAF_LOG_REMOTE_BUFFER_LINES": "1",
metricsToken: token,
instanceName: instance,
}
out := runUntilStopped(t, env, func(url string) {
@@ -1127,7 +1131,7 @@ func TestBanIsAlertedAndAnAlertNotSentIsKeptAcrossARestart(t *testing.T) {
env := map[string]string{
listenAddr: localhost + ":0",
stateDir: dir,
rulesDir: rules,
ruleFilesDir: rules,
"SWW_WAF_ALERT_WEBHOOK_URL": webhook.url,
"SWW_WAF_ALERT_WEBHOOK_HEADERS": "Authorization:Bearer " + adminSecret,
instanceName: instance,
@@ -1233,7 +1237,7 @@ func TestBanIsAlertedToSlackAndNtfy(t *testing.T) {
env := map[string]string{
listenAddr: localhost + ":0",
stateDir: t.TempDir(),
rulesDir: t.TempDir(),
ruleFilesDir: t.TempDir(),
rateLimitPerDay: "1",
// The metrics are read from 127.0.0.1, which no limit counts.
"SWW_WAF_ALLOW_NETS": localhost + "/32",
@@ -1327,7 +1331,7 @@ func TestUnwritableCacheDirStopsTheStart(t *testing.T) {
out := &output{}
status := run(ctx, map[string]string{
listenAddr: localhost + ":0", stateDir: t.TempDir(), rulesDir: t.TempDir(),
listenAddr: localhost + ":0", stateDir: t.TempDir(), ruleFilesDir: t.TempDir(),
cacheDir: filepath.Join(t.TempDir(), "missing"),
}, out)
if status != 1 {
@@ -1356,7 +1360,7 @@ func wantStartRefused(t *testing.T, dir, want string) {
out := &output{}
status := run(ctx, map[string]string{
listenAddr: localhost + ":0", stateDir: dir, rulesDir: t.TempDir(),
listenAddr: localhost + ":0", stateDir: dir, ruleFilesDir: t.TempDir(),
}, out)
if status != 1 {
t.Fatalf("exit status %d, want 1", status)
@@ -1386,7 +1390,7 @@ func wantRulesRefused(t *testing.T, dir, want string) {
out := &output{}
status := run(ctx, map[string]string{
listenAddr: localhost + ":0", stateDir: t.TempDir(), rulesDir: dir,
listenAddr: localhost + ":0", stateDir: t.TempDir(), ruleFilesDir: dir,
}, out)
if status != 1 {
t.Fatalf("exit status %d, want 1", status)
@@ -1458,8 +1462,8 @@ func wantStartingLine(t *testing.T, line map[string]any, dir string) {
listenAddr: localhost + ":0",
stateDir: dir,
"SWW_WAF_MODE": "enforce",
stateWriteDelay: "10s",
stateCounterInterval: "15m",
bansWriteDelay: "10s",
stateWriteInterval: "15m",
"SWW_WAF_ALLOW_NETS": "",
rateLimitExemptNets: "",
"SWW_WAF_DENY_NETS": "",
@@ -1472,11 +1476,11 @@ func wantStartingLine(t *testing.T, line map[string]any, dir string) {
"SWW_WAF_BAN_RESPONSE": "403",
"SWW_WAF_LIMIT_BAN_DURATION": "1h",
"SWW_WAF_LIMIT_BAN_REPEAT_WINDOW": "24h",
"SWW_WAF_MAX_BAN_DURATION": "7d",
"SWW_WAF_LIMIT_BAN_PERMANENT_ABOVE": "7d",
"SWW_WAF_ATTACK_BAN_DURATION": "7d",
"SWW_WAF_MAX_BANS": "5000",
"SWW_WAF_BAN_SCOPE_V4_PREFIX": "32",
"SWW_WAF_RULES_ENABLED": "true",
"SWW_WAF_RULE_FILES_ENABLED": "true",
}
for name, value := range want {
+4 -4
View File
@@ -96,7 +96,7 @@ func TestMainRunsWAF(t *testing.T) {
listenAddr+"="+localhost+":0",
stateDir+"="+dir,
cacheDir+"="+dir,
rulesDir+"="+t.TempDir(),
ruleFilesDir+"="+t.TempDir(),
lookupSource+"=off")
exited := startProcess(t, cmd)
@@ -278,9 +278,9 @@ func TestWAFKeepsItsBansAcrossARestartAndNeedsNoApp(t *testing.T) {
t.Parallel()
env := map[string]string{
listenAddr: localhost + ":0",
stateDir: t.TempDir(),
rulesDir: filepath.Join("..", "..", "share", "rules.d"),
listenAddr: localhost + ":0",
stateDir: t.TempDir(),
ruleFilesDir: filepath.Join("..", "..", "share", "rules.d"),
}
runWAF(t, env, func(addr string) {
+3 -3
View File
@@ -1,7 +1,7 @@
package waf
// The heuristic abuseipdb. It reads SWW_WAF_ABUSEIPDB_KEY,
// SWW_WAF_ABUSEIPDB_MIN_SCORE and SWW_WAF_REPUTATION_ACTION.
// SWW_WAF_ABUSEIPDB_MIN_SCORE and SWW_WAF_DNSBL_ABUSEIPDB_ACTION.
import (
"context"
@@ -16,7 +16,7 @@ import (
// abuseIPDB adds the tag abuseipdb and notes AbuseIPDB, as noteHit does, with
// the score, when its score of the client is a hit, and refuses the request
// when SWW_WAF_REPUTATION_ACTION is deny, as dnsbl does for a zone. While
// when SWW_WAF_DNSBL_ABUSEIPDB_ACTION is deny, as dnsbl does for a zone. While
// SWW_WAF_ABUSEIPDB_KEY is unset it does nothing. A client without a score is
// checked in the background, by the request's address, if its history counts
// an offence, and the request does not wait for the answer. The score is then
@@ -54,7 +54,7 @@ func (abuseIPDB) check(
"scored by AbuseIPDB at or over SWW_WAF_ABUSEIPDB_MIN_SCORE",
)
if rq.h.config.ReputationAction != deny {
if rq.h.config.DNSBLAbuseIPDBAction != deny {
return nil
}
+3 -3
View File
@@ -57,7 +57,7 @@ func TestAttackBanRaisesABanAlertThenAPermanentBanAlert(t *testing.T) {
t.Parallel()
s, clk, server, queue := startWithAlerts(t, map[string]string{
rulesDir: writeRules(t, testRules),
ruleFilesDir: writeRules(t, testRules),
})
start := clk.Now()
netblock := netip.MustParsePrefix(client + "/32")
@@ -121,7 +121,7 @@ func TestObserveModeRaisesTheBanAlertsItWouldHave(t *testing.T) {
s, clk, server, queue := startWithAlerts(t, map[string]string{
mode: observe,
rateLimitPerMinute: "2",
rulesDir: writeRules(t, testRules),
ruleFilesDir: writeRules(t, testRules),
})
start := clk.Now()
@@ -209,7 +209,7 @@ func TestObserveModeWorksOutABanOnlyWhenItsAlertWouldBeSent(t *testing.T) {
s, _, _, queue := startWithAlerts(t, map[string]string{
mode: observe,
rateLimitPerMinute: "2",
rulesDir: writeRules(t, testRules),
ruleFilesDir: writeRules(t, testRules),
alertMaxPerHour: "2",
})
+1 -1
View File
@@ -19,7 +19,7 @@ func TestWithEveryAnomalyThresholdOffARequestIsNotCounted(t *testing.T) {
client: netip.MustParseAddr("203.0.113.9"),
lookedUp: true,
},
h: &handler{config: &config.Config{LookupSource: "geojs"}},
h: &handler{config: &config.WAF{LookupSource: "geojs"}},
}
defer func() {
+13 -13
View File
@@ -21,11 +21,11 @@ import (
// The anomaly thresholds: the prefix of a scope followed by the end of a
// count.
const (
anomalyClient = "SWW_WAF_ANOMALY_CLIENT_"
anomalyNet = "SWW_WAF_ANOMALY_NET_"
anomalyASN = "SWW_WAF_ANOMALY_ASN_"
anomalyTotal = "SWW_WAF_ANOMALY_TOTAL_"
anomalyWatch = "SWW_WAF_WATCH_"
anomalyClient = "SWW_WAF_ANOMALY_CLIENT_"
anomalyNet = "SWW_WAF_ANOMALY_NET_"
anomalyASN = "SWW_WAF_ANOMALY_ASN_"
anomalyTotal = "SWW_WAF_ANOMALY_TOTAL_"
anomalyWatchedNet = "SWW_WAF_ANOMALY_WATCHED_NET_"
requestsPerMinute = "REQUESTS_PER_MINUTE"
requestsPerHour = "REQUESTS_PER_HOUR"
@@ -37,7 +37,7 @@ const (
const (
anomalyNetV4Prefix = "SWW_WAF_ANOMALY_NET_V4_PREFIX"
anomalyNetV6Prefix = "SWW_WAF_ANOMALY_NET_V6_PREFIX"
watchNets = "SWW_WAF_WATCH_NETS"
watchedNets = "SWW_WAF_ANOMALY_WATCHED_NETS"
)
const (
@@ -75,7 +75,7 @@ func TestEachScopeAndWindowOverItsThresholdAlertsOncePerCooldown(
{anomalyASN, anomaly.ScopeASN, netip.Prefix{}, asnDE, map[string]any{"asn": asnDE}},
{anomalyTotal, anomaly.ScopeTotal, netip.Prefix{}, "the whole service", nil},
{
anomalyWatch, anomaly.ScopeWatchedNet, netip.MustParsePrefix(clientsNet),
anomalyWatchedNet, anomaly.ScopeWatchedNet, netip.MustParsePrefix(clientsNet),
"the named netblock office, " + clientsNet, map[string]any{"name": "office"},
},
} {
@@ -97,7 +97,7 @@ func TestEachScopeAndWindowOverItsThresholdAlertsOncePerCooldown(
t.Run(setting, func(t *testing.T) {
t.Parallel()
env := map[string]string{setting: value, watchNets: office}
env := map[string]string{setting: value, watchedNets: office}
s, clk, server, queue := startWithLookupsAndClock(t, env)
start := clk.Now()
@@ -219,7 +219,7 @@ func TestThresholdsOffCountNothingAndAlertNothing(t *testing.T) {
// With every threshold off, nothing is counted.
s, server, queue := startWithLookups(
t,
map[string]string{watchNets: office},
map[string]string{watchedNets: office},
)
for range 5 {
@@ -234,7 +234,7 @@ func TestThresholdsOffCountNothingAndAlertNothing(t *testing.T) {
// With one set, its count alone is counted, in its scope alone.
s, clk, server, queue := startWithLookupsAndClock(t, map[string]string{
anomalyNet + requestsPerMinute: aLot, watchNets: office,
anomalyNet + requestsPerMinute: aLot, watchedNets: office,
})
for range 5 {
@@ -348,7 +348,7 @@ func TestRequestCountsForTheASNumberGeoJSGivesBeforeItEnds(t *testing.T) {
addr, out, started := startProxyWithClock(t, app.URL, geojsURL, clk.Now,
map[string]string{
trustedProxies: trustLocalhost,
lookupTimeout: "1h",
asnAndCountryLookupTimeout: "3600000",
anomalyASN + requestsPerMinute: aLot,
})
server.Store(started)
@@ -379,8 +379,8 @@ func TestEachNamedNetblockCountsTheClientsInIt(t *testing.T) {
t,
readAndAnswer,
map[string]string{
anomalyWatch + requestsPerMinute: aLot,
watchNets: office + ",wide=203.0.0.0/16" +
anomalyWatchedNet + requestsPerMinute: aLot,
watchedNets: office + ",wide=203.0.0.0/16" +
",other=198.51.100.0/25",
},
)
+38 -26
View File
@@ -29,12 +29,12 @@ import (
)
const (
// answerTimeLimit is the default of rproxy's SWW_RPROXY_TIMEOUT_MS, the time
// waf has to answer.
// answerTimeLimit is the default of rproxy's
// SWW_RPROXY_WAF_BACKEND_TIMEOUT_MS, the time waf has to answer.
answerTimeLimit = 2 * time.Second
// defaultLookupWait is the default of SWW_WAF_LOOKUP_TIMEOUT.
// defaultLookupWait is the default of SWW_WAF_ASN_AND_COUNTRY_LOOKUP_TIMEOUT_MS.
defaultLookupWait = 800 * time.Millisecond
// defaultReputationWait is the default of SWW_WAF_REPUTATION_TIMEOUT.
// defaultReputationWait is the default of SWW_WAF_DNSBL_ABUSEIPDB_TIMEOUT_MS.
defaultReputationWait = 2 * time.Second
// The header limits SPEC.md gives.
cookieLimit = 50
@@ -80,7 +80,7 @@ func TestSixteenThousandCookiesAreRefusedBeforeTheCoreRuleSetReadsThem(
) {
t.Parallel()
s := startWAF(t, "", map[string]string{wafMode: block})
s := startWAF(t, "", map[string]string{coreRuleSetMode: block})
req := s.question(siteA, client, "/")
req.Header.Set(cookieHeader, strings.Repeat("a;", 16000))
@@ -160,7 +160,7 @@ func TestEachHeaderLimitRefusesARequestOverIt(t *testing.T) {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
s := startWAF(t, "", map[string]string{wafMode: block})
s := startWAF(t, "", map[string]string{coreRuleSetMode: block})
req := s.question(siteA, client, "/")
tc.make(req)
@@ -195,9 +195,9 @@ func TestHeaderLimitsHoldWhateverTheCoreRuleSetSettingsSay(t *testing.T) {
t.Parallel()
for _, env := range []map[string]string{
{wafMode: off},
{wafMode: "detect"},
{wafMode: block, "SWW_WAF_CORE_RULE_SET_EXEMPT_PATHS": "/"},
{coreRuleSetMode: off},
{coreRuleSetMode: "detect"},
{coreRuleSetMode: block, "SWW_WAF_CORE_RULE_SET_EXEMPT_PATHS": "/"},
} {
t.Run(fmt.Sprint(env), func(t *testing.T) {
t.Parallel()
@@ -217,7 +217,7 @@ func TestClientInTheAllowNetsSkipsTheHeaderLimits(t *testing.T) {
s := startWAF(
t,
"",
map[string]string{wafMode: block, allowNets: client},
map[string]string{coreRuleSetMode: block, allowNets: client},
)
req := s.question(siteA, client, "/")
req.Header.Set(cookieHeader, cookies(cookieLimit+1))
@@ -228,7 +228,11 @@ func TestClientInTheAllowNetsSkipsTheHeaderLimits(t *testing.T) {
func TestObserveModeAllowsARequestOverTheHeaderLimitsUnread(t *testing.T) {
t.Parallel()
s := startWAF(t, "", map[string]string{mode: observe, wafMode: block})
s := startWAF(
t,
"",
map[string]string{mode: observe, coreRuleSetMode: block},
)
req := s.question(siteA, client, "/")
req.Header.Set(cookieHeader, strings.Repeat("a;", 16000))
@@ -298,7 +302,7 @@ func TestTheHeaderLimitsComeBeforeEveryCheckThatRefuses(t *testing.T) {
},
{
"a DNS blocklist", map[string]string{
dnsblZones: dnsblZone, dnsblResolver: noResolver, reputationAction: actionDeny,
dnsblZones: dnsblZone, dnsblResolver: noResolver, dnsblAbuseIPDBAction: actionDeny,
},
"/", func(_ *testing.T, server *waf.Server, _ time.Time) {
loadVerdicts(server, map[string][]string{client: {dnsblZone}})
@@ -306,7 +310,7 @@ func TestTheHeaderLimitsComeBeforeEveryCheckThatRefuses(t *testing.T) {
},
{
"AbuseIPDB",
map[string]string{abuseIPDBKey: accountKey, reputationAction: actionDeny},
map[string]string{abuseIPDBKey: accountKey, dnsblAbuseIPDBAction: actionDeny},
"/", func(_ *testing.T, server *waf.Server, _ time.Time) {
// Over SWW_WAF_ABUSEIPDB_MIN_SCORE, 75 by default.
loadScores(server, map[string]int64{client: 80})
@@ -325,7 +329,9 @@ func TestTheHeaderLimitsComeBeforeEveryCheckThatRefuses(t *testing.T) {
leaveAsItIs,
},
{
"a block rule", map[string]string{rulesDir: writeRules(t, testRules)}, "/blocked",
"a block rule",
map[string]string{ruleFilesDir: writeRules(t, testRules)},
"/blocked",
leaveAsItIs,
},
} {
@@ -366,9 +372,9 @@ func TestARequestOverTheHeaderLimitsRunsNoOtherCheck(t *testing.T) {
t,
map[string]standInRoute{client: {prefixDE, 64496}},
),
dnsblZones: dnsblZone, reputationAction: actionLog,
rulesDir: writeRules(t, "noted path log ^/\n"),
wafMode: block,
dnsblZones: dnsblZone, dnsblAbuseIPDBAction: actionLog,
ruleFilesDir: writeRules(t, "noted path log ^/\n"),
coreRuleSetMode: block,
})
loadLists(t, s.server, map[string][]string{vpnList: {client}})
loadVerdicts(s.server, map[string][]string{client: {dnsblZone}})
@@ -426,7 +432,9 @@ func TestNotWaitingForGeoJSNeverLowersAClientsLimit(t *testing.T) {
// not wait.
geojsURL, _ := startGeoJS(t)
s := startWAF(t, geojsURL, map[string]string{
unknownLimitPercent: "0", lookupTimeout: "1h", rateLimitPerMinute: "1",
unknownCountryLimitPercent: "0",
asnAndCountryLookupTimeout: "3600000",
rateLimitPerMinute: "1",
})
countClient(t, s.server, s.clock.Now())
@@ -459,7 +467,7 @@ func TestAClientGeoJSPlacedInNoCountryCountsAsUnknownWithoutWaiting(
// not wait.
geojsURL, _ := startGeoJS(t)
s := startWAF(t, geojsURL, map[string]string{
unknownLimitPercent: "0", rateLimitPerMinute: "1",
unknownCountryLimitPercent: "0", rateLimitPerMinute: "1",
})
s.server.GeoJS.Load([]lookup.Answer{{
Client: netip.MustParsePrefix(client + "/32"), ASN: "AS64512",
@@ -494,7 +502,7 @@ func TestObserveModeAsksGeoJSNothingForARequestOverTheHeaderLimits(
geojsURL, asked := startGeoJS(t)
s := startWAF(t, geojsURL, map[string]string{
mode: observe, addLookupHeaders: on, lookupTimeout: "1h",
mode: observe, addLookupHeaders: on, asnAndCountryLookupTimeout: "3600000",
})
req := s.question(siteA, fromDE, "/")
req.Header.Set(cookieHeader, cookies(cookieLimit+1))
@@ -622,8 +630,10 @@ func TestARefusedRequestDoesNotWaitForASlowGeoJS(t *testing.T) {
action: requestlog.ActionClientIsRateLimited,
},
{
name: "the rate limits, with a biased threshold",
env: map[string]string{rateLimitPerMinute: "1", unknownLimitPercent: "50"},
name: "the rate limits, with a biased threshold",
env: map[string]string{
rateLimitPerMinute: "1", unknownCountryLimitPercent: "50",
},
prepare: countClient, status: forbidden,
action: requestlog.ActionClientIsRateLimited,
},
@@ -646,8 +656,9 @@ func TestARefusedRequestDoesNotWaitForASlowGeoJS(t *testing.T) {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
// In a synctest bubble, as TestRequestWaitsAsLongAsTheLookupTimeoutSays
// says, so that a request that does not wait takes no time at all.
// In a synctest bubble, as
// TestRequestWaitsAsLongAsTheASNAndCountryLookupTimeoutSays says,
// so that a request that does not wait takes no time at all.
synctest.Test(t, func(t *testing.T) {
server, out := newWAFInBubble(t, tc.env)
tc.prepare(t, server, time.Now())
@@ -759,7 +770,7 @@ func TestWAFAnswersWithinTheTimeLimitWhileEveryOutsideSourceHangs(
return waf.New(params)
}, silentGeoJSURL, time.Now, map[string]string{
tagDeny: "country=KP", wafMode: block,
tagDeny: "country=KP", coreRuleSetMode: block,
dnsblZones: dnsblZone, dnsblResolver: resolver,
abuseIPDBKey: accountKey,
blocklistURLs: held + "/drop.txt", asnLimitPercentURL: held + "/asn.txt",
@@ -770,7 +781,8 @@ func TestWAFAnswersWithinTheTimeLimitWhileEveryOutsideSourceHangs(
// network inside it would keep its clock from moving on.
go server.Lists.Run(t.Context())
// In the bubble, as TestRequestWaitsAsLongAsTheLookupTimeoutSays says, the
// In the bubble, as
// TestRequestWaitsAsLongAsTheASNAndCountryLookupTimeoutSays says, the
// answer takes exactly as long as waf waits, however busy the host is.
// GeoJS and AbuseIPDB never answer there, without the network. The query to
// the DNS blocklist waits on the network, which stops the bubble's clock,
+4 -4
View File
@@ -81,11 +81,11 @@ func limitFigures(hit ratelimit.Hit) map[string]any {
// countedBytes returns the request's bytes, once it has ended, as the
// byte limits and the anomaly thresholds count them: the response's body
// bytes, the request's, or both, as SWW_WAF_BYTES_COUNT says.
// bytes, the request's, or both, as SWW_WAF_BYTES_COUNTED says.
func (rq *request) countedBytes() int64 {
response, request := rq.out.bytes, rq.requestBytes
switch rq.h.config.BytesCount {
switch rq.h.config.BytesCounted {
case "response":
return response
case "request":
@@ -100,8 +100,8 @@ func (rq *request) countedBytes() int64 {
// the metrics. status is what the client was sent, or is sent:
// SWW_WAF_BAN_RESPONSE for a request over a rate limit, what the report says for
// one whose bytes broke a byte limit, the refusal for one that broke the
// error burst. The ban's notes give the client's limit percentage for a
// rate limit or a byte limit; the error burst is not lowered. The ban sets
// refusal limit. The ban's notes give the client's limit percentage for a
// rate limit or a byte limit; the refusal limit is not lowered. The ban sets
// the client's counters back to zero. In observe mode it makes no ban and
// sets nothing back, and raises the alert for the ban it would have made,
// if that alert would be sent.
+16 -16
View File
@@ -90,10 +90,10 @@ func TestBanLengthsFollowTheSettings(t *testing.T) {
t.Parallel()
s, clk, _ := startWithClock(t, "", map[string]string{
rateLimitPerMinute: "1",
limitBanDuration: "10m",
limitBanRepeatWindow: "1h",
maxBanDuration: "1h",
rateLimitPerMinute: "1",
limitBanDuration: "10m",
limitBanRepeatWindow: "1h",
limitBanPermanentAbove: "1h",
})
// breakLimit has client go over the limit of one a minute, and
@@ -130,7 +130,7 @@ func TestBanLengthsFollowTheSettings(t *testing.T) {
clk.advance(10 * time.Minute)
wantExpires(breakLimit(), 30*time.Minute)
// 90 minutes would be longer than SWW_WAF_MAX_BAN_DURATION: the ban is
// 90 minutes would be longer than SWW_WAF_LIMIT_BAN_PERMANENT_ABOVE: the ban is
// permanent.
clk.advance(30 * time.Minute)
@@ -256,10 +256,10 @@ func TestBannedClientIsRefusedBeforeItsCountryIsLookedUp(t *testing.T) {
geojsURL, asked := startGeoJS(t)
s, _, _ := startWithClock(t, geojsURL, map[string]string{
lookupTimeout: "1h",
rateLimitPerMinute: "1",
banScopeV4Prefix: "24",
tagDeny: denyKP,
asnAndCountryLookupTimeout: "3600000",
rateLimitPerMinute: "1",
banScopeV4Prefix: "24",
tagDeny: denyKP,
})
// fromDE's ban covers otherClient, which is refused unasked about.
@@ -319,10 +319,10 @@ func TestBanResponseAnswersEveryRefusalButTheSizeLimits(t *testing.T) {
geojsURL, _ := startGeoJS(t)
env := map[string]string{
lookupTimeout: "1h",
rateLimitPerMinute: "1",
denyNets: denied,
tagDeny: denyKP,
asnAndCountryLookupTimeout: "3600000",
rateLimitPerMinute: "1",
denyNets: denied,
tagDeny: denyKP,
}
if tc.setting != "" {
@@ -345,9 +345,9 @@ func TestBanNotes(t *testing.T) {
geojsURL, _ := startGeoJS(t)
s, clk, server := startWithClock(t, geojsURL, map[string]string{
lookupTimeout: "1h",
rateLimitPerMinute: "1",
tagDeny: denyKP,
asnAndCountryLookupTimeout: "3600000",
rateLimitPerMinute: "1",
tagDeny: denyKP,
})
start := clk.Now()
+24 -23
View File
@@ -17,14 +17,15 @@ type percentage struct {
}
// biasedThresholdsSet reports whether a biased threshold can lower a
// client's limits by its lookup: SWW_WAF_ASN_BYTES_PERCENT or
// SWW_WAF_COUNTRY_BYTES_PERCENT is not empty, SWW_WAF_UNKNOWN_LIMIT_PERCENT is
// below 100, or SWW_WAF_ASN_LIMIT_PERCENT_URL is set. The client's lookup is
// client's limits by its lookup: SWW_WAF_ASN_BYTES_LIMIT_PERCENT or
// SWW_WAF_COUNTRY_BYTES_LIMIT_PERCENT is not empty,
// SWW_WAF_UNKNOWN_COUNTRY_LIMIT_PERCENT is below 100, or
// SWW_WAF_ASN_LIMIT_PERCENT_URL is set. The client's lookup is
// then needed before its request goes on.
func biasedThresholdsSet(cfg *config.Config) bool {
return len(cfg.ASNBytesPercent) > 0 ||
len(cfg.CountryBytesPercent) > 0 ||
cfg.UnknownLimitPercent < whole ||
func biasedThresholdsSet(cfg *config.WAF) bool {
return len(cfg.ASNBytesLimitPercent) > 0 ||
len(cfg.CountryBytesLimitPercent) > 0 ||
cfg.UnknownCountryLimitPercent < whole ||
cfg.ASNLimitPercentURL != ""
}
@@ -37,13 +38,13 @@ func biasedThresholdsSet(cfg *config.Config) bool {
// SWW_WAF_ASN_LIMIT_PERCENT_URL names gives its AS number, the one
// SWW_WAF_TAG_LIMIT_PERCENT gives its country= tag, the lowest it gives its
// other tags, for a client without a country, unless its request skipped
// the wait for GeoJS, SWW_WAF_UNKNOWN_LIMIT_PERCENT, for a client a blocklist
// the wait for GeoJS, SWW_WAF_UNKNOWN_COUNTRY_LIMIT_PERCENT, for a client a blocklist
// lists, the percentage of SWW_WAF_BLOCKLIST_ACTION while it is limit, and
// for a client a DNSBL zone's verdict lists, or whose AbuseIPDB score is
// a hit, the percentage of SWW_WAF_REPUTATION_ACTION while it is limit. For
// the byte limits, SWW_WAF_ASN_BYTES_PERCENT and SWW_WAF_COUNTRY_BYTES_PERCENT
// take the place of the first three for an AS number or a country they
// list.
// a hit, the percentage of SWW_WAF_DNSBL_ABUSEIPDB_ACTION while it is limit. For
// the byte limits, SWW_WAF_ASN_BYTES_LIMIT_PERCENT and
// SWW_WAF_COUNTRY_BYTES_LIMIT_PERCENT take the place of the first three for
// an AS number or a country they list.
func (rq *request) limitPercentages() (percentage, percentage) {
cfg := rq.h.config
asn, country := rq.line.ASN, rq.line.Country
@@ -51,8 +52,8 @@ func (rq *request) limitPercentages() (percentage, percentage) {
unknown := percentage{percent: whole}
if country == "" && !rq.skippedLookupWait {
unknown = percentage{
cfg.UnknownLimitPercent,
"SWW_WAF_UNKNOWN_LIMIT_PERCENT",
cfg.UnknownCountryLimitPercent,
"SWW_WAF_UNKNOWN_COUNTRY_LIMIT_PERCENT",
}
}
@@ -71,10 +72,10 @@ func (rq *request) limitPercentages() (percentage, percentage) {
reputationListed := percentage{percent: whole}
if (rq.dnsblListed || rq.abuseIPDBHit) &&
cfg.ReputationAction == "limit" {
cfg.DNSBLAbuseIPDBAction == "limit" {
reputationListed = percentage{
cfg.ReputationLimitPercent,
"SWW_WAF_REPUTATION_ACTION",
cfg.DNSBLAbuseIPDBLimitPercent,
"SWW_WAF_DNSBL_ABUSEIPDB_ACTION",
}
}
@@ -102,19 +103,19 @@ func (rq *request) limitPercentages() (percentage, percentage) {
}
asnBytes, countryBytes := asnRequests, countryRequests
if _, listed := cfg.ASNBytesPercent[asn]; listed {
if _, listed := cfg.ASNBytesLimitPercent[asn]; listed {
asnBytes = given(
cfg.ASNBytesPercent,
cfg.ASNBytesLimitPercent,
asn,
"SWW_WAF_ASN_BYTES_PERCENT",
"SWW_WAF_ASN_BYTES_LIMIT_PERCENT",
)
}
if _, listed := cfg.CountryBytesPercent[country]; listed {
if _, listed := cfg.CountryBytesLimitPercent[country]; listed {
countryBytes = given(
cfg.CountryBytesPercent,
cfg.CountryBytesLimitPercent,
country,
"SWW_WAF_COUNTRY_BYTES_PERCENT",
"SWW_WAF_COUNTRY_BYTES_LIMIT_PERCENT",
)
}
+51 -37
View File
@@ -8,6 +8,7 @@ import (
"net/http/httptest"
"net/netip"
"path/filepath"
"strconv"
"strings"
"testing"
"testing/synctest"
@@ -22,10 +23,10 @@ import (
// The biased thresholds.
const (
tagLimitPercent = "SWW_WAF_TAG_LIMIT_PERCENT"
asnBytesPercent = "SWW_WAF_ASN_BYTES_PERCENT"
countryBytesPercent = "SWW_WAF_COUNTRY_BYTES_PERCENT"
unknownLimitPercent = "SWW_WAF_UNKNOWN_LIMIT_PERCENT"
tagLimitPercent = "SWW_WAF_TAG_LIMIT_PERCENT"
asnBytesLimitPercent = "SWW_WAF_ASN_BYTES_LIMIT_PERCENT"
countryBytesLimitPercent = "SWW_WAF_COUNTRY_BYTES_LIMIT_PERCENT"
unknownCountryLimitPercent = "SWW_WAF_UNKNOWN_COUNTRY_LIMIT_PERCENT"
)
const (
@@ -60,7 +61,7 @@ func TestEachBiasedThresholdLowersTheRateLimits(t *testing.T) {
}{
{tagLimitPercent, asnTagDEHalf, fromDE},
{tagLimitPercent, countryTagDEHalf, fromDE},
{unknownLimitPercent, "50", unplaced},
{unknownCountryLimitPercent, "50", unplaced},
} {
t.Run(tc.setting, func(t *testing.T) {
t.Parallel()
@@ -108,9 +109,9 @@ func TestEachBiasedThresholdLowersTheByteLimits(t *testing.T) {
}{
{tagLimitPercent, asnTagDEHalf, fromDE},
{tagLimitPercent, "Country=de:50", fromDE},
{unknownLimitPercent, "50", unplaced},
{asnBytesPercent, "as64496:50", fromDE},
{countryBytesPercent, countryDEHalf, fromDE},
{unknownCountryLimitPercent, "50", unplaced},
{asnBytesLimitPercent, "as64496:50", fromDE},
{countryBytesLimitPercent, countryDEHalf, fromDE},
} {
t.Run(tc.setting, func(t *testing.T) {
t.Parallel()
@@ -149,7 +150,7 @@ func TestEachBiasedThresholdLowersTheByteLimits(t *testing.T) {
}
}
func TestBytesPercentSettingsTakeThePlaceOfTheOthersForByteLimits(
func TestBytesLimitPercentSettingsTakeThePlaceOfTheOthersForByteLimits(
t *testing.T,
) {
t.Parallel()
@@ -163,22 +164,28 @@ func TestBytesPercentSettingsTakeThePlaceOfTheOthersForByteLimits(
}{
{
"lowering the byte limits alone",
map[string]string{asnBytesPercent: asnDEHalf},
none, "50 from " + asnBytesPercent, minuteBytes,
map[string]string{asnBytesLimitPercent: asnDEHalf},
none, "50 from " + asnBytesLimitPercent, minuteBytes,
},
{
"lowering the byte limits alone, by country",
map[string]string{countryBytesPercent: countryDEHalf},
none, "50 from " + countryBytesPercent, minuteBytes,
map[string]string{countryBytesLimitPercent: countryDEHalf},
none, "50 from " + countryBytesLimitPercent, minuteBytes,
},
{
"raising the byte limits back",
map[string]string{tagLimitPercent: asnTagDEHalf, asnBytesPercent: asnDE + ":100"},
map[string]string{
tagLimitPercent: asnTagDEHalf,
asnBytesLimitPercent: asnDE + ":100",
},
"50 from " + tagLimitPercent, none, "",
},
{
"raising the byte limits back, by country",
map[string]string{tagLimitPercent: countryTagDEHalf, countryBytesPercent: "de:100"},
map[string]string{
tagLimitPercent: countryTagDEHalf,
countryBytesLimitPercent: "de:100",
},
"50 from " + tagLimitPercent, none, "",
},
} {
@@ -214,7 +221,7 @@ func TestZeroPercentIsAZeroAllowance(t *testing.T) {
s, _, _ := startWithLookups(
t,
map[string]string{unknownLimitPercent: "0"},
map[string]string{unknownCountryLimitPercent: "0"},
)
// The first request breaks the limit, and bans the client; the log line
@@ -225,12 +232,12 @@ func TestZeroPercentIsAZeroAllowance(t *testing.T) {
requestlog.ActionClientIsRateLimited,
)
if line.fields["rate_limit_percent"] != float64(0) ||
line.fields["rate_limit_percent_setting"] != unknownLimitPercent {
line.fields["rate_limit_percent_setting"] != unknownCountryLimitPercent {
t.Errorf(
"log line has rate_limit_percent %v from %v, want 0 from %s",
line.fields["rate_limit_percent"],
line.fields["rate_limit_percent_setting"],
unknownLimitPercent,
unknownCountryLimitPercent,
)
}
@@ -264,11 +271,11 @@ func TestLowestPercentageApplies(t *testing.T) {
},
{
"that for a client without a country",
map[string]string{tagLimitPercent: "asn=64500:50", unknownLimitPercent: "25"},
noCountry, "25 from " + unknownLimitPercent,
map[string]string{tagLimitPercent: "asn=64500:50", unknownCountryLimitPercent: "25"},
noCountry, "25 from " + unknownCountryLimitPercent,
},
{
// SWW_WAF_UNKNOWN_LIMIT_PERCENT is left at its default, 100.
// SWW_WAF_UNKNOWN_COUNTRY_LIMIT_PERCENT is left at its default, 100.
"the AS number's, for a client without a country",
map[string]string{tagLimitPercent: "asn=64500:25"},
noCountry, "25 from " + tagLimitPercent,
@@ -296,13 +303,13 @@ func TestLowestPercentageApplies(t *testing.T) {
}
}
func TestUnknownLimitPercentGivesEveryClientWithoutACountryItsPercentage(
func TestUnknownCountryLimitPercentGivesEveryClientWithoutACountryItsPercentage(
t *testing.T,
) {
t.Parallel()
s, _, _ := startWithLookups(t, map[string]string{
rateLimitPerMinute: fourAMinute, unknownLimitPercent: "50",
rateLimitPerMinute: fourAMinute, unknownCountryLimitPercent: "50",
})
// One the lookup database does not hold, and one on a private address,
@@ -323,14 +330,17 @@ func TestUnknownLimitPercentGivesEveryClientWithoutACountryItsPercentage(
}
}
func TestClientWithoutAnAnswerInTimeHasTheUnknownLimitPercent(t *testing.T) {
func TestClientWithoutAnAnswerInTimeHasTheUnknownCountryLimitPercent(
t *testing.T,
) {
t.Parallel()
// In a synctest bubble, as TestRequestWaitsAsLongAsTheLookupTimeoutSays
// says, with a GeoJS that never answers.
// In a synctest bubble, as
// TestRequestWaitsAsLongAsTheASNAndCountryLookupTimeoutSays says, with
// a GeoJS that never answers.
synctest.Test(t, func(t *testing.T) {
server, out, _ := newWAF(t, silentGeoJSURL, time.Now,
map[string]string{unknownLimitPercent: "0"})
map[string]string{unknownCountryLimitPercent: "0"})
// Once the time the request waits for its answer is up, the client
// counts as without a country, and its zero allowance refuses the
@@ -346,7 +356,7 @@ func TestClientWithoutAnAnswerInTimeHasTheUnknownLimitPercent(t *testing.T) {
)
wantPercent(t, "rate_limit_percent",
line.RateLimitPercent, line.RateLimitPercentSetting,
"0 from "+unknownLimitPercent)
"0 from "+unknownCountryLimitPercent)
})
}
@@ -365,22 +375,26 @@ func TestRequestWaitsForItsLookupWhileABiasedThresholdIsSet(t *testing.T) {
// A tag the lookup does not give needs no answer.
{tagLimitPercent, "vpn:50", false},
{tagDeny, "vpn", false},
{asnBytesPercent, asnDEHalf, true},
{countryBytesPercent, countryDEHalf, true},
{unknownLimitPercent, "99", true},
{asnBytesLimitPercent, asnDEHalf, true},
{countryBytesLimitPercent, countryDEHalf, true},
{unknownCountryLimitPercent, "99", true},
{asnLimitPercentURL, asnURL, true},
// At 100, its default, it lowers no limit.
{unknownLimitPercent, "100", false},
{unknownCountryLimitPercent, "100", false},
} {
t.Run(tc.setting+"="+tc.value, func(t *testing.T) {
t.Parallel()
// In a synctest bubble, as TestRequestWaitsAsLongAsTheLookupTimeoutSays
// says, with a GeoJS that never answers.
// In a synctest bubble, as
// TestRequestWaitsAsLongAsTheASNAndCountryLookupTimeoutSays says,
// with a GeoJS that never answers.
synctest.Test(t, func(t *testing.T) {
server, out, _ := newWAF(t, silentGeoJSURL, time.Now,
map[string]string{
lookupTimeout: timeout.String(), tagLists: "vpn=" + vpnList, tc.setting: tc.value,
asnAndCountryLookupTimeout: strconv.FormatInt(
timeout.Milliseconds(), 10),
tagLists: "vpn=" + vpnList,
tc.setting: tc.value,
})
began := time.Now()
@@ -449,10 +463,10 @@ func TestBanForALoweredLimitGivesThePercentageInItsNotesAndItsAlert(
"a byte limit",
map[string]string{
bytesLimitPerMinute: twoUploads, tagLimitPercent: asnTagDEQuarter,
asnBytesPercent: asnDEHalf,
asnBytesLimitPercent: asnDEHalf,
},
0, http.StatusOK, requestlog.ActionForward,
"bytes per minute over the limit of 99", "50 from " + asnBytesPercent,
"bytes per minute over the limit of 99", "50 from " + asnBytesLimitPercent,
},
} {
t.Run(tc.name, func(t *testing.T) {
+1 -1
View File
@@ -1,6 +1,6 @@
package waf
// The heuristic byte_limits. It reads SWW_WAF_BYTES_COUNT, and the byte limits
// The heuristic byte_limits. It reads SWW_WAF_BYTES_COUNTED, and the byte limits
// and biased thresholds through the limiter and limitPercentages.
import (
+5 -5
View File
@@ -22,10 +22,10 @@ const (
bytesLimitPerMinute = "SWW_WAF_BYTES_LIMIT_PER_MINUTE"
bytesLimitPerHour = "SWW_WAF_BYTES_LIMIT_PER_HOUR"
bytesLimitPerDay = "SWW_WAF_BYTES_LIMIT_PER_DAY"
bytesCount = "SWW_WAF_BYTES_COUNT"
bytesCounted = "SWW_WAF_BYTES_COUNTED"
)
// The values of SWW_WAF_BYTES_COUNT.
// The values of SWW_WAF_BYTES_COUNTED.
const (
countResponse = "response"
countRequest = "request"
@@ -212,7 +212,7 @@ func TestWebSocketBytesAreCountedOnceItCloses(t *testing.T) {
t,
answerAfterUpgrade,
map[string]string{
bytesLimitPerMinute: "29", bytesCount: tc.setting,
bytesLimitPerMinute: "29", bytesCounted: tc.setting,
},
)
@@ -239,7 +239,7 @@ func TestWebSocketBytesAreCountedOnceItCloses(t *testing.T) {
}
}
func TestBytesCountSaysWhichBytesCount(t *testing.T) {
func TestBytesCountedSaysWhichBytesAreCounted(t *testing.T) {
t.Parallel()
for _, tc := range []struct {
@@ -257,7 +257,7 @@ func TestBytesCountSaysWhichBytesCount(t *testing.T) {
t.Parallel()
s, _ := startWithAnswers(t, map[string]string{
bytesLimitPerMinute: byteLimit, bytesCount: tc.setting,
bytesLimitPerMinute: byteLimit, bytesCounted: tc.setting,
})
for i := 1; i <= tc.breaking; i++ {
+11 -11
View File
@@ -1,7 +1,7 @@
package waf
// The heuristic core_rule_set. It reads SWW_WAF_CORE_RULE_SET_MODE,
// SWW_WAF_CORE_RULE_SET_EXEMPT_PATHS and SWW_WAF_CORE_RULE_SET_ANOMALY_THRESHOLD.
// SWW_WAF_CORE_RULE_SET_EXEMPT_PATHS and SWW_WAF_CORE_RULE_SET_SCORE_THRESHOLD.
import (
"context"
@@ -17,7 +17,7 @@ import (
// SWW_WAF_CORE_RULE_SET_MODE is off or SWW_WAF_CORE_RULE_SET_EXEMPT_PATHS
// exempts its path, as pathExempt decides, and notes the rules it matched and
// its score in the log line, and the rules in the metrics. A score at or over
// SWW_WAF_CORE_RULE_SET_ANOMALY_THRESHOLD is a match: it adds the tag
// SWW_WAF_CORE_RULE_SET_SCORE_THRESHOLD is a match: it adds the tag
// exploitsignature, raises the core_rule_set_match alert, and in block mode
// refuses the request with 403, which is an offence its client's history
// counts. Its figures are the score and the threshold.
@@ -31,8 +31,8 @@ func (coreRuleSet) check(
_ time.Time,
) *verdict {
cfg := rq.h.config
if cfg.WAFMode == config.WAFModeOff ||
pathExempt(rq.in, cfg.WAFExemptPaths) {
if cfg.CoreRuleSetMode == config.CoreRuleSetModeOff ||
pathExempt(rq.in, cfg.CoreRuleSetExemptPaths) {
return nil
}
@@ -50,10 +50,10 @@ func (coreRuleSet) check(
rq.line.CoreRuleSetScore = &result.Score
for _, id := range result.RuleIDs {
rq.h.metrics.CoreRuleSetMatched(cfg.WAFMode, id)
rq.h.metrics.CoreRuleSetMatched(cfg.CoreRuleSetMode, id)
}
threshold := cfg.WAFAnomalyThreshold
threshold := cfg.CoreRuleSetScoreThreshold
if threshold == 0 || result.Score < threshold {
return nil
}
@@ -61,7 +61,7 @@ func (coreRuleSet) check(
rq.addTag(config.ExploitSignatureTag)
rq.alertCoreRuleSetMatch(result)
if cfg.WAFMode == config.WAFModeDetect {
if cfg.CoreRuleSetMode == config.CoreRuleSetModeDetect {
return nil
}
@@ -78,7 +78,7 @@ func (coreRuleSet) check(
// alertCoreRuleSetMatch raises the core_rule_set_match alert for the request,
// which the Core Rule Set scored at result, at or over
// SWW_WAF_CORE_RULE_SET_ANOMALY_THRESHOLD. Its detail gives the rule ids, the
// SWW_WAF_CORE_RULE_SET_SCORE_THRESHOLD. Its detail gives the rule ids, the
// score, the method and the path with the query, and, for a request that is
// not refused for it, the mode: detect, or observe in observe mode.
func (rq *request) alertCoreRuleSetMatch(result coreruleset.Result) {
@@ -90,8 +90,8 @@ func (rq *request) alertCoreRuleSetMatch(result coreruleset.Result) {
}
switch {
case rq.h.config.WAFMode == config.WAFModeDetect:
detail["mode"] = config.WAFModeDetect
case rq.h.config.CoreRuleSetMode == config.CoreRuleSetModeDetect:
detail["mode"] = config.CoreRuleSetModeDetect
case rq.h.config.Observe:
detail["mode"] = "observe"
}
@@ -105,7 +105,7 @@ func (rq *request) alertCoreRuleSetMatch(result coreruleset.Result) {
ASName: rq.line.ASName,
Country: rq.line.Country,
Reason: "scored by the Core Rule Set at or over " +
"SWW_WAF_CORE_RULE_SET_ANOMALY_THRESHOLD",
"SWW_WAF_CORE_RULE_SET_SCORE_THRESHOLD",
Detail: detail,
})
}
+56 -40
View File
@@ -18,20 +18,20 @@ import (
// The Core Rule Set's settings the tests set, besides SWW_WAF_CORE_RULE_SET_MODE, and
// its two modes that inspect requests.
const (
wafAnomalyThreshold = "SWW_WAF_CORE_RULE_SET_ANOMALY_THRESHOLD"
wafDisabledRules = "SWW_WAF_CORE_RULE_SET_DISABLED_RULES"
wafExemptPaths = "SWW_WAF_CORE_RULE_SET_EXEMPT_PATHS"
wafBodyLimit = "SWW_WAF_CORE_RULE_SET_BODY_LIMIT"
apps = "SWW_WAF_APPS"
block = "block"
detect = "detect"
coreRuleSetScoreThreshold = "SWW_WAF_CORE_RULE_SET_SCORE_THRESHOLD"
coreRuleSetDisabledRules = "SWW_WAF_CORE_RULE_SET_DISABLED_RULES"
coreRuleSetExemptPaths = "SWW_WAF_CORE_RULE_SET_EXEMPT_PATHS"
coreRuleSetBodyInspectionSize = "SWW_WAF_CORE_RULE_SET_BODY_INSPECTION_SIZE"
apps = "SWW_WAF_APPS"
block = "block"
detect = "detect"
)
// formData is the type of a form's body.
const formData = "application/x-www-form-urlencoded"
// sizeLimit is SWW_WAF_CORE_RULE_SET_BODY_LIMIT in the tests that read bodies, 1K as a
// setting.
// sizeLimit is SWW_WAF_CORE_RULE_SET_BODY_INSPECTION_SIZE in the tests that
// read bodies, 1K as a setting.
const (
sizeLimit = 1 << 10
sizeLimitSetting = "1K"
@@ -39,7 +39,7 @@ const (
// sqlInjection asks for / with an SQL injection in its query, which only
// the Core Rule Set's rule 942100 matches, with a score of 5, the default
// SWW_WAF_CORE_RULE_SET_ANOMALY_THRESHOLD.
// SWW_WAF_CORE_RULE_SET_SCORE_THRESHOLD.
const sqlInjection = "/?id=1'%20OR%20'1'='1"
// wantCoreRuleSet checks the request log line's core_rule_set_rule_ids and
@@ -95,7 +95,7 @@ func TestCoreRuleSetMatchAddsTheExploitSignatureTag(t *testing.T) {
s, _, _ := startWithClock(
t,
"",
map[string]string{wafMode: tc.mode},
map[string]string{coreRuleSetMode: tc.mode},
)
wantTags(
@@ -115,7 +115,7 @@ func TestDetectedAttackIsRefusedByTagDenyLast(t *testing.T) {
t.Parallel()
s, _, _ := startWithClock(t, "", map[string]string{
wafMode: detect, tagDeny: "exploitsignature",
coreRuleSetMode: detect, tagDeny: "exploitsignature",
})
line := s.request(client, sqlInjection, http.StatusForbidden,
@@ -167,7 +167,7 @@ func TestCoreRuleSetRefusesAttacksInBlockModeAndOnlyLogsThemInDetectMode(
s, _, _ := startWithClock(
t,
"",
map[string]string{wafMode: tc.mode},
map[string]string{coreRuleSetMode: tc.mode},
)
line := s.requestWithHeader(
@@ -186,7 +186,11 @@ func TestCoreRuleSetRefusesAttacksInBlockModeAndOnlyLogsThemInDetectMode(
func TestCoreRuleSetRefusalNamesItsHeuristicScoreAndThreshold(t *testing.T) {
t.Parallel()
s, _, _ := startWithClock(t, "", map[string]string{wafMode: block})
s, _, _ := startWithClock(
t,
"",
map[string]string{coreRuleSetMode: block},
)
line := s.request(client, sqlInjection, http.StatusForbidden,
requestlog.ActionBlockedByCoreRuleSet)
@@ -196,7 +200,11 @@ func TestCoreRuleSetRefusalNamesItsHeuristicScoreAndThreshold(t *testing.T) {
func TestOrdinaryRequestIsInspectedAndPassed(t *testing.T) {
t.Parallel()
s, _, _ := startWithClock(t, "", map[string]string{wafMode: block})
s, _, _ := startWithClock(
t,
"",
map[string]string{coreRuleSetMode: block},
)
line := s.request(
client,
@@ -215,10 +223,10 @@ func TestCoreRuleSetIsNotRunWhenOffOrForAnExemptClientPathOrRuleFileRefusal(
const allowed = "192.0.2.60" // in SWW_WAF_ALLOW_NETS
s, _, _ := startWithClock(t, "", map[string]string{
wafMode: block,
wafExemptPaths: "/api/",
allowNets: allowed,
rulesDir: writeRules(t, testRules),
coreRuleSetMode: block,
coreRuleSetExemptPaths: "/api/",
allowNets: allowed,
ruleFilesDir: writeRules(t, testRules),
})
// A client in SWW_WAF_ALLOW_NETS, and a path SWW_WAF_CORE_RULE_SET_EXEMPT_PATHS
@@ -258,7 +266,7 @@ func TestCoreRuleSetIsNotRunWhenOffOrForAnExemptClientPathOrRuleFileRefusal(
wantCoreRuleSet(t, line, nil)
// With SWW_WAF_CORE_RULE_SET_MODE off, no request is.
s, _, _ = startWithClock(t, "", map[string]string{wafMode: off})
s, _, _ = startWithClock(t, "", map[string]string{coreRuleSetMode: off})
line = s.request(
client,
sqlInjection,
@@ -268,14 +276,14 @@ func TestCoreRuleSetIsNotRunWhenOffOrForAnExemptClientPathOrRuleFileRefusal(
wantCoreRuleSet(t, line, nil)
}
func TestAnomalyThreshold(t *testing.T) {
func TestCoreRuleSetScoreThreshold(t *testing.T) {
t.Parallel()
// A score under the threshold, or with the threshold off, is logged,
// and refuses nothing.
for _, threshold := range []string{"6", off} {
s, _, _ := startWithClock(t, "", map[string]string{
wafMode: block, wafAnomalyThreshold: threshold,
coreRuleSetMode: block, coreRuleSetScoreThreshold: threshold,
})
line := s.request(
@@ -288,7 +296,7 @@ func TestAnomalyThreshold(t *testing.T) {
}
s, _, _ := startWithClock(t, "", map[string]string{
wafMode: block, wafAnomalyThreshold: "5",
coreRuleSetMode: block, coreRuleSetScoreThreshold: "5",
})
s.request(client, sqlInjection, http.StatusForbidden,
requestlog.ActionBlockedByCoreRuleSet)
@@ -334,7 +342,7 @@ func TestDisabledRulesSwitchOffWhatGiteaWouldBeRefused(t *testing.T) {
s, _, _ := startWithClock(
t,
"",
map[string]string{wafMode: block},
map[string]string{coreRuleSetMode: block},
)
line, _ := s.requestWithBody(
request.method,
@@ -349,7 +357,7 @@ func TestDisabledRulesSwitchOffWhatGiteaWouldBeRefused(t *testing.T) {
// A list given replaces the default.
s, _, _ = startWithClock(t, "", map[string]string{
wafMode: block, wafDisabledRules: "942100",
coreRuleSetMode: block, coreRuleSetDisabledRules: "942100",
})
score := 5 * len(request.ruleIDs)
line, _ = s.requestWithBody(
@@ -383,7 +391,7 @@ func TestGiteasFixesApplyOnlyToAHostnameThatServesGitea(t *testing.T) {
const fileName = "/?path=.gitignore"
s := startWAF(t, "", map[string]string{
wafMode: block, apps: siteA + ":gitea",
coreRuleSetMode: block, apps: siteA + ":gitea",
})
wantDisposition(t, do(t, s.question(siteA, client, fileName)),
@@ -409,13 +417,17 @@ func TestAttackInAFormBodyIsRefusedOnlyWhileBodiesAreRead(t *testing.T) {
header := "Content-Type: " + formData + "\r\nContent-Length: " +
strconv.Itoa(len(body))
s, _, _ := startWithClock(t, "", map[string]string{wafMode: block})
s, _, _ := startWithClock(
t,
"",
map[string]string{coreRuleSetMode: block},
)
line, _ := s.requestWithBody(http.MethodPost, client, "/", header, body,
http.StatusOK, requestlog.ActionForward)
wantCoreRuleSet(t, line, new(0))
s, _, _ = startWithClock(t, "", map[string]string{
wafMode: block, wafBodyLimit: sizeLimitSetting,
coreRuleSetMode: block, coreRuleSetBodyInspectionSize: sizeLimitSetting,
})
line, _ = s.requestWithBody(http.MethodPost, client, "/", header, body,
http.StatusForbidden, requestlog.ActionBlockedByCoreRuleSet)
@@ -432,7 +444,7 @@ func TestBodiesReachTheAppAsSentWhileBodiesAreRead(t *testing.T) {
_, _ = w.Write(body)
})
addr, out := startProxy(t, app.URL, map[string]string{
wafMode: block, wafBodyLimit: sizeLimitSetting,
coreRuleSetMode: block, coreRuleSetBodyInspectionSize: sizeLimitSetting,
})
longer := "a=" + strings.Repeat("b", 64*sizeLimit)
@@ -496,7 +508,11 @@ func TestResponsesAreNotInspected(t *testing.T) {
app := startApp(t, func(w http.ResponseWriter, _ *http.Request) {
_, _ = w.Write([]byte(page))
})
addr, out := startProxy(t, app.URL, map[string]string{wafMode: block})
addr, out := startProxy(
t,
app.URL,
map[string]string{coreRuleSetMode: block},
)
got := get(t, addr, "/owner/repo/raw/branch/main/build.sh")
if got.status != http.StatusOK || string(got.body) != page {
@@ -510,13 +526,13 @@ func TestResponsesAreNotInspected(t *testing.T) {
wantLine(t, out.endedLine(t), http.StatusOK, requestlog.ActionForward)
}
func TestCoreRuleSetRefusalIsAnOffenceAndCountsTowardTheErrorBurst(
func TestCoreRuleSetRefusalIsAnOffenceAndCountsTowardTheRefusalLimit(
t *testing.T,
) {
t.Parallel()
s, _, server := startWithClock(t, "", map[string]string{
wafMode: block, errorBurstThreshold: "2", metricsToken: token,
coreRuleSetMode: block, refusalsLimitPerMinute: "2", metricsToken: token,
})
for range 2 {
@@ -524,7 +540,7 @@ func TestCoreRuleSetRefusalIsAnOffenceAndCountsTowardTheErrorBurst(
requestlog.ActionBlockedByCoreRuleSet)
}
// The third refusal in a minute breaks the error burst, and bans the
// The third refusal in a minute breaks the refusal limit, and bans the
// client.
line := s.request(client, sqlInjection, http.StatusForbidden,
requestlog.ActionBlockedByCoreRuleSet)
@@ -566,13 +582,13 @@ func TestCoreRuleSetRefusalIsAnOffenceAndCountsTowardTheErrorBurst(
)
}
func TestDetectModeMatchIsNoOffenceAndNotCountedTowardTheErrorBurst(
func TestDetectModeMatchIsNoOffenceAndNotCountedTowardTheRefusalLimit(
t *testing.T,
) {
t.Parallel()
s, _, server := startWithClock(t, "", map[string]string{
wafMode: detect, errorBurstThreshold: "2", metricsToken: token,
coreRuleSetMode: detect, refusalsLimitPerMinute: "2", metricsToken: token,
})
for range 3 {
@@ -608,7 +624,7 @@ func TestObserveModeLogsWhatTheCoreRuleSetWouldDo(t *testing.T) {
s, _, server := startWithClock(
t,
"",
map[string]string{wafMode: block, mode: observe},
map[string]string{coreRuleSetMode: block, mode: observe},
)
line := s.request(
@@ -640,15 +656,15 @@ func TestCoreRuleSetMatchRaisesTheCoreRuleSetMatchAlert(t *testing.T) {
action, alertMode string
}{
{
"block", map[string]string{wafMode: block},
"block", map[string]string{coreRuleSetMode: block},
http.StatusForbidden, requestlog.ActionBlockedByCoreRuleSet, "",
},
{
"detect", map[string]string{wafMode: detect},
"detect", map[string]string{coreRuleSetMode: detect},
http.StatusOK, requestlog.ActionForward, detect,
},
{
"block in observe mode", map[string]string{wafMode: block, mode: observe},
"block in observe mode", map[string]string{coreRuleSetMode: block, mode: observe},
http.StatusOK, requestlog.ActionForward, observe,
},
} {
@@ -683,7 +699,7 @@ func TestCoreRuleSetMatchRaisesTheCoreRuleSetMatchAlert(t *testing.T) {
Client: netip.MustParseAddr(client),
Netblock: netip.MustParsePrefix(client + "/32"),
Reason: "scored by the Core Rule Set at or over " +
"SWW_WAF_CORE_RULE_SET_ANOMALY_THRESHOLD",
"SWW_WAF_CORE_RULE_SET_SCORE_THRESHOLD",
Detail: detail,
})
+17 -16
View File
@@ -53,8 +53,8 @@ func TestCountryLists(t *testing.T) {
})
geojsURL, _ := startGeoJS(t)
env := map[string]string{
trustedProxies: trustLocalhost,
lookupTimeout: "1h",
trustedProxies: trustLocalhost,
asnAndCountryLookupTimeout: "3600000",
}
maps.Copy(env, tc.env)
addr, out := startProxyWithGeoJS(t, app.URL, geojsURL, env)
@@ -109,7 +109,7 @@ func TestCountryRefusalNamesItsHeuristicCountryAndSetting(t *testing.T) {
geojsURL, _ := startGeoJS(t)
s, _, _ := startWithClock(t, geojsURL, map[string]string{
lookupTimeout: "1h", allowedCountries: "de",
asnAndCountryLookupTimeout: "3600000", allowedCountries: "de",
})
line := s.get(
@@ -147,9 +147,9 @@ func TestCountryRefusalKeepsARequestFromTheApp(t *testing.T) {
})
geojsURL, _ := startGeoJS(t)
addr, out := startProxyWithGeoJS(t, app.URL, geojsURL, map[string]string{
trustedProxies: trustLocalhost,
lookupTimeout: "1h",
allowedCountries: "de",
trustedProxies: trustLocalhost,
asnAndCountryLookupTimeout: "3600000",
allowedCountries: "de",
})
req := newRequest(
@@ -198,10 +198,10 @@ func TestRequestRefusedByCountryIsNotCounted(t *testing.T) {
app := startApp(t, func(http.ResponseWriter, *http.Request) {})
addr, _ := startProxyWithGeoJS(t, app.URL, geojs.URL, map[string]string{
trustedProxies: trustLocalhost,
lookupTimeout: "1h",
allowedCountries: "de",
rateLimitPerMinute: "1",
trustedProxies: trustLocalhost,
asnAndCountryLookupTimeout: "3600000",
allowedCountries: "de",
rateLimitPerMinute: "1",
})
request := func() answer {
@@ -248,8 +248,8 @@ func TestPrivateAddressIsNeverLookedUp(t *testing.T) {
app := startApp(t, func(http.ResponseWriter, *http.Request) {})
geojsURL, asked := startGeoJS(t)
env := map[string]string{
trustedProxies: trustLocalhost,
lookupTimeout: "1h",
trustedProxies: trustLocalhost,
asnAndCountryLookupTimeout: "3600000",
}
maps.Copy(env, tc.env)
addr, out := startProxyWithGeoJS(t, app.URL, geojsURL, env)
@@ -352,10 +352,11 @@ func TestExclusiveListRefusesAPrivateAddressUnlessAllowed(t *testing.T) {
// startGeoJS starts a stand-in for GeoJS, which places fromDE and fromKP,
// each in an AS of its own, and no other address. It returns its URL, and
// what returns the addresses it has been asked about. A test that needs
// the stand-in to be asked or to answer sets SWW_WAF_LOOKUP_TIMEOUT to an
// hour, whether or not a request waits for the answer: on the default, a
// hold-up of the test process can abandon the request to the stand-in, and
// leave the client unknown.
// the stand-in to be asked or to answer sets
// SWW_WAF_ASN_AND_COUNTRY_LOOKUP_TIMEOUT_MS to an hour, whether or not a
// request waits for the answer: on the default, a hold-up of the test
// process can abandon the request to the stand-in, and leave the client
// unknown.
func startGeoJS(t *testing.T) (string, func() []string) {
t.Helper()
+3 -3
View File
@@ -1,6 +1,6 @@
package waf
// The heuristic dnsbl. It reads SWW_WAF_REPUTATION_ACTION, and the DNSBL zones'
// The heuristic dnsbl. It reads SWW_WAF_DNSBL_ABUSEIPDB_ACTION, and the DNSBL zones'
// verdicts through the DNSBL checker.
import (
@@ -14,7 +14,7 @@ import (
// dnsbl notes the DNSBL zones whose verdict lists the client, as noteListed
// does, adds the tag dnsbl=<zone> for each, the zone's key masked, and refuses
// the request when SWW_WAF_REPUTATION_ACTION is deny. Being
// the request when SWW_WAF_DNSBL_ABUSEIPDB_ACTION is deny. Being
// limit, it lowers the client's limits instead (see limitPercentages), and
// being log, it does nothing more. A zone without a verdict on the client is
// asked about it in the background, and the request does not wait for the
@@ -32,7 +32,7 @@ func (dnsbl) check(ctx context.Context, rq *request, _ time.Time) *verdict {
rq.addTag(config.DNSBLTagPrefix + strings.ToLower(zone))
}
if !rq.dnsblListed || rq.h.config.ReputationAction != deny {
if !rq.dnsblListed || rq.h.config.DNSBLAbuseIPDBAction != deny {
return nil
}
+1 -1
View File
@@ -7,7 +7,7 @@ import (
// To add a heuristic, write one file with a type that implements heuristic,
// and add one line for it to one of the lists below. Its settings go in
// config.Config, and the comment at the top of its file names them.
// config.WAF, and the comment at the top of its file names them.
// heuristic is one of the checks that decide what waf does with a
// request.
+3 -3
View File
@@ -18,9 +18,9 @@ func TestHistoryKeepsEachRequestOfTheClient(t *testing.T) {
geojsURL, _ := startGeoJS(t)
s, clk, server := startWithClock(t, geojsURL, map[string]string{
lookupTimeout: "1h",
rateLimitPerMinute: "2",
tagDeny: denyKP,
asnAndCountryLookupTimeout: "3600000",
rateLimitPerMinute: "2",
tagDeny: denyKP,
})
start := clk.Now()
+14 -10
View File
@@ -5,6 +5,7 @@ import (
"net/netip"
"path/filepath"
"slices"
"strconv"
"sync"
"testing"
"testing/synctest"
@@ -34,8 +35,8 @@ func TestEveryClientIsLookedUpWithoutWaitingWhileNoSettingNeedsIt(
// waitLimit.
geojsURL, asked, release := startHeldGeoJS(t)
s, _, server := startWithClock(t, geojsURL, map[string]string{
lookupTimeout: "1h",
rateLimitPerMinute: "1",
asnAndCountryLookupTimeout: "3600000",
rateLimitPerMinute: "1",
})
// fromDE's second request breaks the limit and bans it, and fromKP
@@ -104,9 +105,9 @@ func TestRequestThatWaitedForItsLookupIsCountedWhenItGoesOn(t *testing.T) {
clk := &clock{now: time.Date(2026, 10, 6, 0, 0, 58, 0, time.UTC)}
addr, out, server := startProxyWithClock(t, app.URL, geojsURL, clk.Now,
map[string]string{
trustedProxies: trustLocalhost,
lookupTimeout: "1h",
tagDeny: denyKP,
trustedProxies: trustLocalhost,
asnAndCountryLookupTimeout: "3600000",
tagDeny: denyKP,
})
requestFromDE := func() *http.Request {
@@ -364,9 +365,9 @@ func TestLookupHeadersArePassedToTheAppAndTheClientsOwnRemoved(
geojsURL,
time.Now,
map[string]string{
trustedProxies: trustLocalhost,
lookupTimeout: "1h",
addLookupHeaders: on,
trustedProxies: trustLocalhost,
asnAndCountryLookupTimeout: "3600000",
addLookupHeaders: on,
},
)
s := &sender{t: t, addr: addr, out: out}
@@ -437,7 +438,9 @@ func TestClientsOwnLookupHeadersAreRemovedWhileTheSettingIsOff(
}
}
func TestRequestWaitsAsLongAsTheLookupTimeoutSays(t *testing.T) {
func TestRequestWaitsAsLongAsTheASNAndCountryLookupTimeoutSays(
t *testing.T,
) {
t.Parallel()
// The test runs in a synctest bubble, where the time package runs on a
@@ -452,7 +455,8 @@ func TestRequestWaitsAsLongAsTheLookupTimeoutSays(t *testing.T) {
server, out, _ := newWAF(t, silentGeoJSURL, time.Now,
map[string]string{
lookupTimeout: timeout.String(),
asnAndCountryLookupTimeout: strconv.FormatInt(
timeout.Milliseconds(), 10),
allowedCountries: "DE",
})
+18 -16
View File
@@ -18,7 +18,8 @@ import (
const (
metricsToken = "SWW_WAF_METRICS_TOKEN" //nolint:gosec // the setting's name
metricsTopN = "SWW_WAF_METRICS_TOP_N"
metricsBusiestCountriesAndASNs = "SWW_WAF_METRICS_BUSIEST_COUNTRIES_AND_ASNS"
// token is the SWW_WAF_METRICS_TOKEN the tests set, and bearer how a
// request carries it.
token = "0123456789abcdef0123456789abcdef"
@@ -133,12 +134,12 @@ func TestMetricsCountLimitsAndBans(t *testing.T) {
const denied = "192.0.2.50" // in SWW_WAF_DENY_NETS
s, clk, _ := startWithClock(t, "", map[string]string{
metricsToken: token,
rateLimitPerMinute: "1",
denyNets: denied,
banResponse: banResponseClose,
limitBanDuration: "1h",
maxBanDuration: "2h",
metricsToken: token,
rateLimitPerMinute: "1",
denyNets: denied,
banResponse: banResponseClose,
limitBanDuration: "1h",
limitBanPermanentAbove: "2h",
})
// SWW_WAF_BAN_RESPONSE=close sends no status at all.
@@ -176,7 +177,7 @@ func TestMetricsCountLimitsAndBans(t *testing.T) {
wantMetric(t, s.scrape(), `sww_active_bans{instance="app"}`, 0)
// A limit broken again right after would ban for three hours, longer
// than SWW_WAF_MAX_BAN_DURATION, so the ban is permanent.
// than SWW_WAF_LIMIT_BAN_PERMANENT_ABOVE, so the ban is permanent.
s.get(client, http.StatusOK, requestlog.ActionForward)
s.get(client, 0, requestlog.ActionClientIsRateLimited)
@@ -236,10 +237,10 @@ func TestMetricsByCountryKeepTheBusiestAndCountTheRestAsOther(t *testing.T) {
_, _ = io.WriteString(w, "hello")
})
env := map[string]string{
trustedProxies: trustLocalhost,
metricsToken: token,
metricsTopN: "2",
allowedCountries: "de,fr",
trustedProxies: trustLocalhost,
metricsToken: token,
metricsBusiestCountriesAndASNs: "2",
allowedCountries: "de,fr",
}
geojsURL, _ := startGeoJS(t)
addr, out, server := startProxyWithClock(t, app.URL, geojsURL,
@@ -322,8 +323,8 @@ func TestMetricsByASNumberKeepTheBusiestAndCountTheRestAsOther(
geojsURL, _ := startGeoJS(t)
s, clk, server := startWithClock(t, geojsURL, map[string]string{
metricsToken: token,
metricsTopN: "1",
metricsToken: token,
metricsBusiestCountriesAndASNs: "1",
})
// The answers are kept before the requests, so that GeoJS gives none
@@ -379,8 +380,9 @@ func TestMetricsCountGeoJSRequestsAndFailures(t *testing.T) {
req.Header.Set(forwardedFor, fromDE)
wantStatus(t, do(t, req), http.StatusOK)
// The client stops waiting for GeoJS once SWW_WAF_LOOKUP_TIMEOUT is up, so
// GeoJS's failure can come after its request has ended.
// The client stops waiting for GeoJS once
// SWW_WAF_ASN_AND_COUNTRY_LOOKUP_TIMEOUT_MS is up, so GeoJS's failure can
// come after its request has ended.
deadline := time.Now().Add(waitLimit)
metrics := scrape(t, server.Addr)
+4 -4
View File
@@ -149,10 +149,10 @@ func TestObserveModeForwardsWhatEnforceModeRefuses(t *testing.T) {
geojsURL, _ := startGeoJS(t)
env := map[string]string{
lookupTimeout: "1h",
rateLimitPerMinute: "1",
denyNets: denied,
tagDeny: denyKP,
asnAndCountryLookupTimeout: "3600000",
rateLimitPerMinute: "1",
denyNets: denied,
tagDeny: denyKP,
}
if tc.setting != "" {
+30 -22
View File
@@ -47,7 +47,7 @@ const ClientsPath = "/_sww/clients/"
// Params are what New needs.
type Params struct {
Config *config.Config
Config *config.WAF
// RequestLog receives two JSON lines per question, one at its decision and
// one when its report comes, and one per request to waf's own
// endpoints.
@@ -77,7 +77,7 @@ type Params struct {
// permanent, for each count over an anomaly threshold, for each request
// whose client a blocklist, the CrowdSec decision list, a DNSBL zone or
// AbuseIPDB lists, for each request the Core Rule Set scores at or over
// SWW_WAF_CORE_RULE_SET_ANOMALY_THRESHOLD, and for GeoJS failing, a fetch of a list
// SWW_WAF_CORE_RULE_SET_SCORE_THRESHOLD, and for GeoJS failing, a fetch of a list
// failing, a query to a DNSBL zone or a check with AbuseIPDB failing,
// or the day's AbuseIPDB checks used up.
Alerts *alerts.Queue
@@ -106,7 +106,10 @@ type Server struct {
// newHandler returns the handler with what every request shares, as the
// settings in params make it.
func newHandler(params Params) *handler {
m := metrics.New(params.Config.MetricsTopN, params.Config.InstanceName)
m := metrics.New(
params.Config.MetricsBusiestCountriesAndASNs,
params.Config.InstanceName,
)
lists, dnsbl, abuseIPDB := newReputation(params, m)
awaitingReport, err := lru.New[string, *decision](maxAwaitingReport)
@@ -130,21 +133,21 @@ func newHandler(params Params) *handler {
BytesPerDay: params.Config.BytesLimitPerDay,
}, params.Config.MaxTrackedClients),
ledger: bans.New(bans.Params{
LimitBanDuration: params.Config.LimitBanDuration,
LimitBanRepeatWindow: params.Config.LimitBanRepeatWindow,
MaxBanDuration: params.Config.MaxBanDuration,
AttackBanDuration: params.Config.AttackBanDuration,
MaxBans: params.Config.MaxBans,
LimitBanDuration: params.Config.LimitBanDuration,
LimitBanRepeatWindow: params.Config.LimitBanRepeatWindow,
LimitBanPermanentAbove: params.Config.LimitBanPermanentAbove,
AttackBanDuration: params.Config.AttackBanDuration,
MaxBans: params.Config.MaxBans,
}),
anomalies: anomaly.New(anomaly.Params{
Client: params.Config.AnomalyClient,
Net: params.Config.AnomalyNet,
ASN: params.Config.AnomalyASN,
Total: params.Config.AnomalyTotal,
Watch: params.Config.AnomalyWatch,
WatchedNet: params.Config.AnomalyWatchedNet,
NetV4Prefix: params.Config.AnomalyNetV4Prefix,
NetV6Prefix: params.Config.AnomalyNetV6Prefix,
NamedNetblocks: params.Config.WatchNets,
NamedNetblocks: params.Config.AnomalyWatchedNets,
Alerts: params.Alerts,
}),
lookupFile: params.LookupFile,
@@ -163,7 +166,7 @@ func newHandler(params Params) *handler {
h.geojs = lookup.New(lookup.Params{
URL: params.GeoJSURL,
Timeout: params.Config.LookupTimeout,
Timeout: params.Config.ASNAndCountryLookupTimeout,
// The country list, the headers, the tag settings that name an asn=
// or country= tag and the biased thresholds act on the answer before
// the request goes on.
@@ -198,14 +201,15 @@ func newReputation(
Now: params.Now, ProcessLog: params.ProcessLog, Alerts: params.Alerts,
})
dnsbl := reputation.NewDNSBL(reputation.DNSBLParams{
Zones: cfg.DNSBLZones, Resolver: cfg.DNSBLResolver, CacheTTL: cfg.ReputationCacheTTL,
Timeout: cfg.ReputationTimeout, Now: params.Now, ProcessLog: params.ProcessLog,
Zones: cfg.DNSBLZones, Resolver: cfg.DNSBLResolver,
CacheTTL: cfg.IPReputationCacheTTL,
Timeout: cfg.DNSBLAbuseIPDBTimeout, Now: params.Now, ProcessLog: params.ProcessLog,
Alerts: params.Alerts,
})
abuseIPDB := reputation.NewAbuseIPDB(reputation.AbuseIPDBParams{
URL: params.AbuseIPDBURL, Key: cfg.AbuseIPDBKey, MinScore: cfg.AbuseIPDBMinScore,
DailyBudget: cfg.AbuseIPDBDailyBudget, CacheTTL: cfg.ReputationCacheTTL,
Timeout: cfg.ReputationTimeout, Now: params.Now, ProcessLog: params.ProcessLog,
DailyBudget: cfg.AbuseIPDBDailyBudget, CacheTTL: cfg.IPReputationCacheTTL,
Timeout: cfg.DNSBLAbuseIPDBTimeout, Now: params.Now, ProcessLog: params.ProcessLog,
Alerts: params.Alerts,
})
@@ -220,7 +224,7 @@ func newReputation(
// NewCoreRuleSet returns the Core Rule Set at SWW_WAF_CORE_RULE_SET_PARANOIA_LEVEL,
// without the rules SWW_WAF_CORE_RULE_SET_DISABLED_RULES switches off, reading bodies
// up to SWW_WAF_CORE_RULE_SET_BODY_LIMIT, with the exclusions in
// up to SWW_WAF_CORE_RULE_SET_BODY_INSPECTION_SIZE, with the exclusions in
// SWW_WAF_CORE_RULE_SET_EXCLUSIONS_FILE and the fixes of the apps in SWW_WAF_APPS,
// or nil while SWW_WAF_CORE_RULE_SET_MODE is off. It
// reads at most QuestionBodyMaxBytes less one byte: a question's body of
@@ -228,15 +232,19 @@ func newReputation(
// JSON or XML body over its limit. An exclusions file that coreruleset.New refuses is
// an error in every mode. While the mode is off, a set file is loaded with the
// Core Rule Set only to check it.
func NewCoreRuleSet(cfg *config.Config) (*coreruleset.CoreRuleSet, error) {
if cfg.WAFMode == config.WAFModeOff &&
func NewCoreRuleSet(cfg *config.WAF) (*coreruleset.CoreRuleSet, error) {
if cfg.CoreRuleSetMode == config.CoreRuleSetModeOff &&
cfg.CoreRuleSetExclusionsFile == "" {
return nil, nil //nolint:nilnil // off is no Core Rule Set, and no error
}
coreRuleSet, err := coreruleset.New(coreruleset.Params{
ParanoiaLevel: cfg.WAFParanoiaLevel, DisabledRules: cfg.WAFDisabledRules,
BodyLimit: min(cfg.WAFBodyLimit, QuestionBodyMaxBytes-1),
ParanoiaLevel: cfg.CoreRuleSetParanoiaLevel,
DisabledRules: cfg.CoreRuleSetDisabledRules,
BodyInspectionSize: min(
cfg.CoreRuleSetBodyInspectionSize,
QuestionBodyMaxBytes-1,
),
ExclusionsFile: cfg.CoreRuleSetExclusionsFile,
Apps: cfg.Apps,
})
@@ -247,7 +255,7 @@ func NewCoreRuleSet(cfg *config.Config) (*coreruleset.CoreRuleSet, error) {
)
}
if cfg.WAFMode == config.WAFModeOff {
if cfg.CoreRuleSetMode == config.CoreRuleSetModeOff {
return nil, nil //nolint:nilnil // off is no Core Rule Set, and no error
}
@@ -257,7 +265,7 @@ func NewCoreRuleSet(cfg *config.Config) (*coreruleset.CoreRuleSet, error) {
// handler is waf's handler. It holds what every request shares; what
// belongs to one request is in a request.
type handler struct {
config *config.Config
config *config.WAF
requestLog io.Writer
processLog *slog.Logger
now func() time.Time
+39 -37
View File
@@ -56,35 +56,35 @@ const (
// The settings the tests set.
const (
mode = "SWW_WAF_MODE"
trustedProxies = "SWW_RPROXY_TRUSTED_PROXIES"
ipv6GroupPrefix = "SWW_WAF_IPV6_GROUP_PREFIX"
maxTrackedClients = "SWW_WAF_MAX_TRACKED_CLIENTS"
allowNets = "SWW_WAF_ALLOW_NETS"
rateLimitExemptNets = "SWW_WAF_RATE_LIMIT_EXEMPT_NETS"
denyNets = "SWW_WAF_DENY_NETS"
rateLimitPerMinute = "SWW_WAF_RATE_LIMIT_PER_MINUTE"
rateLimitPerDay = "SWW_WAF_RATE_LIMIT_PER_DAY"
rateLimitExemptPaths = "SWW_WAF_RATE_LIMIT_EXEMPT_PATHS"
lookupSource = "SWW_WAF_LOOKUP_SOURCE"
lookupDBPath = "SWW_WAF_LOOKUP_DB_PATH"
lookupTimeout = "SWW_WAF_LOOKUP_TIMEOUT"
addLookupHeaders = "SWW_WAF_ADD_LOOKUP_HEADERS"
tagDeny = "SWW_WAF_TAG_DENY"
mode = "SWW_WAF_MODE"
trustedProxies = "SWW_RPROXY_TRUSTED_PROXIES"
ipv6GroupPrefix = "SWW_WAF_IPV6_GROUP_PREFIX"
maxTrackedClients = "SWW_WAF_MAX_TRACKED_CLIENTS"
allowNets = "SWW_WAF_ALLOW_NETS"
rateLimitExemptNets = "SWW_WAF_RATE_LIMIT_EXEMPT_NETS"
denyNets = "SWW_WAF_DENY_NETS"
rateLimitPerMinute = "SWW_WAF_RATE_LIMIT_PER_MINUTE"
rateLimitPerDay = "SWW_WAF_RATE_LIMIT_PER_DAY"
rateLimitExemptPaths = "SWW_WAF_RATE_LIMIT_EXEMPT_PATHS"
lookupSource = "SWW_WAF_LOOKUP_SOURCE"
lookupDBPath = "SWW_WAF_LOOKUP_DB_PATH"
asnAndCountryLookupTimeout = "SWW_WAF_ASN_AND_COUNTRY_LOOKUP_TIMEOUT_MS"
addLookupHeaders = "SWW_WAF_ADD_LOOKUP_HEADERS"
tagDeny = "SWW_WAF_TAG_DENY"
// denyKP is the value of SWW_WAF_TAG_DENY that refuses fromKP's country.
denyKP = "country=KP"
allowedCountries = "SWW_WAF_EXCLUSIVELY_ALLOWED_COUNTRIES"
banResponse = "SWW_WAF_BAN_RESPONSE"
limitBanDuration = "SWW_WAF_LIMIT_BAN_DURATION"
limitBanRepeatWindow = "SWW_WAF_LIMIT_BAN_REPEAT_WINDOW"
maxBanDuration = "SWW_WAF_MAX_BAN_DURATION"
maxBans = "SWW_WAF_MAX_BANS"
banScopeV4Prefix = "SWW_WAF_BAN_SCOPE_V4_PREFIX"
instanceName = "SWW_WAF_INSTANCE_NAME"
logRequestHeaders = "SWW_WAF_LOG_REQUEST_HEADERS"
attackBanDuration = "SWW_WAF_ATTACK_BAN_DURATION"
rulesDir = "SWW_WAF_RULES_DIR"
wafMode = "SWW_WAF_CORE_RULE_SET_MODE"
denyKP = "country=KP"
allowedCountries = "SWW_WAF_EXCLUSIVELY_ALLOWED_COUNTRIES"
banResponse = "SWW_WAF_BAN_RESPONSE"
limitBanDuration = "SWW_WAF_LIMIT_BAN_DURATION"
limitBanRepeatWindow = "SWW_WAF_LIMIT_BAN_REPEAT_WINDOW"
limitBanPermanentAbove = "SWW_WAF_LIMIT_BAN_PERMANENT_ABOVE"
maxBans = "SWW_WAF_MAX_BANS"
banScopeV4Prefix = "SWW_WAF_BAN_SCOPE_V4_PREFIX"
instanceName = "SWW_WAF_INSTANCE_NAME"
logRequestHeaders = "SWW_WAF_LOG_REQUEST_HEADERS"
attackBanDuration = "SWW_WAF_ATTACK_BAN_DURATION"
ruleFilesDir = "SWW_WAF_RULE_FILES_DIR"
coreRuleSetMode = "SWW_WAF_CORE_RULE_SET_MODE"
)
// off is the value that switches a setting off.
@@ -301,7 +301,7 @@ func startProxyWithGeoJS(
// startProxyWithClock is startProxyWithGeoJS with requests counted and
// bans made by the time now tells, and returns waf's server as well, whose
// Addr is where waf listens. Unless env sets SWW_WAF_RULES_DIR, it is an empty
// Addr is where waf listens. Unless env sets SWW_WAF_RULE_FILES_DIR, it is an empty
// directory, of no rules, and unless it sets SWW_WAF_INSTANCE_NAME, that is app,
// the label instance of every metric.
func startProxyWithClock(
@@ -362,17 +362,19 @@ func serve(t *testing.T, server *waf.Server) {
// startRProxy starts rproxy in front of the app at appURL, asking the waf
// at wafAddr, with the settings in env on top of the defaults, and returns
// where it listens. What it writes is dropped. SWW_RPROXY_TIMEOUT_MS is a minute unless
// env sets it, so that on a busy host SWW_RPROXY_DEFAULT_DISPOSITION does not decide.
// where it listens. What it writes is dropped.
// SWW_RPROXY_WAF_BACKEND_TIMEOUT_MS is a minute unless env sets it, so that
// on a busy host SWW_RPROXY_WAF_BACKEND_NO_ANSWER_DISPOSITION does not
// decide.
func startRProxy(
t *testing.T, wafAddr, appURL string, env map[string]string,
) string {
t.Helper()
settings := map[string]string{
"SWW_RPROXY_WAF_BACKEND": "http://" + wafAddr,
"SWW_RPROXY_UPSTREAM_URL": appURL,
"SWW_RPROXY_TIMEOUT_MS": "60000",
"SWW_RPROXY_WAF_BACKEND": "http://" + wafAddr,
"SWW_RPROXY_UPSTREAM_URL": appURL,
"SWW_RPROXY_WAF_BACKEND_TIMEOUT_MS": "60000",
}
maps.Copy(settings, env)
@@ -440,7 +442,7 @@ func newWAFWith(
t.Helper()
settings := map[string]string{
rulesDir: t.TempDir(), instanceName: "app", wafMode: off,
ruleFilesDir: t.TempDir(), instanceName: "app", coreRuleSetMode: off,
}
if geojsURL == "" {
settings[lookupSource] = off
@@ -448,7 +450,7 @@ func newWAFWith(
maps.Copy(settings, env)
cfg, err := config.FromEnvironment(configtest.Viper(settings))
cfg, err := config.WAFFromEnvironment(configtest.Viper(settings))
if err != nil {
t.Fatalf("settings %v: %v", settings, err)
}
@@ -461,7 +463,7 @@ func newWAFWith(
)
ruleFiles, err := rules.Load(rules.Params{
Dir: cfg.RulesDir, Enabled: cfg.RulesEnabled, ProcessLog: processLog,
Dir: cfg.RuleFilesDir, Enabled: cfg.RuleFilesEnabled, ProcessLog: processLog,
})
if err != nil {
t.Fatalf("rule files: %v", err)
+5 -5
View File
@@ -1,6 +1,6 @@
package waf
// The heuristic refusal_limit. It reads SWW_WAF_ERROR_BURST_THRESHOLD and
// The heuristic refusal_limit. It reads SWW_WAF_REFUSALS_LIMIT_PER_MINUTE and
// SWW_WAF_BAN_RESPONSE.
import (
@@ -14,9 +14,9 @@ import (
// refusalLimit counts the request once it has been answered, if waf
// refused it after a rule file match, a trap path or a Core Rule Set match, and
// in observe mode if enforce mode would have: more than
// SWW_WAF_ERROR_BURST_THRESHOLD such refusals of the client within a minute break
// SWW_WAF_REFUSALS_LIMIT_PER_MINUTE such refusals of the client within a minute break
// a limit, which bans the client. A client in SWW_WAF_ALLOW_NETS, which the
// heuristics skip, is not counted, and nothing is while the threshold is off.
// heuristics skip, is not counted, and nothing is while the limit is off.
// Its figures are the window, the count and the limit.
type refusalLimit struct{}
@@ -28,7 +28,7 @@ func (refusalLimit) check(
now time.Time,
) *verdict {
cfg := rq.h.config
if cfg.ErrorBurstThreshold == 0 {
if cfg.RefusalsLimitPerMinute == 0 {
return nil
}
@@ -37,7 +37,7 @@ func (refusalLimit) check(
}
hit, over := rq.h.limiter.CountRefusal(rq.h.clientGroup(rq.client), now,
cfg.ErrorBurstThreshold)
cfg.RefusalsLimitPerMinute)
if !over {
return nil
}
+42 -42
View File
@@ -15,7 +15,7 @@ import (
"sneak.berlin/go/smallwebwaf/internal/waf"
)
const errorBurstThreshold = "SWW_WAF_ERROR_BURST_THRESHOLD"
const refusalsLimitPerMinute = "SWW_WAF_REFUSALS_LIMIT_PER_MINUTE"
// refused is a request the tests here send, which waf refuses after a rule
// file match.
@@ -48,29 +48,29 @@ func (r refused) send(s *sender, from string) logLine {
return logLine{}
}
// startForErrorBurst is startWithClock with testRules, the metrics token and
// SWW_WAF_ERROR_BURST_THRESHOLD at threshold, and the settings in env.
func startForErrorBurst(
t *testing.T, threshold string, env map[string]string,
// startForRefusalLimit is startWithClock with testRules, the metrics token and
// SWW_WAF_REFUSALS_LIMIT_PER_MINUTE at limit, and the settings in env.
func startForRefusalLimit(
t *testing.T, limit string, env map[string]string,
) (*sender, *clock, *waf.Server) {
t.Helper()
settings := map[string]string{
errorBurstThreshold: threshold,
rulesDir: writeRules(t, testRules),
metricsToken: token,
refusalsLimitPerMinute: limit,
ruleFilesDir: writeRules(t, testRules),
metricsToken: token,
}
maps.Copy(settings, env)
return startWithClock(t, "", settings)
}
func TestErrorBurstBreaksAtOneOverTheThreshold(t *testing.T) {
func TestRefusalLimitBreaksAtOneOverTheLimit(t *testing.T) {
t.Parallel()
for _, tc := range []struct {
name string
// refusals are four, one over the threshold of three.
// refusals are four, one over the limit of three.
refusals []refused
// nextAction is the action of the client's next request: the next
// request after a ban for a ban rule makes it permanent.
@@ -90,7 +90,7 @@ func TestErrorBurstBreaksAtOneOverTheThreshold(t *testing.T) {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
s, _, _ := startForErrorBurst(t, "3", nil)
s, _, _ := startForRefusalLimit(t, "3", nil)
// Three refusals break nothing, and the app's answers between
// them are not counted.
@@ -109,8 +109,8 @@ func TestErrorBurstBreaksAtOneOverTheThreshold(t *testing.T) {
s.get(client, http.StatusOK, requestlog.ActionForward)
}
// The fourth is answered as the others were, breaks the error
// burst, and bans the client.
// The fourth is answered as the others were, breaks the refusal
// limit, and bans the client.
line := tc.refusals[3].send(s, client)
if line.LimitHit != requestlog.LimitHitMinuteRefusals ||
line.Offence != requestlog.OffenceLimit {
@@ -130,7 +130,7 @@ func TestErrorBurstBreaksAtOneOverTheThreshold(t *testing.T) {
func TestRefusalLimitBanNamesItsHeuristicWindowCountAndLimit(t *testing.T) {
t.Parallel()
s, _, _ := startForErrorBurst(t, "1", nil)
s, _, _ := startForRefusalLimit(t, "1", nil)
const ruleFigures = `{"rule_id": "blocked", "rule_action": "block"}`
@@ -146,10 +146,10 @@ func TestRefusalLimitBanNamesItsHeuristicWindowCountAndLimit(t *testing.T) {
`{"window": "minute", "count": 2, "limit": 1}`)
}
func TestErrorBurstBanNotesHistoryAndMetrics(t *testing.T) {
func TestRefusalLimitBanNotesHistoryAndMetrics(t *testing.T) {
t.Parallel()
s, clk, server := startForErrorBurst(t, "2", nil)
s, clk, server := startForRefusalLimit(t, "2", nil)
start := clk.Now()
blockRule.send(s, client)
@@ -221,18 +221,18 @@ func TestErrorBurstBanNotesHistoryAndMetrics(t *testing.T) {
)
}
func TestErrorBurstIsNotLoweredForAClientWithLowerLimits(t *testing.T) {
func TestRefusalLimitIsNotLoweredForAClientWithLowerLimits(t *testing.T) {
t.Parallel()
geojsURL, _ := startGeoJS(t)
s, _, server := startWithClock(t, geojsURL, map[string]string{
lookupTimeout: "1h",
errorBurstThreshold: "2",
rulesDir: writeRules(t, testRules),
tagLimitPercent: countryTagDEHalf,
asnAndCountryLookupTimeout: "3600000",
refusalsLimitPerMinute: "2",
ruleFilesDir: writeRules(t, testRules),
tagLimitPercent: countryTagDEHalf,
})
// Half of the threshold would be one, which the second refusal is over.
// Half of the limit would be one, which the second refusal is over.
for range 2 {
line := blockRule.send(s, fromDE)
if line.LimitHit != "" {
@@ -252,7 +252,7 @@ func TestErrorBurstIsNotLoweredForAClientWithLowerLimits(t *testing.T) {
}
}
func TestErrorBurstDoesNotCountTheAppsAnswers(t *testing.T) {
func TestRefusalLimitDoesNotCountTheAppsAnswers(t *testing.T) {
t.Parallel()
statuses := map[string]int{
@@ -266,8 +266,8 @@ func TestErrorBurstDoesNotCountTheAppsAnswers(t *testing.T) {
w.WriteHeader(statuses[r.URL.Path])
},
map[string]string{
errorBurstThreshold: "1",
rulesDir: writeRules(t, testRules),
refusalsLimitPerMinute: "1",
ruleFilesDir: writeRules(t, testRules),
},
)
@@ -277,7 +277,7 @@ func TestErrorBurstDoesNotCountTheAppsAnswers(t *testing.T) {
}
}
// The first refusal is one, not over the threshold.
// The first refusal is one, not over the limit.
line := blockRule.send(s, client)
if line.LimitHit != "" {
t.Errorf("log line has limit_hit %q, want none", line.LimitHit)
@@ -293,23 +293,23 @@ func TestErrorBurstDoesNotCountTheAppsAnswers(t *testing.T) {
wantAlerts(t, queue)
}
func TestErrorBurstOffOrAtItsDefault(t *testing.T) {
func TestRefusalLimitOffOrAtItsDefault(t *testing.T) {
t.Parallel()
for _, tc := range []struct {
threshold string
// broken is whether the 31st refusal breaks the error burst.
limit string
// broken is whether the 31st refusal breaks the refusal limit.
broken bool
}{
{"", true},
{off, false},
} {
t.Run(errorBurstThreshold+"="+tc.threshold, func(t *testing.T) {
t.Run(refusalsLimitPerMinute+"="+tc.limit, func(t *testing.T) {
t.Parallel()
env := map[string]string{rulesDir: writeRules(t, testRules)}
if tc.threshold != "" {
env[errorBurstThreshold] = tc.threshold
env := map[string]string{ruleFilesDir: writeRules(t, testRules)}
if tc.limit != "" {
env[refusalsLimitPerMinute] = tc.limit
}
s, _, _ := startWithClock(t, "", env)
@@ -322,7 +322,7 @@ func TestErrorBurstOffOrAtItsDefault(t *testing.T) {
broken := line.LimitHit == requestlog.LimitHitMinuteRefusals
if broken != tc.broken {
t.Errorf(
"the 31st refusal broke the error burst: %t, want %t",
"the 31st refusal broke the refusal limit: %t, want %t",
broken,
tc.broken,
)
@@ -331,7 +331,7 @@ func TestErrorBurstOffOrAtItsDefault(t *testing.T) {
}
}
func TestErrorBurstCountsEachClientTheChecksApplyTo(t *testing.T) {
func TestRefusalLimitCountsEachClientTheChecksApplyTo(t *testing.T) {
t.Parallel()
const (
@@ -339,7 +339,7 @@ func TestErrorBurstCountsEachClientTheChecksApplyTo(t *testing.T) {
exempt = "192.0.2.50" // in SWW_WAF_RATE_LIMIT_EXEMPT_NETS
)
s, _, _ := startForErrorBurst(t, "1", map[string]string{
s, _, _ := startForRefusalLimit(t, "1", map[string]string{
allowNets: allowed, rateLimitExemptNets: exempt,
})
@@ -367,10 +367,10 @@ func TestErrorBurstCountsEachClientTheChecksApplyTo(t *testing.T) {
s.get(exempt, http.StatusForbidden, requestlog.ActionTemporarilyBanned)
}
func TestErrorBurstBanSetsTheRefusalsBackToZero(t *testing.T) {
func TestRefusalLimitBanSetsTheRefusalsBackToZero(t *testing.T) {
t.Parallel()
s, clk, _ := startForErrorBurst(
s, clk, _ := startForRefusalLimit(
t,
"1",
map[string]string{limitBanDuration: "1s"},
@@ -389,13 +389,13 @@ func TestErrorBurstBanSetsTheRefusalsBackToZero(t *testing.T) {
}
}
func TestObserveModeLogsAndAlertsTheErrorBurst(t *testing.T) {
func TestObserveModeLogsAndAlertsABrokenRefusalLimit(t *testing.T) {
t.Parallel()
s, clk, server, queue := startWithAlerts(t, map[string]string{
mode: observe,
errorBurstThreshold: "1",
rulesDir: writeRules(t, testRules),
mode: observe,
refusalsLimitPerMinute: "1",
ruleFilesDir: writeRules(t, testRules),
})
start := clk.Now()
held := bans.Ban{
+1 -1
View File
@@ -8,7 +8,7 @@ import (
"sneak.berlin/go/smallwebwaf/internal/bans"
)
// deny is the SWW_WAF_BLOCKLIST_ACTION and the SWW_WAF_REPUTATION_ACTION that
// deny is the SWW_WAF_BLOCKLIST_ACTION and the SWW_WAF_DNSBL_ABUSEIPDB_ACTION that
// refuses the requests of a client a source lists.
const deny = "deny"
+36 -33
View File
@@ -28,7 +28,7 @@ const (
asnLimitPercentURL = "SWW_WAF_ASN_LIMIT_PERCENT_URL"
)
// The actions of SWW_WAF_BLOCKLIST_ACTION and SWW_WAF_REPUTATION_ACTION:
// The actions of SWW_WAF_BLOCKLIST_ACTION and SWW_WAF_DNSBL_ABUSEIPDB_ACTION:
// limitHalf gives a listed client half of every limit, and limitQuarter a
// quarter.
const (
@@ -155,7 +155,7 @@ func TestReputationRefusalNamesItsHeuristicAndWhatListedTheClient(
"dnsbl",
map[string]string{
dnsblZones: dnsblZone, dnsblResolver: noResolver,
reputationAction: actionDeny,
dnsblAbuseIPDBAction: actionDeny,
},
func(_ *testing.T, server *waf.Server) {
loadVerdicts(server, map[string][]string{client: {dnsblZone}})
@@ -164,7 +164,7 @@ func TestReputationRefusalNamesItsHeuristicAndWhatListedTheClient(
},
{
"abuseipdb",
map[string]string{abuseIPDBKey: accountKey, reputationAction: actionDeny},
map[string]string{abuseIPDBKey: accountKey, dnsblAbuseIPDBAction: actionDeny},
func(_ *testing.T, server *waf.Server) {
loadScores(server, map[string]int64{client: 80})
},
@@ -308,8 +308,8 @@ func TestASNLimitPercentFileCountsAsTheSettingDoesTheLowerWinning(
},
{"none, for an AS number the file does not list", nil, asnKP + ":25", none, none},
{
"SWW_WAF_ASN_BYTES_PERCENT's in place of the file's for the byte limits",
map[string]string{asnBytesPercent: asnDE + ":100"}, asnDEQuarter, fromURL, none,
"SWW_WAF_ASN_BYTES_LIMIT_PERCENT's in place of the file's for the byte limits",
map[string]string{asnBytesLimitPercent: asnDE + ":100"}, asnDEQuarter, fromURL, none,
},
} {
t.Run(tc.name, func(t *testing.T) {
@@ -417,9 +417,9 @@ func TestEachBlocklistThatListsAClientRaisesAnAlertOncePerCooldownAndIsCounted(
// The DNSBL settings.
const (
dnsblZones = "SWW_WAF_DNSBL_ZONES"
dnsblResolver = "SWW_WAF_DNSBL_RESOLVER"
reputationAction = "SWW_WAF_REPUTATION_ACTION"
dnsblZones = "SWW_WAF_DNSBL_ZONES"
dnsblResolver = "SWW_WAF_DNSBL_RESOLVER"
dnsblAbuseIPDBAction = "SWW_WAF_DNSBL_ABUSEIPDB_ACTION"
)
// The DNSBL zones these tests name, which are never asked about the
@@ -432,7 +432,7 @@ const (
noResolver = "127.0.0.1:9"
)
func TestEachReputationActionForAClientADNSBLZoneLists(t *testing.T) {
func TestEachDNSBLAbuseIPDBActionForAClientADNSBLZoneLists(t *testing.T) {
t.Parallel()
forward, denied := requestlog.ActionForward, requestlog.ActionClientDenied
@@ -453,7 +453,7 @@ func TestEachReputationActionForAClientADNSBLZoneLists(t *testing.T) {
// Half of 4 requests a minute: the third breaks the limit.
limitHalf, []int{http.StatusOK, http.StatusOK, http.StatusForbidden},
[]string{forward, forward, requestlog.ActionClientIsRateLimited},
"50 from " + reputationAction,
"50 from " + dnsblAbuseIPDBAction,
},
{
actionLog, []int{http.StatusOK, http.StatusOK, http.StatusOK},
@@ -465,7 +465,7 @@ func TestEachReputationActionForAClientADNSBLZoneLists(t *testing.T) {
s, server, _ := startWithLookups(t, map[string]string{
rateLimitPerMinute: fourAMinute, dnsblZones: dnsblZone + "," + otherZone,
dnsblResolver: noResolver, reputationAction: tc.action,
dnsblResolver: noResolver, dnsblAbuseIPDBAction: tc.action,
})
listedBy := map[string][]string{
fromDE: {dnsblZone, otherZone}, fromKP: {otherZone},
@@ -523,7 +523,7 @@ func TestDNSBLZonesComeAfterTheBlocklistsAndSkipAllowNets(t *testing.T) {
s, server, queue := startWithLookups(t, map[string]string{
blocklistURLs: dropURL, dnsblZones: dnsblZone, dnsblResolver: noResolver,
reputationAction: actionDeny, allowNets: fromDE,
dnsblAbuseIPDBAction: actionDeny, allowNets: fromDE,
})
loadLists(t, server, map[string][]string{dropURL: {fromKP}})
loadVerdicts(
@@ -566,7 +566,7 @@ func TestReputationSourcesTagTheRequestWhateverTheirAction(t *testing.T) {
{
"dnsbl",
map[string]string{
dnsblZones: dnsblZone, dnsblResolver: noResolver, reputationAction: actionLog,
dnsblZones: dnsblZone, dnsblResolver: noResolver, dnsblAbuseIPDBAction: actionLog,
},
func(server *waf.Server) {
loadVerdicts(server, map[string][]string{fromDE: {dnsblZone}})
@@ -575,7 +575,7 @@ func TestReputationSourcesTagTheRequestWhateverTheirAction(t *testing.T) {
},
{
abuseipdb,
map[string]string{abuseIPDBKey: accountKey, reputationAction: actionLog},
map[string]string{abuseIPDBKey: accountKey, dnsblAbuseIPDBAction: actionLog},
func(server *waf.Server) {
loadScores(server, map[string]int64{fromDE: 80})
},
@@ -602,7 +602,7 @@ func TestObserveModeForwardsAClientADNSBLZoneDeniesAndAlertsIt(
t.Parallel()
s, server, queue := startWithLookups(t, map[string]string{
dnsblZones: dnsblZone, dnsblResolver: noResolver, reputationAction: actionDeny,
dnsblZones: dnsblZone, dnsblResolver: noResolver, dnsblAbuseIPDBAction: actionDeny,
mode: observe,
})
loadVerdicts(server, map[string][]string{fromDE: {dnsblZone}})
@@ -623,27 +623,27 @@ func TestReputationLimitTakesPartInTheLowestPercentageOfEveryLimit(
t.Parallel()
for _, tc := range []struct {
blocklistAction, reputationAction string
blocklistAction, dnsblAbuseIPDBAction string
// want is the request's rate_limit_percent and bytes_limit_percent, as
// percentText gives them.
want string
}{
{limitHalf, limitQuarter, "25 from " + reputationAction},
{limitHalf, limitQuarter, "25 from " + dnsblAbuseIPDBAction},
{limitQuarter, limitHalf, "25 from " + blocklistAction},
// The blocklist's, the first of two alike.
{limitQuarter, limitQuarter, "25 from " + blocklistAction},
{actionLog, limitQuarter, "25 from " + reputationAction},
{actionLog, limitQuarter, "25 from " + dnsblAbuseIPDBAction},
{actionLog, actionLog, none},
} {
t.Run(
tc.blocklistAction+" "+tc.reputationAction,
tc.blocklistAction+" "+tc.dnsblAbuseIPDBAction,
func(t *testing.T) {
t.Parallel()
s, server, _ := startWithLookups(t, map[string]string{
blocklistURLs: dropURL, blocklistAction: tc.blocklistAction,
dnsblZones: dnsblZone, dnsblResolver: noResolver,
reputationAction: tc.reputationAction,
dnsblAbuseIPDBAction: tc.dnsblAbuseIPDBAction,
})
loadLists(t, server, map[string][]string{dropURL: {fromDE}})
loadVerdicts(
@@ -676,7 +676,7 @@ func TestEachZoneThatListsAClientRaisesAnAlertOncePerCooldownAndIsCounted(
s, clk, server, queue := startWithLookupsAndClock(t, map[string]string{
dnsblZones: dnsblZone + "," + otherZone, dnsblResolver: noResolver,
reputationAction: actionLog, metricsToken: token,
dnsblAbuseIPDBAction: actionLog, metricsToken: token,
})
loadVerdicts(server, map[string][]string{
fromDE: {dnsblZone, otherZone}, unplaced: nil,
@@ -740,7 +740,7 @@ func TestZoneKeyIsMaskedInTheLogTheAlertAndTheMetrics(t *testing.T) {
)
s, server, queue := startWithLookups(t, map[string]string{
dnsblZones: keyed, dnsblResolver: noResolver, reputationAction: actionLog,
dnsblZones: keyed, dnsblResolver: noResolver, dnsblAbuseIPDBAction: actionLog,
metricsToken: token,
})
loadVerdicts(server, map[string][]string{fromDE: {keyed}, unplaced: nil})
@@ -849,7 +849,7 @@ func TestOnlyAClientThatHasCommittedAnOffenceIsCheckedWithAbuseIPDB(
forward := requestlog.ActionForward
s, clk, server, _ := startWithLookupsAndClock(t, map[string]string{
abuseIPDBKey: accountKey, rateLimitPerMinute: "2", reputationAction: actionLog,
abuseIPDBKey: accountKey, rateLimitPerMinute: "2", dnsblAbuseIPDBAction: actionLog,
})
// Neither fromDE, until it breaks a rate limit, nor fromKP, which never
@@ -892,7 +892,7 @@ func TestIPv6ClientCostsOneAbuseIPDBCheckWhicheverOfItsAddressesSends(
}
s, clk, server := startWithClock(t, "", map[string]string{
abuseIPDBKey: accountKey, reputationAction: actionLog,
abuseIPDBKey: accountKey, dnsblAbuseIPDBAction: actionLog,
rateLimitPerMinute: strconv.Itoa(len(addresses)),
})
@@ -961,9 +961,12 @@ func TestClientRefusedForAnOffenceIsCheckedWithAbuseIPDBAtItsNextRequest(
t.Parallel()
s, clk, server := startWithClock(t, "", map[string]string{
abuseIPDBKey: accountKey, reputationAction: actionLog,
rulesDir: writeRules(t, testRules), attackBanDuration: "1h",
trapPaths: trapPathList, wafMode: block,
abuseIPDBKey: accountKey, dnsblAbuseIPDBAction: actionLog,
ruleFilesDir: writeRules(
t,
testRules,
), attackBanDuration: "1h",
trapPaths: trapPathList, coreRuleSetMode: block,
})
s.request(client, tc.path, tc.status, tc.action)
@@ -986,7 +989,7 @@ func TestClientRefusedForAnOffenceIsCheckedWithAbuseIPDBAtItsNextRequest(
}
}
func TestEachReputationActionForAClientAbuseIPDBScoresAtOrOverTheMinimum(
func TestEachDNSBLAbuseIPDBActionForAClientAbuseIPDBScoresAtOrOverTheMinimum(
t *testing.T,
) {
t.Parallel()
@@ -1009,7 +1012,7 @@ func TestEachReputationActionForAClientAbuseIPDBScoresAtOrOverTheMinimum(
// Half of 4 requests a minute: the third breaks the limit.
limitHalf, []int{http.StatusOK, http.StatusOK, http.StatusForbidden},
[]string{forward, forward, requestlog.ActionClientIsRateLimited},
"50 from " + reputationAction,
"50 from " + dnsblAbuseIPDBAction,
},
{
actionLog, []int{http.StatusOK, http.StatusOK, http.StatusOK},
@@ -1021,7 +1024,7 @@ func TestEachReputationActionForAClientAbuseIPDBScoresAtOrOverTheMinimum(
s, server, _ := startWithLookups(t, map[string]string{
rateLimitPerMinute: fourAMinute, abuseIPDBKey: accountKey,
reputationAction: tc.action,
dnsblAbuseIPDBAction: tc.action,
})
// At SWW_WAF_ABUSEIPDB_MIN_SCORE, 75 by default, and just under it.
loadScores(server, map[string]int64{fromDE: 75, fromKP: 74})
@@ -1068,7 +1071,7 @@ func TestAbuseIPDBHitRaisesAnAlertWithTheScoreOncePerCooldownAndIsCounted(
t.Parallel()
s, server, queue := startWithLookups(t, map[string]string{
abuseIPDBKey: accountKey, reputationAction: actionLog, metricsToken: token,
abuseIPDBKey: accountKey, dnsblAbuseIPDBAction: actionLog, metricsToken: token,
})
loadScores(server, map[string]int64{fromDE: 90})
@@ -1159,10 +1162,10 @@ func TestBanNotesNameEachReputationSourceThatListedTheClient(t *testing.T) {
t.Parallel()
s, _, server, queue := startWithAlerts(t, map[string]string{
rateLimitPerMinute: "1", rulesDir: writeRules(t, testRules),
rateLimitPerMinute: "1", ruleFilesDir: writeRules(t, testRules),
blocklistURLs: dropURL, blocklistAction: actionLog,
dnsblZones: dnsblZone, dnsblResolver: noResolver,
abuseIPDBKey: accountKey, reputationAction: actionLog,
abuseIPDBKey: accountKey, dnsblAbuseIPDBAction: actionLog,
})
// Every source lists client, and none otherClient, whose score is
// under SWW_WAF_ABUSEIPDB_MIN_SCORE, 75 by default.
+3 -3
View File
@@ -44,7 +44,7 @@ type decision struct {
lookedUp bool
lookupAnswer lookup.Answer
// skippedLookupWait is true for a request that did not wait for GeoJS and
// found no answer kept. SWW_WAF_UNKNOWN_LIMIT_PERCENT does not apply to it,
// found no answer kept. SWW_WAF_UNKNOWN_COUNTRY_LIMIT_PERCENT does not apply to it,
// so that not waiting never lowers its client's limits.
skippedLookupWait bool
// counted is true for a request the rate limits counted, whose bytes
@@ -367,13 +367,13 @@ func (rq *request) countAnomalies() {
}
// anomalyThresholdsSet reports whether any anomaly threshold is set.
func anomalyThresholdsSet(cfg *config.Config) bool {
func anomalyThresholdsSet(cfg *config.WAF) bool {
off := anomaly.Thresholds{}
return cfg.AnomalyClient != off || cfg.AnomalyNet != off ||
cfg.AnomalyASN != off ||
cfg.AnomalyTotal != off ||
cfg.AnomalyWatch != off
cfg.AnomalyWatchedNet != off
}
// answerAtTheEnd returns, for a client that was looked up, the lookup's
+1 -1
View File
@@ -1,7 +1,7 @@
package waf
// The heuristic rule_files. It reads the rules of the rule files, which
// SWW_WAF_RULES_DIR and SWW_WAF_RULES_ENABLED name.
// SWW_WAF_RULE_FILES_DIR and SWW_WAF_RULE_FILES_ENABLED name.
import (
"context"
+9 -9
View File
@@ -25,8 +25,8 @@ func TestEachRuleAction(t *testing.T) {
t.Parallel()
s, clk, server := startWithClock(t, "", map[string]string{
rulesDir: writeRules(t, "noted path log ^/\n"+testRules),
banResponse: "429",
ruleFilesDir: writeRules(t, "noted path log ^/\n"+testRules),
banResponse: "429",
})
start := clk.Now()
@@ -112,7 +112,7 @@ func TestEachRuleActionAddsTheExploitSignatureTag(t *testing.T) {
t.Parallel()
s, _, _ := startWithClock(t, "", map[string]string{
rulesDir: writeRules(t, "noted path log ^/noted$\n"+testRules),
ruleFilesDir: writeRules(t, "noted path log ^/noted$\n"+testRules),
})
wantTags(
@@ -129,7 +129,7 @@ func TestRuleRefusalNamesItsHeuristicAndTheRule(t *testing.T) {
t.Parallel()
s, _, _ := startWithClock(t, "", map[string]string{
rulesDir: writeRules(t, "noted path log ^/\n"+testRules),
ruleFilesDir: writeRules(t, "noted path log ^/\n"+testRules),
})
line := s.request(client, "/blocked", http.StatusForbidden,
@@ -155,7 +155,7 @@ func TestNextClearSignOfAttackAfterABanBansPermanently(t *testing.T) {
t.Parallel()
s, clk, _ := startWithClock(t, "", map[string]string{
rulesDir: writeRules(t, testRules),
ruleFilesDir: writeRules(t, testRules),
attackBanDuration: "1h",
})
@@ -193,7 +193,7 @@ func TestRulesComeAfterTheOtherChecks(t *testing.T) {
)
s, _, server := startWithClock(t, "", map[string]string{
rulesDir: writeRules(t, testRules),
ruleFilesDir: writeRules(t, testRules),
allowNets: allowed,
rateLimitExemptNets: exempt,
rateLimitPerMinute: "1",
@@ -234,8 +234,8 @@ func TestObserveModeLogsWhatTheRulesWouldDo(t *testing.T) {
t.Parallel()
s, _, server := startWithClock(t, "", map[string]string{
rulesDir: writeRules(t, testRules),
mode: observe,
ruleFilesDir: writeRules(t, testRules),
mode: observe,
})
line := s.request(
@@ -273,7 +273,7 @@ func TestMetricsCountRuleMatchesAndBansForAnAttack(t *testing.T) {
t.Parallel()
s, _, _ := startWithClock(t, "", map[string]string{
rulesDir: writeRules(t, testRules),
ruleFilesDir: writeRules(t, testRules),
metricsToken: token,
})
+5 -5
View File
@@ -156,11 +156,11 @@ func TestRateLimitExemptNetsAreNeitherCountedNorRefused(t *testing.T) {
app := startApp(t, func(http.ResponseWriter, *http.Request) {})
geojsURL, _ := startGeoJS(t)
addr, out := startProxyWithGeoJS(t, app.URL, geojsURL, map[string]string{
trustedProxies: trustLocalhost,
lookupTimeout: "1h",
rateLimitExemptNets: listedAddr + "," + fromKP,
tagDeny: denyKP,
rateLimitPerMinute: "1",
trustedProxies: trustLocalhost,
asnAndCountryLookupTimeout: "3600000",
rateLimitExemptNets: listedAddr + "," + fromKP,
tagDeny: denyKP,
rateLimitPerMinute: "1",
})
// listedAddr's requests are neither refused nor counted, so the first
+2 -2
View File
@@ -70,7 +70,7 @@ func TestTagDenyRefusesAMixedCaseZoneByItsLowerCaseTag(t *testing.T) {
// The zone's tag is in lower case, however SWW_WAF_DNSBL_ZONES writes it.
s, server, _ := startWithLookups(t, map[string]string{
dnsblZones: mixedCaseZone, dnsblResolver: noResolver,
reputationAction: actionLog, tagDeny: "dnsbl=" + dnsblZone,
dnsblAbuseIPDBAction: actionLog, tagDeny: "dnsbl=" + dnsblZone,
})
loadVerdicts(server, map[string][]string{fromDE: {mixedCaseZone}})
@@ -86,7 +86,7 @@ func TestTagDenyRefusesAnExploitSignatureOfARuleThatOnlyLogs(t *testing.T) {
t.Parallel()
s, _, _ := startWithClock(t, "", map[string]string{
rulesDir: writeRules(
ruleFilesDir: writeRules(
t,
"noted path log ^/\n",
), tagDeny: "exploitsignature",
+6 -6
View File
@@ -25,10 +25,10 @@ func TestTrapPathBansAsABanRuleDoes(t *testing.T) {
// A block rule for the same path: the trap path comes first.
s, clk, server := startWithClock(t, "", map[string]string{
trapPaths: trapPathList,
rulesDir: writeRules(t, `wp path block ^/wp-login\.php$`),
allowNets: allowed,
banResponse: "429",
trapPaths: trapPathList,
ruleFilesDir: writeRules(t, `wp path block ^/wp-login\.php$`),
allowNets: allowed,
banResponse: "429",
})
start := clk.Now()
@@ -153,8 +153,8 @@ func TestTrapPathsNeedNoRuleFiles(t *testing.T) {
t.Parallel()
s, _, _ := startWithClock(t, "", map[string]string{
trapPaths: trapPathList,
"SWW_WAF_RULES_ENABLED": "false",
trapPaths: trapPathList,
"SWW_WAF_RULE_FILES_ENABLED": "false",
})
s.request(client, "/xmlrpc.php", http.StatusForbidden,
+4 -3
View File
@@ -227,9 +227,10 @@ func (h *handler) answerQuestion(w http.ResponseWriter, r *http.Request) {
// readQuestion returns the visitor's address and the body a question carries,
// and whether waf takes the question. It answers a question it does not
// take with 400 and no disposition. rproxy passes that 400 to the visitor,
// and SWW_RPROXY_DEFAULT_DISPOSITION does not apply. The id of a question waf takes
// is held from here on, without a decision yet, so that another question with
// that id is refused while this one is being handled.
// and SWW_RPROXY_WAF_BACKEND_NO_ANSWER_DISPOSITION does not apply. The id of
// a question waf takes is held from here on, without a decision yet, so
// that another question with that id is refused while this one is being
// handled.
//
// The body is read before any check, since Go's server does not send an answer
// until it has read the body. A question whose body stops arriving gets no
+19 -16
View File
@@ -454,7 +454,8 @@ func TestReportNeverReachesAQuestionStillBeingHandled(t *testing.T) {
geojsURL, asked, release := startHeldGeoJS(t)
s := startWAF(t, geojsURL, map[string]string{
lookupTimeout: "1h", tagDeny: denyKP, bytesLimitPerMinute: byteLimit,
asnAndCountryLookupTimeout: "3600000", tagDeny: denyKP,
bytesLimitPerMinute: byteLimit,
})
first := s.question(siteA, client, "/")
@@ -563,7 +564,7 @@ func TestContentLengthUnreadableOrNegativeCountsAsAbsent(t *testing.T) {
t.Parallel()
s := startWAF(t, "", map[string]string{
rulesDir: writeRules(
ruleFilesDir: writeRules(
t,
"any-length header:Content-Length block .\n",
),
@@ -755,8 +756,8 @@ func TestBlockRuleIsDeniedWith403AndABanRuleWithTheBanResponse(
t.Parallel()
s := startWAF(t, "", map[string]string{
rulesDir: writeRules(t, testRules),
banResponse: "429",
ruleFilesDir: writeRules(t, testRules),
banResponse: "429",
})
got := s.ask(client, "/blocked")
@@ -810,13 +811,13 @@ func TestObserveModeAllowsAndLogsWhatEnforceModeWouldHaveDone(t *testing.T) {
wantWouldAction(t, line, requestlog.ActionClientDenied)
}
func TestObserveModeRefusalAnsweredAllowCountsForTheErrorBurst(
func TestObserveModeRefusalAnsweredAllowCountsForTheRefusalLimit(
t *testing.T,
) {
t.Parallel()
s := startWAF(t, "", map[string]string{
mode: observe, rulesDir: writeRules(t, testRules), errorBurstThreshold: "1",
mode: observe, ruleFilesDir: writeRules(t, testRules), refusalsLimitPerMinute: "1",
})
for range 2 {
@@ -836,11 +837,11 @@ func TestObserveModeRefusalAnsweredAllowCountsForTheErrorBurst(
}
}
func TestDeniedQuestionCountsForTheErrorBurst(t *testing.T) {
func TestDeniedQuestionCountsForTheRefusalLimit(t *testing.T) {
t.Parallel()
s := startWAF(t, "", map[string]string{
rulesDir: writeRules(t, testRules), errorBurstThreshold: "1",
ruleFilesDir: writeRules(t, testRules), refusalsLimitPerMinute: "1",
})
for range 2 {
@@ -875,7 +876,9 @@ func TestAllowCarriesTheLookupHeadersWhileTheSettingIsSet(t *testing.T) {
geojsURL, _ := startGeoJS(t)
s := startWAF(t, geojsURL, map[string]string{
addLookupHeaders: tc.setting, lookupTimeout: "1h", tagDeny: denyKP,
addLookupHeaders: tc.setting,
asnAndCountryLookupTimeout: "3600000",
tagDeny: denyKP,
})
got := s.ask(fromDE, "/")
@@ -946,8 +949,8 @@ func TestReportCountsAtThePercentageItsQuestionWasGiven(t *testing.T) {
geojsURL, _ := startGeoJS(t)
// 100 bytes pass half of a limit of 99 and are within a limit of 199.
s := startWAF(t, geojsURL, map[string]string{
bytesLimitPerMinute: "199", countryBytesPercent: countryDEHalf,
lookupTimeout: "1h",
bytesLimitPerMinute: "199", countryBytesLimitPercent: countryDEHalf,
asnAndCountryLookupTimeout: "3600000",
})
s.passedOn(fromKP)
@@ -1421,7 +1424,7 @@ func TestReportWritesTheLastLineWithTheDecisionsAndTheReportsFields(
s := startWAF(t, "", map[string]string{
logRequestHeaders: "accept",
rulesDir: writeRules(t, "seen path log ^/page$\n"),
ruleFilesDir: writeRules(t, "seen path log ^/page$\n"),
})
const (
@@ -1573,7 +1576,7 @@ func TestBanMadeThroughOneHostRefusesTheClientOnAnother(t *testing.T) {
s := startWAF(
t,
"",
map[string]string{rulesDir: writeRules(t, testRules)},
map[string]string{ruleFilesDir: writeRules(t, testRules)},
)
got := do(t, s.question(siteA, client, "/.env"))
@@ -1745,7 +1748,7 @@ func TestChecksSeeTheHeadersTheVisitorSent(t *testing.T) {
t.Parallel()
s := startWAF(t, "", map[string]string{
rulesDir: writeRules(t, `
ruleFilesDir: writeRules(t, `
own-client header:X-Sww-Client block .
own-peer header:X-Sww-Peer block .
own-length header:X-Sww-Content-Length block .
@@ -1779,7 +1782,7 @@ func TestCoreRuleSetReadsTheBodyAQuestionCarries(t *testing.T) {
t.Parallel()
s := startWAF(t, "", map[string]string{
wafMode: block, wafBodyLimit: "64K",
coreRuleSetMode: block, coreRuleSetBodyInspectionSize: "64K",
})
ask := func(contentType, body string) answer {
@@ -1804,7 +1807,7 @@ func TestCoreRuleSetReadsTheBodyAQuestionCarries(t *testing.T) {
func TestCoreRuleSetAlertNamesTheSite(t *testing.T) {
t.Parallel()
s := startWAF(t, "", map[string]string{wafMode: block})
s := startWAF(t, "", map[string]string{coreRuleSetMode: block})
got := s.ask(client, sqlInjection)
wantDisposition(t, got, http.StatusForbidden, waf.DispositionDeny)