check / check (push) Successful in 3m27s
A request over a rate limit is refused with SWWAF_BAN_RESPONSE and bans the client's netblock: an hour at first, three times the last ban when broken again within a day of its end, permanent past seven days. The ban ledger in internal/bans is checked after the static lists and before the lookup, and the requests it refuses are not counted. A ban resets the client's counters and carries notes holding the request that broke the limit, as SPEC.md now says. At most SWWAF_MAX_BANS are held. SWWAF_BAN_RESPONSE also answers SWWAF_DENY_NETS and the country lists. Judgement call: the six ban settings cannot be off. Judgement call: a permanent ban's ban_expires is "permanent". Model: opus-5-5
412 lines
13 KiB
Go
412 lines
13 KiB
Go
package config_test
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/json"
|
|
"log/slog"
|
|
"maps"
|
|
"net/netip"
|
|
"slices"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"sneak.berlin/go/smallwebwaf/internal/config"
|
|
)
|
|
|
|
// The settings, by name.
|
|
const (
|
|
listenAddr = "SWWAF_LISTEN_ADDR"
|
|
upstreamURL = "SWWAF_UPSTREAM_URL"
|
|
trustedProxies = "SWWAF_TRUSTED_PROXIES"
|
|
clientRequestTimeout = "SWWAF_CLIENT_REQUEST_TIMEOUT"
|
|
clientResponseTimeout = "SWWAF_CLIENT_RESPONSE_TIMEOUT"
|
|
upstreamRequestTimeout = "SWWAF_UPSTREAM_REQUEST_TIMEOUT"
|
|
upstreamResponseTimeout = "SWWAF_UPSTREAM_RESPONSE_TIMEOUT"
|
|
requestMaxBytes = "SWWAF_REQUEST_MAX_BYTES"
|
|
responseMaxBytes = "SWWAF_RESPONSE_MAX_BYTES"
|
|
allowNets = "SWWAF_ALLOW_NETS"
|
|
rateLimitExemptNets = "SWWAF_RATE_LIMIT_EXEMPT_NETS"
|
|
denyNets = "SWWAF_DENY_NETS"
|
|
rateLimitPerMinute = "SWWAF_RATE_LIMIT_PER_MINUTE"
|
|
rateLimitPerHour = "SWWAF_RATE_LIMIT_PER_HOUR"
|
|
rateLimitPerDay = "SWWAF_RATE_LIMIT_PER_DAY"
|
|
deniedCountries = "SWWAF_DENIED_COUNTRIES"
|
|
allowedCountries = "SWWAF_EXCLUSIVELY_ALLOWED_COUNTRIES"
|
|
banResponse = "SWWAF_BAN_RESPONSE"
|
|
limitBanDuration = "SWWAF_LIMIT_BAN_DURATION"
|
|
limitBanRepeatWindow = "SWWAF_LIMIT_BAN_REPEAT_WINDOW"
|
|
maxBanDuration = "SWWAF_MAX_BAN_DURATION"
|
|
maxBans = "SWWAF_MAX_BANS"
|
|
banScopeV4Prefix = "SWWAF_BAN_SCOPE_V4_PREFIX"
|
|
)
|
|
|
|
// off switches a timeout, a size limit or a rate limit off.
|
|
const off = "off"
|
|
|
|
// environment is a set of environment variables, for FromEnvironment.
|
|
type environment map[string]string
|
|
|
|
// lookupEnv reads one of the variables, as os.LookupEnv does.
|
|
func (e environment) lookupEnv(name string) (string, bool) {
|
|
value, ok := e[name]
|
|
|
|
return value, ok
|
|
}
|
|
|
|
// fromEnvironment reads the settings from env, which must be valid.
|
|
func fromEnvironment(t *testing.T, env environment) *config.Config {
|
|
t.Helper()
|
|
|
|
cfg, err := config.FromEnvironment(env.lookupEnv)
|
|
if err != nil {
|
|
t.Fatalf("settings %v: %v", env, err)
|
|
}
|
|
|
|
return cfg
|
|
}
|
|
|
|
func TestDefaults(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
cfg := fromEnvironment(t, environment{})
|
|
|
|
wantSettings(t, cfg, config.Config{
|
|
ListenAddr: ":8080",
|
|
ClientRequestTimeout: time.Minute,
|
|
ClientResponseTimeout: 30 * time.Minute,
|
|
UpstreamRequestTimeout: time.Minute,
|
|
UpstreamResponseTimeout: 30 * time.Minute,
|
|
RequestMaxBytes: 100 << 20,
|
|
ResponseMaxBytes: 5 << 30,
|
|
RateLimitPerMinute: 1000,
|
|
RateLimitPerHour: 10000,
|
|
RateLimitPerDay: 50000,
|
|
BanResponse: 403,
|
|
LimitBanDuration: time.Hour,
|
|
LimitBanRepeatWindow: 24 * time.Hour,
|
|
MaxBanDuration: 7 * 24 * time.Hour,
|
|
MaxBans: 5000,
|
|
BanScopeV4Prefix: 32,
|
|
})
|
|
|
|
if cfg.UpstreamURL.String() != "http://127.0.0.1:8081" {
|
|
t.Errorf("%s is %s", upstreamURL, cfg.UpstreamURL)
|
|
}
|
|
|
|
wantNetblocks(t, cfg.TrustedProxies,
|
|
"10.0.0.0/8", "172.16.0.0/12", "192.168.0.0/16")
|
|
wantNetblocks(t, cfg.AllowNets)
|
|
wantNetblocks(t, cfg.RateLimitExemptNets)
|
|
wantNetblocks(t, cfg.DenyNets)
|
|
wantCountries(t, deniedCountries, cfg.DeniedCountries)
|
|
wantCountries(t, allowedCountries, cfg.ExclusivelyAllowedCountries)
|
|
}
|
|
|
|
func TestValuesAsSet(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
cfg := fromEnvironment(t, environment{
|
|
listenAddr: "127.0.0.1:9000",
|
|
upstreamURL: "https://app.internal:8443/",
|
|
trustedProxies: " 192.0.2.1, 10.1.2.3/8 ,2001:db8::/32",
|
|
clientRequestTimeout: "90s",
|
|
clientResponseTimeout: "7d",
|
|
upstreamRequestTimeout: "1h30m",
|
|
upstreamResponseTimeout: off,
|
|
requestMaxBytes: "512K",
|
|
responseMaxBytes: "1234",
|
|
allowNets: "192.0.2.7",
|
|
rateLimitExemptNets: "2001:db8::/48, 10.9.8.7",
|
|
denyNets: "198.51.100.0/24",
|
|
rateLimitPerMinute: "60",
|
|
rateLimitPerHour: "600",
|
|
rateLimitPerDay: "6000",
|
|
deniedCountries: "cn, RU,kp,Xk",
|
|
allowedCountries: "de",
|
|
banResponse: "429",
|
|
limitBanDuration: "15m",
|
|
limitBanRepeatWindow: "2d",
|
|
maxBanDuration: "30d",
|
|
maxBans: "100",
|
|
banScopeV4Prefix: "24",
|
|
})
|
|
|
|
wantSettings(t, cfg, config.Config{
|
|
ListenAddr: "127.0.0.1:9000",
|
|
ClientRequestTimeout: 90 * time.Second,
|
|
ClientResponseTimeout: 7 * 24 * time.Hour,
|
|
UpstreamRequestTimeout: 90 * time.Minute,
|
|
UpstreamResponseTimeout: 0,
|
|
RequestMaxBytes: 512 << 10,
|
|
ResponseMaxBytes: 1234,
|
|
RateLimitPerMinute: 60,
|
|
RateLimitPerHour: 600,
|
|
RateLimitPerDay: 6000,
|
|
BanResponse: 429,
|
|
LimitBanDuration: 15 * time.Minute,
|
|
LimitBanRepeatWindow: 48 * time.Hour,
|
|
MaxBanDuration: 30 * 24 * time.Hour,
|
|
MaxBans: 100,
|
|
BanScopeV4Prefix: 24,
|
|
})
|
|
|
|
if cfg.UpstreamURL.String() != "https://app.internal:8443/" {
|
|
t.Errorf("%s is %s", upstreamURL, cfg.UpstreamURL)
|
|
}
|
|
|
|
wantNetblocks(t, cfg.TrustedProxies, "192.0.2.1/32", "10.0.0.0/8", "2001:db8::/32")
|
|
wantNetblocks(t, cfg.AllowNets, "192.0.2.7/32")
|
|
wantNetblocks(t, cfg.RateLimitExemptNets, "2001:db8::/48", "10.9.8.7/32")
|
|
wantNetblocks(t, cfg.DenyNets, "198.51.100.0/24")
|
|
wantCountries(t, deniedCountries, cfg.DeniedCountries, "CN", "RU", "KP", "XK")
|
|
wantCountries(t, allowedCountries, cfg.ExclusivelyAllowedCountries, "DE")
|
|
}
|
|
|
|
func TestCodeOnBothCountryListsStopsTheStart(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
_, err := config.FromEnvironment(environment{
|
|
deniedCountries: "cn,ru",
|
|
allowedCountries: "de,RU",
|
|
}.lookupEnv)
|
|
if err == nil {
|
|
t.Fatal("ru on both country lists was accepted")
|
|
}
|
|
|
|
if !strings.HasPrefix(err.Error(), allowedCountries+": ") ||
|
|
!strings.Contains(err.Error(), `"RU"`) {
|
|
t.Errorf("error %q does not name %s and RU", err, allowedCountries)
|
|
}
|
|
}
|
|
|
|
func TestSizesAndOff(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
cfg := fromEnvironment(t, environment{
|
|
requestMaxBytes: "3G",
|
|
responseMaxBytes: off,
|
|
clientRequestTimeout: off,
|
|
})
|
|
|
|
if cfg.RequestMaxBytes != 3<<30 || cfg.ResponseMaxBytes != 0 ||
|
|
cfg.ClientRequestTimeout != 0 {
|
|
t.Errorf("3G, off and off read as %d, %d and %s",
|
|
cfg.RequestMaxBytes, cfg.ResponseMaxBytes, cfg.ClientRequestTimeout)
|
|
}
|
|
}
|
|
|
|
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 TestBanResponseCloseIsZero(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
cfg := fromEnvironment(t, environment{banResponse: "close"})
|
|
if cfg.BanResponse != 0 {
|
|
t.Errorf("close read as %d, want 0", cfg.BanResponse)
|
|
}
|
|
}
|
|
|
|
func TestTrustedProxiesSetButEmptyTrustNothing(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
cfg := fromEnvironment(t, environment{trustedProxies: ""})
|
|
if len(cfg.TrustedProxies) != 0 {
|
|
t.Errorf("trusted proxies %v, want none", cfg.TrustedProxies)
|
|
}
|
|
}
|
|
|
|
func TestInvalidValueStopsTheStart(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
for _, tc := range []struct{ name, value string }{
|
|
{listenAddr, "8080"},
|
|
{listenAddr, ":http"},
|
|
{listenAddr, ":65536"},
|
|
{upstreamURL, "127.0.0.1:8081"},
|
|
{upstreamURL, "ftp://127.0.0.1:8081"},
|
|
{upstreamURL, "http://"},
|
|
{upstreamURL, "http://:8081"},
|
|
{upstreamURL, "http://127.0.0.1:0"},
|
|
{upstreamURL, "http://127.0.0.1:99999"},
|
|
{upstreamURL, "http://127.0.0.1:8081/app"},
|
|
{upstreamURL, "http://127.0.0.1:8081/?a=1"},
|
|
{upstreamURL, "http://user:secret@127.0.0.1:8081"},
|
|
{trustedProxies, "10.0.0.0/33"},
|
|
{trustedProxies, "traefik"},
|
|
{trustedProxies, "10.0.0.0/8,,192.168.0.0/16"},
|
|
{trustedProxies, "fe80::1%eth0"},
|
|
{allowNets, "192.0.2.0/24,monitoring"},
|
|
{rateLimitExemptNets, "2001:db8::/129"},
|
|
{denyNets, "198.51.100.0/24,"},
|
|
{clientRequestTimeout, "60"},
|
|
{clientRequestTimeout, ""},
|
|
{clientResponseTimeout, "1y"},
|
|
{upstreamRequestTimeout, "-1s"},
|
|
{upstreamResponseTimeout, "0s"},
|
|
{upstreamResponseTimeout, "1.5d"},
|
|
{requestMaxBytes, "100MB"},
|
|
{requestMaxBytes, "100m"},
|
|
{requestMaxBytes, "1.5M"},
|
|
{responseMaxBytes, "0"},
|
|
{responseMaxBytes, "-5"},
|
|
{responseMaxBytes, "99999999999G"},
|
|
{rateLimitPerMinute, ""},
|
|
{rateLimitPerMinute, "1K"},
|
|
{rateLimitPerHour, "0"},
|
|
{rateLimitPerHour, "1.5"},
|
|
{rateLimitPerDay, "-1"},
|
|
{rateLimitPerDay, "lots"},
|
|
{deniedCountries, "nk"},
|
|
{deniedCountries, "kp,,ir"},
|
|
{deniedCountries, "prk"},
|
|
{deniedCountries, "408"},
|
|
{deniedCountries, "k"},
|
|
{deniedCountries, "eu"},
|
|
{deniedCountries, "un"},
|
|
{deniedCountries, "su"},
|
|
{allowedCountries, "ac"},
|
|
{allowedCountries, "uk"},
|
|
{allowedCountries, "zz"},
|
|
{allowedCountries, "de,germany"},
|
|
{banResponse, "404"}, {banResponse, "drop"}, {banResponse, ""},
|
|
{limitBanDuration, off}, {limitBanDuration, "0s"}, {limitBanDuration, "1"},
|
|
{limitBanRepeatWindow, off}, {limitBanRepeatWindow, "-1h"},
|
|
{maxBanDuration, off}, {maxBanDuration, "1w"},
|
|
{maxBans, off}, {maxBans, "0"}, {maxBans, "5K"},
|
|
{banScopeV4Prefix, "33"}, {banScopeV4Prefix, "-1"}, {banScopeV4Prefix, "/24"},
|
|
} {
|
|
t.Run(tc.name+"="+tc.value, func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
_, err := config.FromEnvironment(environment{tc.name: tc.value}.lookupEnv)
|
|
if err == nil {
|
|
t.Fatalf("%s=%q was accepted", tc.name, tc.value)
|
|
}
|
|
|
|
if !strings.HasPrefix(err.Error(), tc.name+": ") {
|
|
t.Errorf("error %q does not name %s", err, tc.name)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
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)
|
|
}
|
|
|
|
want := map[string]string{
|
|
listenAddr: ":8080",
|
|
upstreamURL: "http://127.0.0.1:8081",
|
|
trustedProxies: "10.0.0.0/8,172.16.0.0/12,192.168.0.0/16",
|
|
clientRequestTimeout: "45s",
|
|
clientResponseTimeout: "30m",
|
|
upstreamRequestTimeout: "60s",
|
|
upstreamResponseTimeout: "30m",
|
|
requestMaxBytes: "100M",
|
|
responseMaxBytes: "5G",
|
|
allowNets: "",
|
|
rateLimitExemptNets: "",
|
|
denyNets: "",
|
|
rateLimitPerMinute: "1000",
|
|
rateLimitPerHour: "10000",
|
|
rateLimitPerDay: "50000",
|
|
deniedCountries: "",
|
|
allowedCountries: "",
|
|
banResponse: "403",
|
|
limitBanDuration: "1h",
|
|
limitBanRepeatWindow: "24h",
|
|
maxBanDuration: "7d",
|
|
maxBans: "5000",
|
|
banScopeV4Prefix: "32",
|
|
}
|
|
if !maps.Equal(line.Settings, want) {
|
|
t.Errorf("logged settings\n%v\nwant\n%v", line.Settings, want)
|
|
}
|
|
}
|
|
|
|
// wantSettings checks the settings that are plain values.
|
|
func wantSettings(t *testing.T, got *config.Config, want config.Config) {
|
|
t.Helper()
|
|
|
|
if got.ListenAddr != want.ListenAddr ||
|
|
got.ClientRequestTimeout != want.ClientRequestTimeout ||
|
|
got.ClientResponseTimeout != want.ClientResponseTimeout ||
|
|
got.UpstreamRequestTimeout != want.UpstreamRequestTimeout ||
|
|
got.UpstreamResponseTimeout != want.UpstreamResponseTimeout ||
|
|
got.RequestMaxBytes != want.RequestMaxBytes ||
|
|
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)
|
|
}
|
|
|
|
wantBanSettings(t, got, want)
|
|
}
|
|
|
|
// wantBanSettings checks the settings for bans.
|
|
func wantBanSettings(t *testing.T, got *config.Config, want config.Config) {
|
|
t.Helper()
|
|
|
|
if got.BanResponse != want.BanResponse ||
|
|
got.LimitBanDuration != want.LimitBanDuration ||
|
|
got.LimitBanRepeatWindow != want.LimitBanRepeatWindow ||
|
|
got.MaxBanDuration != want.MaxBanDuration ||
|
|
got.MaxBans != want.MaxBans ||
|
|
got.BanScopeV4Prefix != want.BanScopeV4Prefix {
|
|
t.Errorf("ban settings\n%+v\nwant\n%+v", got, want)
|
|
}
|
|
}
|
|
|
|
// wantNetblocks checks a list of netblocks.
|
|
func wantNetblocks(t *testing.T, got []netip.Prefix, want ...string) {
|
|
t.Helper()
|
|
|
|
gotText := make([]string, 0, len(got))
|
|
for _, netblock := range got {
|
|
gotText = append(gotText, netblock.String())
|
|
}
|
|
|
|
if !slices.Equal(gotText, want) {
|
|
t.Errorf("netblocks %v, want %v", gotText, want)
|
|
}
|
|
}
|
|
|
|
// wantCountries checks the list of countries the setting name gave.
|
|
func wantCountries(t *testing.T, name string, got []string, want ...string) {
|
|
t.Helper()
|
|
|
|
if !slices.Equal(got, want) {
|
|
t.Errorf("%s gave %v, want %v", name, got, want)
|
|
}
|
|
}
|