Per-client request rate limits over a minute, an hour and a day (closes #43)
check / check (push) Successful in 2m50s
check / check (push) Successful in 2m50s
Each client, one IPv4 address or one IPv6 /64, is counted in two buckets per window, the earlier weighted by how much of it the window covers; at most 20,000 clients are kept, least recently seen dropped first. A request over SWWAF_RATE_LIMIT_PER_MINUTE, _HOUR or _DAY (1000, 10000, 50000, or off) gets 429 before reaching the app. Refused requests count, 413s included. A clock set back over a second behind a bucket's start restarts that window. The log line gains limit_hit and the action rate_limited. Deviation from SPEC.md, per the issue: the 20,000 bound and /64 are fixed. Judgement call: golang-lru/v2 holds the table; httprate does not count refused requests. Deviation: go.mod and go.sum hand-written; no make target tidies them. Model: opus-5-5
This commit was merged in pull request #48.
This commit is contained in:
@@ -16,7 +16,8 @@ import (
|
||||
"time"
|
||||
)
|
||||
|
||||
// Config is smallwebwaf's settings. A timeout or size of zero is off.
|
||||
// Config is smallwebwaf's settings. A timeout, size or rate limit of zero
|
||||
// is off.
|
||||
type Config struct {
|
||||
// ListenAddr is where smallwebwaf listens (SWWAF_LISTEN_ADDR).
|
||||
ListenAddr string
|
||||
@@ -43,13 +44,21 @@ type Config struct {
|
||||
// ResponseMaxBytes is the largest response body
|
||||
// (SWWAF_RESPONSE_MAX_BYTES).
|
||||
ResponseMaxBytes int64
|
||||
// RateLimitPerMinute, RateLimitPerHour and RateLimitPerDay are the
|
||||
// most requests a client may make in a minute, an hour and a day
|
||||
// (SWWAF_RATE_LIMIT_PER_MINUTE, SWWAF_RATE_LIMIT_PER_HOUR and
|
||||
// SWWAF_RATE_LIMIT_PER_DAY).
|
||||
RateLimitPerMinute int64
|
||||
RateLimitPerHour int64
|
||||
RateLimitPerDay int64
|
||||
|
||||
// settings are the values read, as given or by default, for the
|
||||
// log line at start.
|
||||
settings []slog.Attr
|
||||
}
|
||||
|
||||
// off is the value that switches a timeout or a size limit off.
|
||||
// off is the value that switches a timeout, a size limit or a rate limit
|
||||
// off.
|
||||
const off = "off"
|
||||
|
||||
const (
|
||||
@@ -64,6 +73,8 @@ var (
|
||||
"is not a duration such as 90s, 15m or 7d, or off")
|
||||
errNotSize = errors.New(
|
||||
"is not a size such as 512K, 100M or 5G, or off")
|
||||
errNotCount = errors.New(
|
||||
"is not a whole number of requests such as 1000, or off")
|
||||
errNotPositive = errors.New("must be more than zero, or off")
|
||||
errEmptyItem = errors.New("has an empty item in its list")
|
||||
errNotNetblock = errors.New(
|
||||
@@ -90,6 +101,9 @@ func FromEnvironment(lookupEnv func(string) (string, bool)) (*Config, error) {
|
||||
UpstreamResponseTimeout: env.duration("SWWAF_UPSTREAM_RESPONSE_TIMEOUT", "30m"),
|
||||
RequestMaxBytes: env.size("SWWAF_REQUEST_MAX_BYTES", "100M"),
|
||||
ResponseMaxBytes: env.size("SWWAF_RESPONSE_MAX_BYTES", "5G"),
|
||||
RateLimitPerMinute: env.count("SWWAF_RATE_LIMIT_PER_MINUTE", "1000"),
|
||||
RateLimitPerHour: env.count("SWWAF_RATE_LIMIT_PER_HOUR", "10000"),
|
||||
RateLimitPerDay: env.count("SWWAF_RATE_LIMIT_PER_DAY", "50000"),
|
||||
}
|
||||
|
||||
if env.err != nil {
|
||||
@@ -179,6 +193,14 @@ func (e *environment) size(name, defaultValue string) int64 {
|
||||
return size
|
||||
}
|
||||
|
||||
// count reads a setting that is a number of requests.
|
||||
func (e *environment) count(name, defaultValue string) int64 {
|
||||
count, err := parseCount(e.value(name, defaultValue))
|
||||
e.check(name, err)
|
||||
|
||||
return count
|
||||
}
|
||||
|
||||
// parseDuration reads a duration in Go's syntax, such as 90s or 15m, a
|
||||
// whole number of days such as 7d, or off.
|
||||
func parseDuration(value string) (time.Duration, error) {
|
||||
@@ -249,6 +271,24 @@ func splitUnit(value string) (string, int64) {
|
||||
}
|
||||
}
|
||||
|
||||
// parseCount reads a whole number of requests, or off.
|
||||
func parseCount(value string) (int64, error) {
|
||||
if value == off {
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
n, err := strconv.ParseInt(value, 10, 64)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("%q %w", value, errNotCount)
|
||||
}
|
||||
|
||||
if n <= 0 {
|
||||
return 0, fmt.Errorf("%q %w", value, errNotPositive)
|
||||
}
|
||||
|
||||
return n, nil
|
||||
}
|
||||
|
||||
// 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) {
|
||||
|
||||
@@ -25,9 +25,12 @@ const (
|
||||
upstreamResponseTimeout = "SWWAF_UPSTREAM_RESPONSE_TIMEOUT"
|
||||
requestMaxBytes = "SWWAF_REQUEST_MAX_BYTES"
|
||||
responseMaxBytes = "SWWAF_RESPONSE_MAX_BYTES"
|
||||
rateLimitPerMinute = "SWWAF_RATE_LIMIT_PER_MINUTE"
|
||||
rateLimitPerHour = "SWWAF_RATE_LIMIT_PER_HOUR"
|
||||
rateLimitPerDay = "SWWAF_RATE_LIMIT_PER_DAY"
|
||||
)
|
||||
|
||||
// off switches a timeout or a size limit off.
|
||||
// off switches a timeout, a size limit or a rate limit off.
|
||||
const off = "off"
|
||||
|
||||
// environment is a set of environment variables, for FromEnvironment.
|
||||
@@ -65,6 +68,9 @@ func TestDefaults(t *testing.T) {
|
||||
UpstreamResponseTimeout: 30 * time.Minute,
|
||||
RequestMaxBytes: 100 << 20,
|
||||
ResponseMaxBytes: 5 << 30,
|
||||
RateLimitPerMinute: 1000,
|
||||
RateLimitPerHour: 10000,
|
||||
RateLimitPerDay: 50000,
|
||||
})
|
||||
|
||||
if cfg.UpstreamURL.String() != "http://127.0.0.1:8081" {
|
||||
@@ -88,6 +94,9 @@ func TestValuesAsSet(t *testing.T) {
|
||||
upstreamResponseTimeout: off,
|
||||
requestMaxBytes: "512K",
|
||||
responseMaxBytes: "1234",
|
||||
rateLimitPerMinute: "60",
|
||||
rateLimitPerHour: "600",
|
||||
rateLimitPerDay: "6000",
|
||||
})
|
||||
|
||||
wantSettings(t, cfg, config.Config{
|
||||
@@ -98,6 +107,9 @@ func TestValuesAsSet(t *testing.T) {
|
||||
UpstreamResponseTimeout: 0,
|
||||
RequestMaxBytes: 512 << 10,
|
||||
ResponseMaxBytes: 1234,
|
||||
RateLimitPerMinute: 60,
|
||||
RateLimitPerHour: 600,
|
||||
RateLimitPerDay: 6000,
|
||||
})
|
||||
|
||||
if cfg.UpstreamURL.String() != "https://app.internal:8443/" {
|
||||
@@ -123,6 +135,22 @@ func TestSizesAndOff(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRateLimitsOff(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cfg := fromEnvironment(t, environment{
|
||||
rateLimitPerMinute: off,
|
||||
rateLimitPerHour: off,
|
||||
rateLimitPerDay: off,
|
||||
})
|
||||
|
||||
if cfg.RateLimitPerMinute != 0 || cfg.RateLimitPerHour != 0 ||
|
||||
cfg.RateLimitPerDay != 0 {
|
||||
t.Errorf("off read as %d, %d and %d",
|
||||
cfg.RateLimitPerMinute, cfg.RateLimitPerHour, cfg.RateLimitPerDay)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTrustedProxiesSetButEmptyTrustNothing(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
@@ -164,6 +192,12 @@ func TestInvalidValueStopsTheStart(t *testing.T) {
|
||||
{responseMaxBytes, "0"},
|
||||
{responseMaxBytes, "-5"},
|
||||
{responseMaxBytes, "99999999999G"},
|
||||
{rateLimitPerMinute, ""},
|
||||
{rateLimitPerMinute, "1K"},
|
||||
{rateLimitPerHour, "0"},
|
||||
{rateLimitPerHour, "1.5"},
|
||||
{rateLimitPerDay, "-1"},
|
||||
{rateLimitPerDay, "lots"},
|
||||
} {
|
||||
t.Run(tc.name+"="+tc.value, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
@@ -208,6 +242,9 @@ func TestLogsEachSettingWithItsValue(t *testing.T) {
|
||||
upstreamResponseTimeout: "30m",
|
||||
requestMaxBytes: "100M",
|
||||
responseMaxBytes: "5G",
|
||||
rateLimitPerMinute: "1000",
|
||||
rateLimitPerHour: "10000",
|
||||
rateLimitPerDay: "50000",
|
||||
}
|
||||
if !maps.Equal(line.Settings, want) {
|
||||
t.Errorf("logged settings\n%v\nwant\n%v", line.Settings, want)
|
||||
@@ -224,7 +261,10 @@ func wantSettings(t *testing.T, got *config.Config, want config.Config) {
|
||||
got.UpstreamRequestTimeout != want.UpstreamRequestTimeout ||
|
||||
got.UpstreamResponseTimeout != want.UpstreamResponseTimeout ||
|
||||
got.RequestMaxBytes != want.RequestMaxBytes ||
|
||||
got.ResponseMaxBytes != want.ResponseMaxBytes {
|
||||
got.ResponseMaxBytes != want.ResponseMaxBytes ||
|
||||
got.RateLimitPerMinute != want.RateLimitPerMinute ||
|
||||
got.RateLimitPerHour != want.RateLimitPerHour ||
|
||||
got.RateLimitPerDay != want.RateLimitPerDay {
|
||||
t.Errorf("settings\n%+v\nwant\n%+v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user