Per-client request rate limits over a minute, an hour and a day (closes #43)
check / check (push) Successful in 3m32s
check / check (push) Successful in 3m32s
Each client, one IPv4 address or one IPv6 /64, has its requests counted in two buckets per window, the earlier weighted by how much of it the window still covers, in a table of at most 20,000 clients that drops the least recently seen. A request over SWWAF_RATE_LIMIT_PER_MINUTE, _HOUR or _DAY (1000, 10000, 50000, or off) gets 429 before anything reaches the app, and refused requests count. The log line gains limit_hit and the action rate_limited. The rate limits run before the announced-size check, so a request refused with 413 is counted too. Deviation from SPEC.md, per the issue: the 20,000 bound and the /64 are fixed, not settings. Judgement call: golang-lru/v2 holds the table; httprate is not used, as it reads the wall clock and does not count refused requests. Deviation: go.mod and go.sum were written by hand from the Go checksum database, as no make target runs go mod tidy. Model: opus-5-5
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) {
|
||||
|
||||
Reference in New Issue
Block a user