Byte limits per client over a minute, an hour and a day (closes #20)
check / check (push) Waiting to run
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:
@@ -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))
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user