Byte limits per client over a minute, an hour and a day (closes #20)
check / check (push) Waiting to run

SWWAF_BYTES_LIMIT_PER_MINUTE, _PER_HOUR and _PER_DAY (10G, 20G, 50G)
and SWWAF_BYTES_COUNT (both). A request's bytes are counted once its
answer has ended, for a request passed to the app that the rate limits
count; what a WebSocket carries each way, once it closes. Bytes over a
limit ban the client as a broken rate limit does, and cut nothing
short. clients.json keeps the byte buckets, the log line's counts carry
the byte totals, ban notes say what the limit is on, and the limit hits
metric is labelled by kind.

Judgement call: limit_hit names a byte window minute_bytes, hour_bytes
or day_bytes, as counts names the byte totals.
Judgement call: in observe mode, the bytes of a request enforce mode
would have refused are not counted.

Model: opus-5-5
This commit was merged in pull request #102.
This commit is contained in:
2026-10-07 13:13:06 +02:00
parent 0dc26041dc
commit f35e3ddfe8
23 changed files with 1400 additions and 300 deletions
+25
View File
@@ -91,6 +91,15 @@ type Config struct {
// limits neither count nor refuse (SWWAF_RATE_LIMIT_EXEMPT_PATHS).
// Each starts with /.
RateLimitExemptPaths []string
// BytesLimitPerMinute, BytesLimitPerHour and BytesLimitPerDay are the
// most bytes a client's requests may carry in a minute, an hour and a
// day (SWWAF_BYTES_LIMIT_PER_MINUTE, SWWAF_BYTES_LIMIT_PER_HOUR and
// SWWAF_BYTES_LIMIT_PER_DAY). BytesCount is which body bytes count
// toward them (SWWAF_BYTES_COUNT): response, request or both.
BytesLimitPerMinute int64
BytesLimitPerHour int64
BytesLimitPerDay int64
BytesCount string
// LookupSource is where each client's AS number and country are
// looked up (SWWAF_LOOKUP_SOURCE): geojs, file, or off for nowhere.
// LookupDBPath is the lookup database, the IPinfo Lite file looked up
@@ -266,6 +275,7 @@ var (
"is not an absolute path, such as /var/lib/smallwebwaf")
errShortToken = errors.New("is shorter than 32 characters")
errNotMode = errors.New("is not enforce or observe")
errNotBytesCount = errors.New("is not response, request or both")
errNotPathPrefix = errors.New(
"is not a path prefix starting with /, such as /assets/")
errNotBoolean = errors.New("is not true or false")
@@ -321,6 +331,10 @@ func FromEnvironment(lookupEnv func(string) (string, bool)) (*Config, error) {
RateLimitPerHour: env.count("SWWAF_RATE_LIMIT_PER_HOUR", "10000"),
RateLimitPerDay: env.count("SWWAF_RATE_LIMIT_PER_DAY", "50000"),
RateLimitExemptPaths: env.pathPrefixes("SWWAF_RATE_LIMIT_EXEMPT_PATHS", ""),
BytesLimitPerMinute: env.size("SWWAF_BYTES_LIMIT_PER_MINUTE", "10G"),
BytesLimitPerHour: env.size("SWWAF_BYTES_LIMIT_PER_HOUR", "20G"),
BytesLimitPerDay: env.size("SWWAF_BYTES_LIMIT_PER_DAY", "50G"),
BytesCount: env.bytesCount("SWWAF_BYTES_COUNT", "both"),
LookupSource: env.lookupSource("SWWAF_LOOKUP_SOURCE", "geojs"),
LookupDBPath: env.value("SWWAF_LOOKUP_DB_PATH", ""),
LookupTimeout: env.durationNotOff("SWWAF_LOOKUP_TIMEOUT", "1s"),
@@ -551,6 +565,17 @@ 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 {
value := e.value(name, defaultValue)
if value != "response" && value != "request" && value != "both" {
e.check(name, fmt.Errorf("%q %w", value, errNotBytesCount))
}
return value
}
// pathPrefixes reads a setting that is a list of path prefixes.
func (e *environment) pathPrefixes(name, defaultValue string) []string {
prefixes, err := parsePathPrefixes(e.value(name, defaultValue))
+84 -16
View File
@@ -40,6 +40,10 @@ const (
rateLimitPerHour = "SWWAF_RATE_LIMIT_PER_HOUR"
rateLimitPerDay = "SWWAF_RATE_LIMIT_PER_DAY"
rateLimitExemptPaths = "SWWAF_RATE_LIMIT_EXEMPT_PATHS"
bytesLimitPerMinute = "SWWAF_BYTES_LIMIT_PER_MINUTE"
bytesLimitPerHour = "SWWAF_BYTES_LIMIT_PER_HOUR"
bytesLimitPerDay = "SWWAF_BYTES_LIMIT_PER_DAY"
bytesCount = "SWWAF_BYTES_COUNT"
lookupSource = "SWWAF_LOOKUP_SOURCE"
lookupDBPath = "SWWAF_LOOKUP_DB_PATH"
lookupTimeout = "SWWAF_LOOKUP_TIMEOUT"
@@ -190,6 +194,10 @@ func TestDefaults(t *testing.T) {
wantLookupSettings(t, cfg, config.Config{
LookupSource: defaultLookupSource, LookupTimeout: time.Second,
})
wantByteLimitSettings(t, cfg, config.Config{
BytesLimitPerMinute: 10 << 30, BytesLimitPerHour: 20 << 30,
BytesLimitPerDay: 50 << 30, BytesCount: "both",
})
if cfg.UpstreamURL.String() != "http://127.0.0.1:8081" {
t.Errorf("%s is %s", upstreamURL, cfg.UpstreamURL)
@@ -220,6 +228,22 @@ func TestDefaults(t *testing.T) {
}
}
func TestNoSingleRequestBreaksAByteLimitAtTheDefaults(t *testing.T) {
t.Parallel()
cfg := fromEnvironment(t, environment{})
// The largest request body and the largest response, both counted.
largest := cfg.RequestMaxBytes + cfg.ResponseMaxBytes
for _, limit := range []int64{
cfg.BytesLimitPerMinute, cfg.BytesLimitPerHour, cfg.BytesLimitPerDay,
} {
if largest > limit {
t.Errorf("a request of %d bytes breaks the byte limit of %d", largest, limit)
}
}
}
func TestValuesAsSet(t *testing.T) {
t.Parallel()
@@ -302,6 +326,22 @@ func TestValuesAsSet(t *testing.T) {
wantCountries(t, allowedCountries, cfg.ExclusivelyAllowedCountries, "DE")
}
func TestByteLimitSettingsAsSet(t *testing.T) {
t.Parallel()
cfg := fromEnvironment(t, environment{
bytesLimitPerMinute: "512M",
bytesLimitPerHour: off,
bytesLimitPerDay: "100000",
bytesCount: "response",
})
wantByteLimitSettings(t, cfg, config.Config{
BytesLimitPerMinute: 512 << 20, BytesLimitPerHour: 0,
BytesLimitPerDay: 100000, BytesCount: "response",
})
}
func TestRateLimitExemptPathsAsSet(t *testing.T) {
t.Parallel()
@@ -996,6 +1036,9 @@ func TestInvalidValueStopsTheStart(t *testing.T) {
{rateLimitPerHour, "1.5"},
{rateLimitPerDay, "-1"}, {rateLimitPerDay, "lots"},
{rateLimitExemptPaths, "/assets/,,/static/"},
{bytesLimitPerMinute, "10GB"}, {bytesLimitPerHour, "0"},
{bytesLimitPerDay, "-1G"},
{bytesCount, "all"}, {bytesCount, "Both"}, {bytesCount, ""},
{lookupSource, "ipinfo"}, {lookupSource, "GeoJS"}, {lookupSource, ""},
{lookupTimeout, off}, {lookupTimeout, "0s"}, {lookupTimeout, "1"},
{addLookupHeaders, "yes"},
@@ -1250,20 +1293,6 @@ func TestLogsEachSettingWithItsValue(t *testing.T) {
t.Parallel()
cfg := fromEnvironment(t, environment{clientRequestTimeout: "45s"})
var out bytes.Buffer
slog.New(slog.NewJSONHandler(&out, nil)).Info("starting", "settings", cfg)
var line struct {
Settings map[string]string `json:"settings"`
}
err := json.Unmarshal(out.Bytes(), &line)
if err != nil {
t.Fatalf("decode %s: %v", out.Bytes(), err)
}
hostname, _ := os.Hostname()
want := map[string]string{
@@ -1286,6 +1315,10 @@ func TestLogsEachSettingWithItsValue(t *testing.T) {
rateLimitPerHour: "10000",
rateLimitPerDay: "50000",
rateLimitExemptPaths: "",
bytesLimitPerMinute: "10G",
bytesLimitPerHour: "20G",
bytesLimitPerDay: "50G",
bytesCount: "both",
lookupSource: defaultLookupSource,
lookupDBPath: "",
lookupTimeout: "1s",
@@ -1323,11 +1356,31 @@ func TestLogsEachSettingWithItsValue(t *testing.T) {
alertCooldown: defaultAlertCooldown,
alertMaxPerHour: "60",
}
if !maps.Equal(line.Settings, want) {
t.Errorf("logged settings\n%v\nwant\n%v", line.Settings, want)
if got := loggedSettings(t, cfg); !maps.Equal(got, want) {
t.Errorf("logged settings\n%v\nwant\n%v", got, want)
}
}
// loggedSettings returns the settings as cfg logs them, each by its name.
func loggedSettings(t *testing.T, cfg *config.Config) map[string]string {
t.Helper()
var out bytes.Buffer
slog.New(slog.NewJSONHandler(&out, nil)).Info("starting", "settings", cfg)
var line struct {
Settings map[string]string `json:"settings"`
}
err := json.Unmarshal(out.Bytes(), &line)
if err != nil {
t.Fatalf("decode %s: %v", out.Bytes(), err)
}
return line.Settings
}
// wantSettings checks the settings that are plain values.
func wantSettings(t *testing.T, got *config.Config, want config.Config) {
t.Helper()
@@ -1351,6 +1404,21 @@ func wantSettings(t *testing.T, got *config.Config, want config.Config) {
wantBanSettings(t, got, want)
}
// wantByteLimitSettings checks the settings for the byte limits.
func wantByteLimitSettings(t *testing.T, got *config.Config, want config.Config) {
t.Helper()
if got.BytesLimitPerMinute != want.BytesLimitPerMinute ||
got.BytesLimitPerHour != want.BytesLimitPerHour ||
got.BytesLimitPerDay != want.BytesLimitPerDay ||
got.BytesCount != want.BytesCount {
t.Errorf("byte limits %d, %d and %d counting %s, want %d, %d and %d counting %s",
got.BytesLimitPerMinute, got.BytesLimitPerHour, got.BytesLimitPerDay,
got.BytesCount, want.BytesLimitPerMinute, want.BytesLimitPerHour,
want.BytesLimitPerDay, want.BytesCount)
}
}
// wantLookupSettings checks the settings for lookups.
func wantLookupSettings(t *testing.T, got *config.Config, want config.Config) {
t.Helper()