check / check (push) Waiting to run
SWWAF_WAF_BODY_LIMIT (default off, at most 1G) has the Core Rule Set read form data and multipart up to the limit, the rest streaming on, and JSON and XML no larger than it, with text/json and the application and text types ending in +json or +xml. The part read is held for the app. A size or time limit met while reading ends the request. Content-Encoding is refused again on these kinds. A body Coraza cannot parse, or a multipart body failing its strict checks, adds 5, as does a multipart body the limit cuts in a part's headers before a colon or a line feed. Coraza is built with no_fs_access, so writes no file. Rule 900300 moves to phase 2. Judgement call: Content-Encoding is refused on a JSON or XML body too large to read, as SPEC.md allows. Model: opus-5-5
2582 lines
85 KiB
Go
2582 lines
85 KiB
Go
package config_test
|
|
|
|
import (
|
|
"bytes"
|
|
"crypto/x509"
|
|
"encoding/json"
|
|
"log/slog"
|
|
"maps"
|
|
"net/http"
|
|
"net/netip"
|
|
"os"
|
|
"path/filepath"
|
|
"reflect"
|
|
"slices"
|
|
"strconv"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"sneak.berlin/go/smallwebwaf/internal/anomaly"
|
|
"sneak.berlin/go/smallwebwaf/internal/config"
|
|
)
|
|
|
|
// The settings, by name.
|
|
const (
|
|
listenAddr = "SWWAF_LISTEN_ADDR"
|
|
upstreamURL = "SWWAF_UPSTREAM_URL"
|
|
mode = "SWWAF_MODE"
|
|
trustedProxies = "SWWAF_TRUSTED_PROXIES"
|
|
ipv6GroupPrefix = "SWWAF_IPV6_GROUP_PREFIX"
|
|
maxTrackedClients = "SWWAF_MAX_TRACKED_CLIENTS"
|
|
clientRequestTimeout = "SWWAF_CLIENT_REQUEST_TIMEOUT"
|
|
clientHeaderMaxBytes = "SWWAF_CLIENT_REQUEST_HEADER_MAX_BYTES"
|
|
clientIdleTimeout = "SWWAF_CLIENT_IDLE_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"
|
|
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"
|
|
addLookupHeaders = "SWWAF_ADD_LOOKUP_HEADERS"
|
|
deniedCountries = "SWWAF_DENIED_COUNTRIES"
|
|
allowedCountries = "SWWAF_EXCLUSIVELY_ALLOWED_COUNTRIES"
|
|
asnLimitPercent = "SWWAF_ASN_LIMIT_PERCENT"
|
|
countryLimitPercent = "SWWAF_COUNTRY_LIMIT_PERCENT"
|
|
asnBytesPercent = "SWWAF_ASN_BYTES_PERCENT"
|
|
countryBytesPercent = "SWWAF_COUNTRY_BYTES_PERCENT"
|
|
unknownLimitPercent = "SWWAF_UNKNOWN_LIMIT_PERCENT"
|
|
asnLimitPercentURL = "SWWAF_ASN_LIMIT_PERCENT_URL"
|
|
blocklistURLs = "SWWAF_BLOCKLIST_URLS"
|
|
blocklistRefresh = "SWWAF_BLOCKLIST_REFRESH"
|
|
blocklistAction = "SWWAF_BLOCKLIST_ACTION"
|
|
dnsblZones = "SWWAF_DNSBL_ZONES"
|
|
dnsblResolver = "SWWAF_DNSBL_RESOLVER"
|
|
abuseIPDBKey = "SWWAF_ABUSEIPDB_KEY"
|
|
abuseIPDBMinScore = "SWWAF_ABUSEIPDB_MIN_SCORE"
|
|
abuseIPDBDailyBudget = "SWWAF_ABUSEIPDB_DAILY_BUDGET"
|
|
reputationAction = "SWWAF_REPUTATION_ACTION"
|
|
reputationCacheTTL = "SWWAF_REPUTATION_CACHE_TTL"
|
|
reputationTimeout = "SWWAF_REPUTATION_TIMEOUT"
|
|
crowdSecURL = "SWWAF_CROWDSEC_LAPI_URL"
|
|
crowdSecKey = "SWWAF_CROWDSEC_LAPI_KEY"
|
|
banResponse = "SWWAF_BAN_RESPONSE"
|
|
limitBanDuration = "SWWAF_LIMIT_BAN_DURATION"
|
|
limitBanRepeatWindow = "SWWAF_LIMIT_BAN_REPEAT_WINDOW"
|
|
maxBanDuration = "SWWAF_MAX_BAN_DURATION"
|
|
attackBanDuration = "SWWAF_ATTACK_BAN_DURATION"
|
|
maxBans = "SWWAF_MAX_BANS"
|
|
banScopeV4Prefix = "SWWAF_BAN_SCOPE_V4_PREFIX"
|
|
stateDir = "SWWAF_STATE_DIR"
|
|
stateWriteDelay = "SWWAF_STATE_WRITE_DELAY"
|
|
stateCounterInterval = "SWWAF_STATE_COUNTER_INTERVAL"
|
|
adminToken = "SWWAF_ADMIN_TOKEN" //nolint:gosec // the setting's name
|
|
metricsToken = "SWWAF_METRICS_TOKEN" //nolint:gosec // the setting's name
|
|
metricsTopN = "SWWAF_METRICS_TOP_N"
|
|
instanceName = "SWWAF_INSTANCE_NAME"
|
|
logRequestHeaders = "SWWAF_LOG_REQUEST_HEADERS"
|
|
logLevel = "SWWAF_LOG_LEVEL"
|
|
rulesDir = "SWWAF_RULES_DIR"
|
|
rulesEnabled = "SWWAF_RULES_ENABLED"
|
|
wafMode = "SWWAF_WAF_MODE"
|
|
wafParanoiaLevel = "SWWAF_WAF_PARANOIA_LEVEL"
|
|
wafAnomalyThreshold = "SWWAF_WAF_ANOMALY_THRESHOLD"
|
|
wafDisabledRules = "SWWAF_WAF_DISABLED_RULES"
|
|
wafExemptPaths = "SWWAF_WAF_EXEMPT_PATHS"
|
|
wafBodyLimit = "SWWAF_WAF_BODY_LIMIT"
|
|
trapPaths = "SWWAF_TRAP_PATHS"
|
|
errorBurstThreshold = "SWWAF_ERROR_BURST_THRESHOLD"
|
|
logRemoteURL = "SWWAF_LOG_REMOTE_URL"
|
|
logRemoteTLSCAFile = "SWWAF_LOG_REMOTE_TLS_CA_FILE"
|
|
logRemoteBuffer = "SWWAF_LOG_REMOTE_BUFFER"
|
|
logRemoteFacility = "SWWAF_LOG_REMOTE_FACILITY"
|
|
logRemoteAppName = "SWWAF_LOG_REMOTE_APP_NAME"
|
|
alertWebhookURL = "SWWAF_ALERT_WEBHOOK_URL"
|
|
alertWebhookHeaders = "SWWAF_ALERT_WEBHOOK_HEADERS"
|
|
alertSlackWebhookURL = "SWWAF_ALERT_SLACK_WEBHOOK_URL"
|
|
alertNtfyURL = "SWWAF_ALERT_NTFY_URL"
|
|
alertNtfyToken = "SWWAF_ALERT_NTFY_TOKEN" //nolint:gosec // the setting's name
|
|
alertEvents = "SWWAF_ALERT_EVENTS"
|
|
alertCooldown = "SWWAF_ALERT_COOLDOWN"
|
|
alertMaxPerHour = "SWWAF_ALERT_MAX_PER_HOUR"
|
|
anomalyNetV4Prefix = "SWWAF_ANOMALY_NET_V4_PREFIX"
|
|
anomalyNetV6Prefix = "SWWAF_ANOMALY_NET_V6_PREFIX"
|
|
watchNets = "SWWAF_WATCH_NETS"
|
|
)
|
|
|
|
// The anomaly thresholds: each of the prefixes below, which name a scope,
|
|
// followed by each of the four ends.
|
|
const (
|
|
anomalyClient = "SWWAF_ANOMALY_CLIENT_"
|
|
anomalyNet = "SWWAF_ANOMALY_NET_"
|
|
anomalyASN = "SWWAF_ANOMALY_ASN_"
|
|
anomalyTotal = "SWWAF_ANOMALY_TOTAL_"
|
|
watch = "SWWAF_WATCH_"
|
|
|
|
requestsPerMinute = "REQUESTS_PER_MINUTE"
|
|
requestsPerHour = "REQUESTS_PER_HOUR"
|
|
bytesPerMinute = "BYTES_PER_MINUTE"
|
|
bytesPerHour = "BYTES_PER_HOUR"
|
|
)
|
|
|
|
// anomalyScopes returns the prefixes of the anomaly thresholds, one for
|
|
// each scope.
|
|
func anomalyScopes() []string {
|
|
return []string{anomalyClient, anomalyNet, anomalyASN, anomalyTotal, watch}
|
|
}
|
|
|
|
// anomalyThresholds returns the names of the twenty anomaly thresholds.
|
|
func anomalyThresholds() []string {
|
|
ends := []string{requestsPerMinute, requestsPerHour, bytesPerMinute, bytesPerHour}
|
|
names := make([]string, 0, len(anomalyScopes())*len(ends))
|
|
|
|
for _, scope := range anomalyScopes() {
|
|
for _, end := range ends {
|
|
names = append(names, scope+end)
|
|
}
|
|
}
|
|
|
|
return names
|
|
}
|
|
|
|
// loggedAnomalyDefaults returns the anomaly settings as the settings
|
|
// logged at start give them by default.
|
|
func loggedAnomalyDefaults() map[string]string {
|
|
logged := map[string]string{
|
|
anomalyNetV4Prefix: "24", anomalyNetV6Prefix: "48", watchNets: "",
|
|
}
|
|
for _, name := range anomalyThresholds() {
|
|
logged[name] = off
|
|
}
|
|
|
|
return logged
|
|
}
|
|
|
|
// defaultAlertEvents is the default of SWWAF_ALERT_EVENTS, and
|
|
// defaultAlertCooldown that of SWWAF_ALERT_COOLDOWN.
|
|
const (
|
|
defaultAlertEvents = "ban,permanent_ban,waf_block,anomaly,reputation_hit," +
|
|
"source_failure,file_error"
|
|
defaultAlertCooldown = "15m"
|
|
)
|
|
|
|
// defaultReputationCacheTTL is the default of SWWAF_REPUTATION_CACHE_TTL.
|
|
const defaultReputationCacheTTL = "24h"
|
|
|
|
// defaultWAFDisabledRules is the default of SWWAF_WAF_DISABLED_RULES.
|
|
const defaultWAFDisabledRules = "920340,920420,920440,920640,930130,930140"
|
|
|
|
// defaultLogRequestHeaders is the default of SWWAF_LOG_REQUEST_HEADERS.
|
|
const defaultLogRequestHeaders = "accept,accept-language,accept-encoding," +
|
|
"content-type,origin,range"
|
|
|
|
// testCA is a CA certificate, of which only that it reads matters here.
|
|
const testCA = `-----BEGIN CERTIFICATE-----
|
|
MIIBkzCCATmgAwIBAgIUeySaE27dnr6A2HijrMB13gTLUKIwCgYIKoZIzj0EAwIw
|
|
HjEcMBoGA1UEAwwTc21hbGx3ZWJ3YWYgdGVzdCBDQTAgFw0yNjEwMDYxNDI2MTNa
|
|
GA8yMTI2MDkxMjE0MjYxM1owHjEcMBoGA1UEAwwTc21hbGx3ZWJ3YWYgdGVzdCBD
|
|
QTBZMBMGByqGSM49AgEGCCqGSM49AwEHA0IABKDEhcWKKhet2KgSdME+iEPxyEyn
|
|
2sd9IdElbt8DM2SfCdB2JsXo0C07UNZaywMPMfn/n8LNI/PKwu+N2uX7gfSjUzBR
|
|
MB0GA1UdDgQWBBRZo3BPLv0KbV4drw6JI1JIUKmoRzAfBgNVHSMEGDAWgBRZo3BP
|
|
Lv0KbV4drw6JI1JIUKmoRzAPBgNVHRMBAf8EBTADAQH/MAoGCCqGSM49BAMCA0gA
|
|
MEUCICC5k+76UpWoSwVbZA+atu5WcALEOJGqwOUWua3zemhcAiEA8Hfdxgwp0z2v
|
|
rlG9y/jrJb6ORy3kTLWo2EA0BA67vuI=
|
|
-----END CERTIFICATE-----
|
|
`
|
|
|
|
// token is a token of 32 characters, the shortest allowed, and
|
|
// otherToken another.
|
|
const (
|
|
token = "0123456789abcdef0123456789abcdef"
|
|
otherToken = "fedcba9876543210fedcba9876543210"
|
|
)
|
|
|
|
// instance is an SWWAF_INSTANCE_NAME that is a valid app name too, and
|
|
// remoteURL an SWWAF_LOG_REMOTE_URL, for the tests that send the lines.
|
|
const (
|
|
instance = "fsn1app1/gitea"
|
|
remoteURL = "syslog+udp://192.0.2.1:514"
|
|
)
|
|
|
|
// off switches a timeout, a size limit or a rate limit off.
|
|
const off = "off"
|
|
|
|
// enabled is true, as a setting's value.
|
|
const enabled = "true"
|
|
|
|
// defaultLookupSource is the default of SWWAF_LOOKUP_SOURCE, and
|
|
// fileSource the source that is the lookup database.
|
|
const (
|
|
defaultLookupSource = "geojs"
|
|
fileSource = "file"
|
|
)
|
|
|
|
// 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",
|
|
Observe: false,
|
|
ClientRequestTimeout: time.Minute,
|
|
ClientRequestHeaderMaxBytes: 32 << 10,
|
|
ClientIdleTimeout: 2 * 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,
|
|
AttackBanDuration: 7 * 24 * time.Hour,
|
|
MaxBans: 5000,
|
|
BanScopeV4Prefix: 32,
|
|
StateDir: "/var/lib/smallwebwaf",
|
|
StateWriteDelay: 10 * time.Second,
|
|
StateCounterInterval: 15 * time.Minute,
|
|
MetricsToken: "",
|
|
MetricsTopN: 50,
|
|
RulesDir: "/etc/smallwebwaf/rules.d",
|
|
RulesEnabled: true,
|
|
})
|
|
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)
|
|
}
|
|
|
|
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)
|
|
|
|
hostname, err := os.Hostname()
|
|
if err != nil || hostname == "" || cfg.InstanceName != hostname {
|
|
t.Errorf("%s is %q, want the host's name %q (%v)", instanceName,
|
|
cfg.InstanceName, hostname, err)
|
|
}
|
|
|
|
wantHeaders := strings.Split(defaultLogRequestHeaders, ",")
|
|
if !slices.Equal(cfg.LogRequestHeaders, wantHeaders) {
|
|
t.Errorf("%s gave %v, want %v", logRequestHeaders, cfg.LogRequestHeaders,
|
|
wantHeaders)
|
|
}
|
|
|
|
if len(cfg.RateLimitExemptPaths) != 0 {
|
|
t.Errorf("%s gave %v, want none", rateLimitExemptPaths, cfg.RateLimitExemptPaths)
|
|
}
|
|
}
|
|
|
|
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()
|
|
|
|
cfg := fromEnvironment(t, environment{
|
|
listenAddr: "127.0.0.1:9000",
|
|
upstreamURL: "https://app.internal:8443/",
|
|
mode: "observe",
|
|
trustedProxies: " 192.0.2.1, 10.1.2.3/8 ,2001:db8::/32",
|
|
clientRequestTimeout: "90s",
|
|
clientHeaderMaxBytes: "8K",
|
|
clientIdleTimeout: "5m",
|
|
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",
|
|
attackBanDuration: "1d",
|
|
maxBans: "100",
|
|
banScopeV4Prefix: "24",
|
|
stateDir: "/srv/waf-state",
|
|
stateWriteDelay: "500ms",
|
|
stateCounterInterval: "1h",
|
|
metricsToken: token,
|
|
metricsTopN: "10",
|
|
rulesDir: "/srv/waf-rules",
|
|
rulesEnabled: "false",
|
|
})
|
|
|
|
wantSettings(t, cfg, config.Config{
|
|
ListenAddr: "127.0.0.1:9000",
|
|
Observe: true,
|
|
ClientRequestTimeout: 90 * time.Second,
|
|
ClientRequestHeaderMaxBytes: 8 << 10,
|
|
ClientIdleTimeout: 5 * time.Minute,
|
|
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,
|
|
AttackBanDuration: 24 * time.Hour,
|
|
MaxBans: 100,
|
|
BanScopeV4Prefix: 24,
|
|
StateDir: "/srv/waf-state",
|
|
StateWriteDelay: 500 * time.Millisecond,
|
|
StateCounterInterval: time.Hour,
|
|
MetricsToken: token,
|
|
MetricsTopN: 10,
|
|
RulesDir: "/srv/waf-rules",
|
|
RulesEnabled: false,
|
|
})
|
|
|
|
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 TestIPv6GroupPrefixMaxTrackedClientsAndLogLevel(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
for _, tc := range []struct {
|
|
env environment
|
|
prefix, clients int
|
|
level slog.Level
|
|
}{
|
|
{environment{}, 64, 20000, slog.LevelInfo},
|
|
{
|
|
environment{ipv6GroupPrefix: "48", maxTrackedClients: "500", logLevel: "warn"},
|
|
48, 500, slog.LevelWarn,
|
|
},
|
|
} {
|
|
cfg := fromEnvironment(t, tc.env)
|
|
if cfg.IPv6GroupPrefix != tc.prefix || cfg.MaxTrackedClients != tc.clients ||
|
|
cfg.LogLevel != tc.level {
|
|
t.Errorf("%v gave %d, %d and %v, want %d, %d and %v", tc.env,
|
|
cfg.IPv6GroupPrefix, cfg.MaxTrackedClients, cfg.LogLevel,
|
|
tc.prefix, tc.clients, tc.level)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestIPv6GroupPrefixFrom32To128(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
for _, length := range []int{32, 128} {
|
|
cfg := fromEnvironment(t, environment{ipv6GroupPrefix: strconv.Itoa(length)})
|
|
if cfg.IPv6GroupPrefix != length {
|
|
t.Errorf("%s=%d gave %d", ipv6GroupPrefix, length, cfg.IPv6GroupPrefix)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestEachLogLevel(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
for value, want := range map[string]slog.Level{
|
|
"debug": slog.LevelDebug,
|
|
"info": slog.LevelInfo,
|
|
"warn": slog.LevelWarn,
|
|
"error": slog.LevelError,
|
|
} {
|
|
cfg := fromEnvironment(t, environment{logLevel: value})
|
|
if cfg.LogLevel != want {
|
|
t.Errorf("%s=%s gave %v, want %v", logLevel, value, cfg.LogLevel, want)
|
|
}
|
|
}
|
|
}
|
|
|
|
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()
|
|
|
|
cfg := fromEnvironment(t, environment{rateLimitExemptPaths: "/assets/, /favicon.ico"})
|
|
|
|
if !slices.Equal(cfg.RateLimitExemptPaths, []string{"/assets/", "/favicon.ico"}) {
|
|
t.Errorf("%s gave %v, want /assets/ and /favicon.ico",
|
|
rateLimitExemptPaths, cfg.RateLimitExemptPaths)
|
|
}
|
|
}
|
|
|
|
func TestPathPrefixNotStartingWithSlashStopsTheStart(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
_, err := config.FromEnvironment(
|
|
environment{rateLimitExemptPaths: "/favicon.ico,assets/"}.lookupEnv)
|
|
|
|
want := rateLimitExemptPaths + `: "assets/" is not a path prefix ` +
|
|
`starting with /, such as /assets/`
|
|
if err == nil || err.Error() != want {
|
|
t.Errorf("error %v, want %s", err, want)
|
|
}
|
|
}
|
|
|
|
func TestTrapPathsAndErrorBurstThreshold(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
for _, tc := range []struct {
|
|
env environment
|
|
paths []string
|
|
threshold int64
|
|
}{
|
|
{environment{}, []string{}, 30},
|
|
{
|
|
environment{trapPaths: "/wp-login.php, /xmlrpc.php", errorBurstThreshold: "5"},
|
|
[]string{"/wp-login.php", "/xmlrpc.php"}, 5,
|
|
},
|
|
{environment{errorBurstThreshold: off}, []string{}, 0},
|
|
} {
|
|
cfg := fromEnvironment(t, tc.env)
|
|
if !slices.Equal(cfg.TrapPaths, tc.paths) ||
|
|
cfg.ErrorBurstThreshold != tc.threshold {
|
|
t.Errorf("%v gave %v and %d, want %v and %d", tc.env, cfg.TrapPaths,
|
|
cfg.ErrorBurstThreshold, tc.paths, tc.threshold)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestInvalidTrapPathOrErrorBurstThresholdStopsTheStart(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
const notTrapPath = " is not a path starting with / and without a ?, " +
|
|
"such as /wp-login.php"
|
|
|
|
for _, tc := range []struct{ name, value, want string }{
|
|
{trapPaths, "/wp-login.php,xmlrpc.php", `"xmlrpc.php"` + notTrapPath},
|
|
{trapPaths, "/xmlrpc.php?rsd", `"/xmlrpc.php?rsd"` + notTrapPath},
|
|
{
|
|
trapPaths, "/wp-login.php,,/xmlrpc.php",
|
|
`"/wp-login.php,,/xmlrpc.php" has an empty item in its list`,
|
|
},
|
|
{errorBurstThreshold, "0", `"0" must be more than zero, or off`},
|
|
{
|
|
errorBurstThreshold, "30/min",
|
|
`"30/min" is not a whole number of requests such as 1000, or off`,
|
|
},
|
|
} {
|
|
t.Run(tc.name+"="+tc.value, func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
_, err := config.FromEnvironment(environment{tc.name: tc.value}.lookupEnv)
|
|
|
|
want := tc.name + ": " + tc.want
|
|
if err == nil || err.Error() != want {
|
|
t.Errorf("error %v, want %s", err, want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestCoreRuleSetSettings(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
for _, tc := range []struct {
|
|
env environment
|
|
want config.Config
|
|
}{
|
|
{
|
|
environment{},
|
|
config.Config{
|
|
WAFMode: config.WAFModeBlock, WAFParanoiaLevel: 1, WAFAnomalyThreshold: 5,
|
|
WAFDisabledRules: []int{920340, 920420, 920440, 920640, 930130, 930140},
|
|
WAFExemptPaths: []string{},
|
|
},
|
|
},
|
|
{
|
|
environment{
|
|
wafMode: config.WAFModeDetect, wafParanoiaLevel: "4", wafAnomalyThreshold: "10",
|
|
wafDisabledRules: "942100, 920350", wafExemptPaths: "/api/, /static/",
|
|
wafBodyLimit: "128K",
|
|
},
|
|
config.Config{
|
|
WAFMode: config.WAFModeDetect, WAFParanoiaLevel: 4, WAFAnomalyThreshold: 10,
|
|
WAFDisabledRules: []int{942100, 920350},
|
|
WAFExemptPaths: []string{"/api/", "/static/"},
|
|
WAFBodyLimit: 128 << 10,
|
|
},
|
|
},
|
|
{
|
|
environment{
|
|
wafMode: off, wafAnomalyThreshold: off, wafDisabledRules: "",
|
|
wafBodyLimit: off,
|
|
},
|
|
config.Config{
|
|
WAFMode: config.WAFModeOff, WAFParanoiaLevel: 1, WAFAnomalyThreshold: 0,
|
|
WAFDisabledRules: []int{}, WAFExemptPaths: []string{},
|
|
},
|
|
},
|
|
} {
|
|
cfg := fromEnvironment(t, tc.env)
|
|
|
|
got := config.Config{
|
|
WAFMode: cfg.WAFMode, WAFParanoiaLevel: cfg.WAFParanoiaLevel,
|
|
WAFAnomalyThreshold: cfg.WAFAnomalyThreshold,
|
|
WAFDisabledRules: cfg.WAFDisabledRules, WAFExemptPaths: cfg.WAFExemptPaths,
|
|
WAFBodyLimit: cfg.WAFBodyLimit,
|
|
}
|
|
if !reflect.DeepEqual(got, tc.want) {
|
|
t.Errorf("%v gave\n%+v\nwant\n%+v", tc.env, got, tc.want)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestWAFBodyLimitOf1G(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
cfg := fromEnvironment(t, environment{wafBodyLimit: "1G"})
|
|
if cfg.WAFBodyLimit != 1<<30 {
|
|
t.Errorf("1G read as %d", cfg.WAFBodyLimit)
|
|
}
|
|
}
|
|
|
|
func TestInvalidCoreRuleSetSettingStopsTheStart(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
const (
|
|
notParanoiaLevel = " is not a paranoia level, from 1 to 4"
|
|
setupRule = " is from 900000 to 900999, the ids of the rules that set " +
|
|
"the Core Rule Set up and of smallwebwaf's own, which cannot be switched off"
|
|
)
|
|
|
|
for _, tc := range []struct{ name, value, want string }{
|
|
{wafMode, "enforce", `"enforce" is not off, detect or block`},
|
|
{wafParanoiaLevel, "0", `"0"` + notParanoiaLevel},
|
|
{wafParanoiaLevel, "5", `"5"` + notParanoiaLevel},
|
|
{wafParanoiaLevel, off, `"off"` + notParanoiaLevel},
|
|
{
|
|
wafAnomalyThreshold, "0",
|
|
`"0" is not a whole number above zero, such as 60, or off`,
|
|
},
|
|
{
|
|
wafDisabledRules, "920340,REQUEST-920",
|
|
`"REQUEST-920" is not the id of a Core Rule Set rule, ` +
|
|
`a whole number such as 942100`,
|
|
},
|
|
{
|
|
wafDisabledRules, "-942100",
|
|
`"-942100" is not the id of a Core Rule Set rule, ` +
|
|
`a whole number such as 942100`,
|
|
},
|
|
// The paranoia level, the allowed methods, the headers refused, a
|
|
// request with more query parameters than Coraza keeps, and a body
|
|
// Coraza cannot parse or that fails its strict checks.
|
|
{wafDisabledRules, "942100,900000", `"900000"` + setupRule},
|
|
{wafDisabledRules, "942100,900200", `"900200"` + setupRule},
|
|
{wafDisabledRules, "942100,900250", `"900250"` + setupRule},
|
|
{wafDisabledRules, "942100,900300", `"900300"` + setupRule},
|
|
{wafDisabledRules, "942100,900440", `"900440"` + setupRule},
|
|
{wafDisabledRules, "942100,900450", `"900450"` + setupRule},
|
|
{
|
|
wafExemptPaths, "api/",
|
|
`"api/" is not a path prefix starting with /, such as /assets/`,
|
|
},
|
|
{
|
|
wafBodyLimit, "128KB",
|
|
`"128KB" is not a size such as 512K, 100M or 5G, or off`,
|
|
},
|
|
{wafBodyLimit, "2G", `"2G" is more than 1G, the most Coraza reads`},
|
|
{
|
|
wafBodyLimit, "1073741825",
|
|
`"1073741825" is more than 1G, the most Coraza reads`,
|
|
},
|
|
} {
|
|
t.Run(tc.name+"="+tc.value, func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
_, err := config.FromEnvironment(environment{tc.name: tc.value}.lookupEnv)
|
|
|
|
want := tc.name + ": " + tc.want
|
|
if err == nil || err.Error() != want {
|
|
t.Errorf("error %v, want %s", err, want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestInstanceNameAndLoggedHeadersAsSet(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
cfg := fromEnvironment(t, environment{
|
|
instanceName: "fsn1app1/gitea",
|
|
logRequestHeaders: " Accept , X-Custom",
|
|
})
|
|
|
|
if cfg.InstanceName != "fsn1app1/gitea" ||
|
|
!slices.Equal(cfg.LogRequestHeaders, []string{"accept", "x-custom"}) {
|
|
t.Errorf("%s is %q and %s gives %v", instanceName, cfg.InstanceName,
|
|
logRequestHeaders, cfg.LogRequestHeaders)
|
|
}
|
|
}
|
|
|
|
func TestRemoteLogSettingsDefaults(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
cfg := fromEnvironment(t, environment{instanceName: instance})
|
|
|
|
if cfg.LogRemoteURL != nil || cfg.LogRemoteTLSCAs != nil ||
|
|
cfg.LogRemoteBuffer != 10000 || cfg.LogRemoteFacility != 16 ||
|
|
cfg.LogRemoteAppName != instance {
|
|
t.Errorf("remote log settings %v, %v, %d, %d and %q, want no URL, no "+
|
|
"certificates, 10000, 16 and %s's %s", cfg.LogRemoteURL,
|
|
cfg.LogRemoteTLSCAs, cfg.LogRemoteBuffer, cfg.LogRemoteFacility,
|
|
cfg.LogRemoteAppName, instanceName, instance)
|
|
}
|
|
}
|
|
|
|
func TestRemoteLogSettingsAsSet(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
caFile := filepath.Join(t.TempDir(), "ca.pem")
|
|
|
|
err := os.WriteFile(caFile, []byte(testCA), 0o600)
|
|
if err != nil {
|
|
t.Fatalf("write %s: %v", caFile, err)
|
|
}
|
|
|
|
cfg := fromEnvironment(t, environment{
|
|
logRemoteURL: "syslog+tls://logs.example:6514",
|
|
logRemoteTLSCAFile: caFile,
|
|
logRemoteBuffer: "500",
|
|
logRemoteFacility: "daemon",
|
|
logRemoteAppName: instance,
|
|
})
|
|
|
|
roots := x509.NewCertPool()
|
|
roots.AppendCertsFromPEM([]byte(testCA))
|
|
|
|
if cfg.LogRemoteURL.String() != "syslog+tls://logs.example:6514" ||
|
|
!roots.Equal(cfg.LogRemoteTLSCAs) || cfg.LogRemoteBuffer != 500 ||
|
|
cfg.LogRemoteFacility != 3 || cfg.LogRemoteAppName != instance {
|
|
t.Errorf("remote log settings %v, %v, %d, %d and %q", cfg.LogRemoteURL,
|
|
cfg.LogRemoteTLSCAs, cfg.LogRemoteBuffer, cfg.LogRemoteFacility,
|
|
cfg.LogRemoteAppName)
|
|
}
|
|
}
|
|
|
|
func TestRemoteLogURLForms(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
for _, value := range []string{
|
|
"syslog+udp://192.0.2.1:514",
|
|
"syslog+tcp://[2001:db8::1]:514",
|
|
"syslog+tls://logs.example:6514/",
|
|
} {
|
|
cfg := fromEnvironment(t, environment{logRemoteURL: value})
|
|
if cfg.LogRemoteURL.String() != value {
|
|
t.Errorf("%s read as %v", value, cfg.LogRemoteURL)
|
|
}
|
|
}
|
|
|
|
cfg := fromEnvironment(t, environment{logRemoteURL: ""})
|
|
if cfg.LogRemoteURL != nil {
|
|
t.Errorf("set but empty, %s read as %v", logRemoteURL, cfg.LogRemoteURL)
|
|
}
|
|
}
|
|
|
|
func TestRemoteLogFacilitiesByNumber(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
for name, number := range map[string]int{
|
|
"kern": 0, "user": 1, "auth": 4, "authpriv": 10, "ftp": 11,
|
|
"local0": 16, "local5": 21, "local7": 23,
|
|
} {
|
|
cfg := fromEnvironment(t, environment{logRemoteFacility: name})
|
|
if cfg.LogRemoteFacility != number {
|
|
t.Errorf("%s read as %d, want %d", name, cfg.LogRemoteFacility, number)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestInvalidRemoteLogSettingStopsTheStart(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
for _, tc := range []struct{ name, value string }{
|
|
{logRemoteURL, "logs.example:514"},
|
|
{logRemoteURL, "syslog://logs.example:514"},
|
|
{logRemoteURL, "http://logs.example:514"},
|
|
{logRemoteURL, "syslog+udp://logs.example"},
|
|
{logRemoteURL, "syslog+tcp://:514"},
|
|
{logRemoteURL, "syslog+tcp://logs.example:0"},
|
|
{logRemoteURL, "syslog+tls://logs.example:65536"},
|
|
{logRemoteURL, "syslog+tls://user@logs.example:6514"},
|
|
{logRemoteURL, "syslog+tcp://logs.example:514/app"},
|
|
{logRemoteURL, "syslog+tcp://logs.example:514?tls=1"},
|
|
{logRemoteTLSCAFile, "/nonexistent/ca.pem"},
|
|
{logRemoteBuffer, off}, {logRemoteBuffer, "0"}, {logRemoteBuffer, "10K"},
|
|
{logRemoteFacility, "local8"}, {logRemoteFacility, "LOCAL0"},
|
|
{logRemoteFacility, "16"}, {logRemoteFacility, ""},
|
|
{logRemoteAppName, ""}, {logRemoteAppName, "my app"},
|
|
{logRemoteAppName, "gitéa"}, {logRemoteAppName, strings.Repeat("a", 49)},
|
|
} {
|
|
_, err := config.FromEnvironment(environment{tc.name: tc.value}.lookupEnv)
|
|
if err == nil || !strings.HasPrefix(err.Error(), tc.name+": ") {
|
|
t.Errorf("%s=%q: error %v, want one naming it", tc.name, tc.value, err)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestRemoteLogCAFileWithoutCertificateStopsTheStart(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
caFile := filepath.Join(t.TempDir(), "ca.pem")
|
|
|
|
err := os.WriteFile(caFile, []byte("not a certificate\n"), 0o600)
|
|
if err != nil {
|
|
t.Fatalf("write %s: %v", caFile, err)
|
|
}
|
|
|
|
_, err = config.FromEnvironment(environment{logRemoteTLSCAFile: caFile}.lookupEnv)
|
|
|
|
want := logRemoteTLSCAFile + `: "` + caFile + `" holds no PEM certificate`
|
|
if err == nil || err.Error() != want {
|
|
t.Errorf("error %v, want %s", err, want)
|
|
}
|
|
}
|
|
|
|
func TestInstanceNameNotAnAppNameStopsTheStartOnlyWhileSending(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
const spaced = "fsn1 app1"
|
|
|
|
sending := environment{logRemoteURL: remoteURL, instanceName: spaced}
|
|
|
|
_, err := config.FromEnvironment(sending.lookupEnv)
|
|
|
|
want := logRemoteAppName + `: is unset, and ` + instanceName +
|
|
` "fsn1 app1", its default, is not 1 to 48 printable ASCII characters ` +
|
|
`without a space, such as gitea`
|
|
if err == nil || err.Error() != want {
|
|
t.Errorf("error %v, want %s", err, want)
|
|
}
|
|
|
|
cfg := fromEnvironment(t, environment{instanceName: spaced})
|
|
if cfg.LogRemoteAppName != spaced {
|
|
t.Errorf("not sending, %s is %q", logRemoteAppName, cfg.LogRemoteAppName)
|
|
}
|
|
|
|
sending[logRemoteAppName] = instance
|
|
|
|
cfg = fromEnvironment(t, sending)
|
|
if cfg.LogRemoteAppName != instance {
|
|
t.Errorf("set to %s, %s is %q", instance, logRemoteAppName,
|
|
cfg.LogRemoteAppName)
|
|
}
|
|
}
|
|
|
|
func TestAppNameSetStopsTheStartWhileSending(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
_, err := config.FromEnvironment(environment{
|
|
logRemoteURL: remoteURL,
|
|
instanceName: instance,
|
|
logRemoteAppName: "my app",
|
|
}.lookupEnv)
|
|
|
|
want := logRemoteAppName + `: "my app" is not 1 to 48 printable ASCII ` +
|
|
`characters without a space, such as gitea`
|
|
if err == nil || err.Error() != want {
|
|
t.Errorf("error %v, want %s", err, want)
|
|
}
|
|
}
|
|
|
|
func TestAlertSettingsDefaults(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
cfg := fromEnvironment(t, environment{})
|
|
|
|
if cfg.AlertWebhookURL != nil || len(cfg.AlertWebhookHeaders) != 0 ||
|
|
cfg.AlertSlackWebhookURL != nil || cfg.AlertNtfyURL != nil ||
|
|
cfg.AlertNtfyToken != "" ||
|
|
strings.Join(cfg.AlertEvents, ",") != defaultAlertEvents ||
|
|
cfg.AlertCooldown != 15*time.Minute || cfg.AlertMaxPerHour != 60 {
|
|
t.Errorf("alert settings %v, %v, %v, %v, %q, %v, %s and %d, want no URLs, "+
|
|
"no headers, no token, %s, 15m and 60", cfg.AlertWebhookURL,
|
|
cfg.AlertWebhookHeaders, cfg.AlertSlackWebhookURL, cfg.AlertNtfyURL,
|
|
cfg.AlertNtfyToken, cfg.AlertEvents, cfg.AlertCooldown, cfg.AlertMaxPerHour,
|
|
defaultAlertEvents)
|
|
}
|
|
}
|
|
|
|
func TestAlertSettingsAsSet(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
const (
|
|
webhook = "https://alerts.example:8443/hooks/waf?team=ops"
|
|
slack = "https://hooks.slack.example/services/T0123/B4567/abcdef"
|
|
ntfy = "https://ntfy.example/smallwebwaf-alerts"
|
|
token = "tk_0123456789abcdefghijklmnopq"
|
|
)
|
|
|
|
cfg := fromEnvironment(t, environment{
|
|
alertWebhookURL: webhook,
|
|
alertWebhookHeaders: "Authorization: Bearer abc:def , x-team:ops",
|
|
alertSlackWebhookURL: slack,
|
|
alertNtfyURL: ntfy,
|
|
alertNtfyToken: token,
|
|
alertEvents: "ban, file_error",
|
|
alertCooldown: "1h",
|
|
alertMaxPerHour: "10",
|
|
})
|
|
|
|
headers := http.Header{"Authorization": {"Bearer abc:def"}, "X-Team": {"ops"}}
|
|
if cfg.AlertWebhookURL.String() != webhook ||
|
|
!reflect.DeepEqual(cfg.AlertWebhookHeaders, headers) ||
|
|
cfg.AlertSlackWebhookURL.String() != slack || cfg.AlertNtfyURL.String() != ntfy ||
|
|
cfg.AlertNtfyToken != token ||
|
|
!slices.Equal(cfg.AlertEvents, []string{"ban", "file_error"}) ||
|
|
cfg.AlertCooldown != time.Hour || cfg.AlertMaxPerHour != 10 {
|
|
t.Errorf("alert settings %v, %v, %v, %v, %q, %v, %s and %d", cfg.AlertWebhookURL,
|
|
cfg.AlertWebhookHeaders, cfg.AlertSlackWebhookURL, cfg.AlertNtfyURL,
|
|
cfg.AlertNtfyToken, cfg.AlertEvents, cfg.AlertCooldown, cfg.AlertMaxPerHour)
|
|
}
|
|
}
|
|
|
|
func TestAlertSettingsSetEmptyOrOff(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
cfg := fromEnvironment(t, environment{
|
|
alertWebhookURL: "", alertSlackWebhookURL: "", alertNtfyURL: "",
|
|
alertNtfyToken: "", alertEvents: "", alertCooldown: off, alertMaxPerHour: off,
|
|
})
|
|
if cfg.AlertWebhookURL != nil || cfg.AlertSlackWebhookURL != nil ||
|
|
cfg.AlertNtfyURL != nil || cfg.AlertNtfyToken != "" ||
|
|
len(cfg.AlertEvents) != 0 || cfg.AlertCooldown != 0 || cfg.AlertMaxPerHour != 0 {
|
|
t.Errorf("set empty or off, alert settings %v, %v, %v, %q, %v, %s and %d",
|
|
cfg.AlertWebhookURL, cfg.AlertSlackWebhookURL, cfg.AlertNtfyURL,
|
|
cfg.AlertNtfyToken, cfg.AlertEvents, cfg.AlertCooldown, cfg.AlertMaxPerHour)
|
|
}
|
|
}
|
|
|
|
func TestInvalidAlertSettingStopsTheStart(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
wantStartStopped(t, []struct{ name, value string }{
|
|
{alertWebhookURL, "alerts.example/smallwebwaf"},
|
|
{alertWebhookURL, "ftp://alerts.example/"},
|
|
{alertWebhookURL, "https:///smallwebwaf"},
|
|
{alertWebhookURL, "https://user:password@alerts.example/"},
|
|
{alertWebhookURL, "https://alerts.example/#top"},
|
|
{alertWebhookURL, "https://alerts.example:0/"},
|
|
{alertWebhookURL, "https://alerts.example:65536/"},
|
|
{alertSlackWebhookURL, "hooks.slack.example/services/T0123"},
|
|
{alertSlackWebhookURL, "https://user:password@hooks.slack.example/"},
|
|
{alertNtfyURL, "ntfy://ntfy.example/smallwebwaf-alerts"},
|
|
{alertNtfyURL, "https://ntfy.example/smallwebwaf-alerts#top"},
|
|
{alertWebhookHeaders, "Authorization"},
|
|
{alertWebhookHeaders, "X Team:ops"},
|
|
{alertWebhookHeaders, ":ops"},
|
|
{alertWebhookHeaders, "X-Team:ops,"},
|
|
{alertWebhookHeaders, "X-Team:o\r\nps"},
|
|
{alertEvents, "bans"},
|
|
{alertEvents, "summary"},
|
|
{alertEvents, "ban,,file_error"},
|
|
{alertCooldown, "0"},
|
|
{alertCooldown, "soon"},
|
|
{alertMaxPerHour, "0"},
|
|
{alertMaxPerHour, "-1"},
|
|
{alertMaxPerHour, "1.5"},
|
|
})
|
|
}
|
|
|
|
func TestWebhookHeadersAreLoggedMaskedAndNeverShown(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
const secret = "Bearer 0123456789abcdef"
|
|
|
|
cfg := fromEnvironment(t, environment{
|
|
alertWebhookHeaders: "Authorization:" + secret + ",X-Team:ops",
|
|
})
|
|
|
|
var out bytes.Buffer
|
|
|
|
slog.New(slog.NewJSONHandler(&out, nil)).Info("starting", "settings", cfg)
|
|
|
|
logged := out.String()
|
|
if strings.Contains(logged, secret) || strings.Contains(logged, "ops") ||
|
|
!strings.Contains(logged,
|
|
`"`+alertWebhookHeaders+`":"Authorization:********,X-Team:********"`) {
|
|
t.Errorf("the headers are not logged masked: %s", logged)
|
|
}
|
|
|
|
// An item that is not a header is named by its place, not shown.
|
|
_, err := config.FromEnvironment(environment{
|
|
alertWebhookHeaders: "X-Team:ops," + secret,
|
|
}.lookupEnv)
|
|
|
|
want := alertWebhookHeaders + ": item 2 is not a header name followed by : " +
|
|
"and the header's value, such as Authorization:Bearer <token>"
|
|
if err == nil || err.Error() != want {
|
|
t.Errorf("error %v, want %s", err, want)
|
|
}
|
|
}
|
|
|
|
func TestWebhookURLIsLoggedWithoutItsPathOrQueryAndNeverShown(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
const secret = "T0123/B4567/abcdef"
|
|
|
|
for value, want := range map[string]string{
|
|
"https://hooks.example/services/" + secret: "https://hooks.example/********",
|
|
"https://hooks.example:8443?token=" + secret: "https://hooks.example:8443/********",
|
|
"http://[2001:db8::1]:8080": "http://[2001:db8::1]:8080",
|
|
} {
|
|
cfg := fromEnvironment(t, environment{alertWebhookURL: value})
|
|
|
|
var out bytes.Buffer
|
|
|
|
slog.New(slog.NewJSONHandler(&out, nil)).Info("starting", "settings", cfg)
|
|
|
|
logged := out.String()
|
|
if strings.Contains(logged, secret) ||
|
|
!strings.Contains(logged, `"`+alertWebhookURL+`":"`+want+`"`) {
|
|
t.Errorf("%s is not logged as %s: %s", value, want, logged)
|
|
}
|
|
}
|
|
|
|
// A value that is not such a URL is not shown either.
|
|
for _, value := range []string{
|
|
"ftp://hooks.example/services/" + secret,
|
|
"https://hooks.example/services/%zz" + secret,
|
|
} {
|
|
_, err := config.FromEnvironment(environment{alertWebhookURL: value}.lookupEnv)
|
|
|
|
want := alertWebhookURL + ": is not an http or https URL without a user or " +
|
|
"a fragment, such as https://alerts.example/smallwebwaf"
|
|
if err == nil || err.Error() != want {
|
|
t.Errorf("error %v, want %s", err, want)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestSlackAndNtfySettingsAreLoggedWithoutTheirSecrets(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
const token = "tk_0123456789abcdefghijklmnopq"
|
|
|
|
cfg := fromEnvironment(t, environment{
|
|
alertSlackWebhookURL: "https://hooks.slack.example/services/T0123/B4567/abcdef",
|
|
alertNtfyURL: "https://ntfy.example/smallwebwaf-alerts",
|
|
alertNtfyToken: token,
|
|
})
|
|
|
|
var out bytes.Buffer
|
|
|
|
slog.New(slog.NewJSONHandler(&out, nil)).Info("starting", "settings", cfg)
|
|
|
|
logged := out.String()
|
|
for _, want := range []string{
|
|
`"` + alertSlackWebhookURL + `":"https://hooks.slack.example/********"`,
|
|
`"` + alertNtfyURL + `":"https://ntfy.example/********"`,
|
|
`"` + alertNtfyToken + `":"********"`,
|
|
} {
|
|
if !strings.Contains(logged, want) {
|
|
t.Errorf("no %s in the settings logged: %s", want, logged)
|
|
}
|
|
}
|
|
|
|
for _, secret := range []string{"T0123", "smallwebwaf-alerts", token} {
|
|
if strings.Contains(logged, secret) {
|
|
t.Errorf("%s in the settings logged: %s", secret, logged)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestNtfyTokenWithAControlCharacterStopsTheStartWithoutShowingIt(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
// A file saved with Windows line ends keeps the carriage return.
|
|
for name, env := range map[string]environment{
|
|
"set": {alertNtfyToken: token + "\r"},
|
|
"in a file": {alertNtfyToken + "_FILE": writeFile(t, token+"\r\n")},
|
|
} {
|
|
_, err := config.FromEnvironment(env.lookupEnv)
|
|
|
|
want := alertNtfyToken + ": holds a control character, such as the " +
|
|
"carriage return of a Windows line end"
|
|
if err == nil || err.Error() != want {
|
|
t.Errorf("%s: error %v, want %s", name, err, want)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestInstanceNameWithAControlCharacterStopsTheStartOnlyWithNtfySet(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
const name = "fsn1app1\r"
|
|
|
|
_, err := config.FromEnvironment(environment{
|
|
instanceName: name, alertNtfyURL: "https://ntfy.example/smallwebwaf-alerts",
|
|
}.lookupEnv)
|
|
|
|
want := instanceName + `: "fsn1app1\r" holds a control character, such as the ` +
|
|
`carriage return of a Windows line end, and is sent to ntfy in a header ` +
|
|
`while ` + alertNtfyURL + ` is set`
|
|
if err == nil || err.Error() != want {
|
|
t.Errorf("error %v, want %s", err, want)
|
|
}
|
|
|
|
cfg := fromEnvironment(t, environment{instanceName: name})
|
|
if cfg.InstanceName != name {
|
|
t.Errorf("not sending to ntfy, %s is %q", instanceName, cfg.InstanceName)
|
|
}
|
|
}
|
|
|
|
func TestInstanceNameNotUTF8StopsTheStart(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
// café saved in Latin-1.
|
|
const latin1 = "caf\xe9"
|
|
|
|
for name, env := range map[string]environment{
|
|
"set": {instanceName: latin1},
|
|
"in a file": {instanceName + "_FILE": writeFile(t, latin1+"\n")},
|
|
} {
|
|
_, err := config.FromEnvironment(env.lookupEnv)
|
|
|
|
want := instanceName + `: "caf\xe9" is not valid UTF-8`
|
|
if err == nil || err.Error() != want {
|
|
t.Errorf("%s: error %v, want %s", name, err, want)
|
|
}
|
|
}
|
|
}
|
|
|
|
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 TestLookupSettingsAsSet(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
cfg := fromEnvironment(t, environment{
|
|
lookupTimeout: "500ms", addLookupHeaders: enabled,
|
|
})
|
|
wantLookupSettings(t, cfg, config.Config{
|
|
LookupSource: defaultLookupSource, LookupTimeout: 500 * time.Millisecond,
|
|
AddLookupHeaders: true,
|
|
})
|
|
|
|
cfg = fromEnvironment(t, environment{lookupSource: off})
|
|
wantLookupSettings(t, cfg, config.Config{
|
|
LookupSource: off, LookupTimeout: time.Second,
|
|
})
|
|
}
|
|
|
|
func TestLookupDBPathGoesWithTheFileSourceAlone(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
const path = "/var/lib/ipinfo/ipinfo_lite.mmdb"
|
|
|
|
cfg := fromEnvironment(t, environment{lookupSource: fileSource, lookupDBPath: path})
|
|
if cfg.LookupSource != fileSource || cfg.LookupDBPath != path {
|
|
t.Errorf("lookups from %q in %q, want file in %q",
|
|
cfg.LookupSource, cfg.LookupDBPath, path)
|
|
}
|
|
|
|
for _, tc := range []struct {
|
|
env environment
|
|
want string
|
|
}{
|
|
{
|
|
environment{lookupSource: fileSource},
|
|
lookupSource + ": is file while " + lookupDBPath +
|
|
" is unset; it names the file to look clients up in",
|
|
},
|
|
{
|
|
environment{lookupSource: fileSource, lookupDBPath: ""},
|
|
lookupSource + ": is file while " + lookupDBPath +
|
|
" is unset; it names the file to look clients up in",
|
|
},
|
|
{
|
|
environment{lookupDBPath: path},
|
|
lookupDBPath + ": is set while " + lookupSource + " is geojs; only file reads it",
|
|
},
|
|
{
|
|
environment{lookupSource: off, lookupDBPath: path},
|
|
lookupDBPath + ": is set while " + lookupSource + " is off; only file reads it",
|
|
},
|
|
} {
|
|
_, err := config.FromEnvironment(tc.env.lookupEnv)
|
|
if err == nil || err.Error() != tc.want {
|
|
t.Errorf("settings %v: error %v, want %s", tc.env, err, tc.want)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestSettingNeedingLookupsStopsTheStartWhileTheyAreOff(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
for name, value := range map[string]string{
|
|
deniedCountries: "kp",
|
|
allowedCountries: "de",
|
|
addLookupHeaders: enabled,
|
|
asnLimitPercentURL: asnURL,
|
|
asnLimitPercent: "AS64496:50",
|
|
countryLimitPercent: "cn:25",
|
|
asnBytesPercent: "AS64496:50",
|
|
countryBytesPercent: "cn:25",
|
|
unknownLimitPercent: "99",
|
|
anomalyASN + requestsPerMinute: "1000",
|
|
anomalyASN + requestsPerHour: "10000",
|
|
anomalyASN + bytesPerMinute: "1G",
|
|
anomalyASN + bytesPerHour: "10G",
|
|
} {
|
|
t.Run(name, func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
_, err := config.FromEnvironment(environment{
|
|
lookupSource: off, name: value,
|
|
}.lookupEnv)
|
|
if err == nil || !strings.HasPrefix(err.Error(), name+": ") ||
|
|
!strings.Contains(err.Error(), lookupSource+" is off") {
|
|
t.Errorf("error %v, want one naming %s and %s", err, name, lookupSource)
|
|
}
|
|
})
|
|
}
|
|
|
|
// Set empty, the lists need nothing looked up, and nor does
|
|
// SWWAF_UNKNOWN_LIMIT_PERCENT at 100, which lowers no limit, an anomaly
|
|
// threshold per AS number that is off, any other anomaly threshold, or
|
|
// a blocklist.
|
|
env := environment{
|
|
lookupSource: off, deniedCountries: "", allowedCountries: "",
|
|
asnLimitPercent: "", countryLimitPercent: "", asnBytesPercent: "",
|
|
countryBytesPercent: "", unknownLimitPercent: "100", asnLimitPercentURL: "",
|
|
blocklistURLs: dropURL,
|
|
}
|
|
for _, name := range anomalyThresholds() {
|
|
env[name] = "1000"
|
|
if strings.HasPrefix(name, anomalyASN) {
|
|
env[name] = off
|
|
}
|
|
}
|
|
|
|
fromEnvironment(t, env)
|
|
}
|
|
|
|
func TestAnomalySettingsDefaults(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
cfg := fromEnvironment(t, environment{})
|
|
wantAllOff(t, cfg)
|
|
|
|
if cfg.AnomalyNetV4Prefix != 24 || cfg.AnomalyNetV6Prefix != 48 ||
|
|
len(cfg.WatchNets) != 0 {
|
|
t.Errorf("%s, %s and %s gave %d, %d and %v, want 24, 48 and none",
|
|
anomalyNetV4Prefix, anomalyNetV6Prefix, watchNets, cfg.AnomalyNetV4Prefix,
|
|
cfg.AnomalyNetV6Prefix, cfg.WatchNets)
|
|
}
|
|
}
|
|
|
|
func TestAnomalySettingsAsSet(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
// Each threshold of a scope its own value; bytes are sizes.
|
|
env := environment{
|
|
anomalyNetV4Prefix: "16",
|
|
anomalyNetV6Prefix: "56",
|
|
// Spaces around a name or a netblock, and a bare address.
|
|
watchNets: "office = 203.0.113.0/24, scraper-x=198.51.100.7,v6=2001:db8::/32",
|
|
}
|
|
want := map[string]anomaly.Thresholds{}
|
|
|
|
for i, scope := range anomalyScopes() {
|
|
n := int64(i + 1)
|
|
env[scope+requestsPerMinute] = strconv.FormatInt(n, 10)
|
|
env[scope+requestsPerHour] = strconv.FormatInt(10*n, 10)
|
|
env[scope+bytesPerMinute] = strconv.FormatInt(n, 10) + "K"
|
|
env[scope+bytesPerHour] = strconv.FormatInt(n, 10) + "G"
|
|
want[scope] = anomaly.Thresholds{
|
|
RequestsPerMinute: n, RequestsPerHour: 10 * n,
|
|
BytesPerMinute: n << 10, BytesPerHour: n << 30,
|
|
}
|
|
}
|
|
|
|
cfg := fromEnvironment(t, env)
|
|
|
|
if got := thresholdsByScope(cfg); !maps.Equal(got, want) {
|
|
t.Errorf("thresholds by scope\n%+v\nwant\n%+v", got, want)
|
|
}
|
|
|
|
wantNamed := []anomaly.NamedNetblock{
|
|
{Name: "office", Netblock: netip.MustParsePrefix("203.0.113.0/24")},
|
|
{Name: "scraper-x", Netblock: netip.MustParsePrefix("198.51.100.7/32")},
|
|
{Name: "v6", Netblock: netip.MustParsePrefix("2001:db8::/32")},
|
|
}
|
|
if cfg.AnomalyNetV4Prefix != 16 || cfg.AnomalyNetV6Prefix != 56 ||
|
|
!slices.Equal(cfg.WatchNets, wantNamed) {
|
|
t.Errorf("%s, %s and %s gave %d, %d and %v, want 16, 56 and %v",
|
|
anomalyNetV4Prefix, anomalyNetV6Prefix, watchNets, cfg.AnomalyNetV4Prefix,
|
|
cfg.AnomalyNetV6Prefix, cfg.WatchNets, wantNamed)
|
|
}
|
|
|
|
// off switches each threshold off.
|
|
for _, name := range anomalyThresholds() {
|
|
env[name] = off
|
|
}
|
|
|
|
wantAllOff(t, fromEnvironment(t, env))
|
|
}
|
|
|
|
// thresholdsByScope returns cfg's anomaly thresholds, each by the prefix
|
|
// of its scope's settings.
|
|
func thresholdsByScope(cfg *config.Config) map[string]anomaly.Thresholds {
|
|
return map[string]anomaly.Thresholds{
|
|
anomalyClient: cfg.AnomalyClient, anomalyNet: cfg.AnomalyNet,
|
|
anomalyASN: cfg.AnomalyASN, anomalyTotal: cfg.AnomalyTotal,
|
|
watch: cfg.AnomalyWatch,
|
|
}
|
|
}
|
|
|
|
// wantAllOff checks that every anomaly threshold of cfg is off.
|
|
func wantAllOff(t *testing.T, cfg *config.Config) {
|
|
t.Helper()
|
|
|
|
for scope, thresholds := range thresholdsByScope(cfg) {
|
|
if thresholds != (anomaly.Thresholds{}) {
|
|
t.Errorf("%s* gave %+v, want every one off", scope, thresholds)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestInvalidAnomalySettingStopsTheStartSayingWhatIsWrong(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
const (
|
|
notCount = " is not a whole number of requests such as 1000, or off"
|
|
notSize = " is not a size such as 512K, 100M or 5G, or off"
|
|
notPositive = " must be more than zero, or off"
|
|
notNamed = " is not a name, = and a netblock, such as office=203.0.113.0/24"
|
|
notNetblock = " is not a netblock such as 10.0.0.0/8, or an address"
|
|
notV4Prefix = " is not the length of an IPv4 netblock, from 0 to 32, such as 24"
|
|
notV6Prefix = " is not the length of an IPv6 netblock, from 0 to 128, such as 48"
|
|
officeNetblock = "office=203.0.113.0/24"
|
|
scraperNetblock = "scraper=198.51.100.0/24"
|
|
)
|
|
|
|
for _, tc := range []struct{ name, value, want string }{
|
|
{anomalyClient + requestsPerMinute, "1K", `"1K"` + notCount},
|
|
{anomalyNet + requestsPerHour, "0", `"0"` + notPositive},
|
|
{anomalyTotal + bytesPerMinute, "1T", `"1T"` + notSize},
|
|
{watch + bytesPerHour, "-1G", `"-1G"` + notPositive},
|
|
{anomalyNetV4Prefix, "33", `"33"` + notV4Prefix},
|
|
{anomalyNetV4Prefix, off, `"off"` + notV4Prefix},
|
|
{anomalyNetV6Prefix, "129", `"129"` + notV6Prefix},
|
|
{anomalyNetV6Prefix, "/48", `"/48"` + notV6Prefix},
|
|
{watchNets, "office", `"office"` + notNamed},
|
|
{watchNets, "=203.0.113.0/24", `"=203.0.113.0/24"` + notNamed},
|
|
{watchNets, "office=203.0.113.300/24", `"203.0.113.300/24"` + notNetblock},
|
|
{watchNets, officeNetblock + ",", `"` + officeNetblock + `," has an empty item ` +
|
|
`in its list`},
|
|
{
|
|
watchNets, officeNetblock + "," + scraperNetblock + ",office=192.0.2.0/24",
|
|
`"office" is listed twice`,
|
|
},
|
|
} {
|
|
t.Run(tc.name+"="+tc.value, func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
_, err := config.FromEnvironment(environment{tc.name: tc.value}.lookupEnv)
|
|
|
|
want := tc.name + ": " + tc.want
|
|
if err == nil || err.Error() != want {
|
|
t.Errorf("error %v, want %s", err, want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestBiasedThresholdsAsSet(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
cfg := fromEnvironment(t, environment{})
|
|
if len(cfg.ASNLimitPercent) != 0 || len(cfg.CountryLimitPercent) != 0 ||
|
|
len(cfg.ASNBytesPercent) != 0 || len(cfg.CountryBytesPercent) != 0 ||
|
|
cfg.UnknownLimitPercent != 100 {
|
|
t.Errorf("biased thresholds %v, %v, %v, %v and %d by default, "+
|
|
"want four empty lists and 100", cfg.ASNLimitPercent, cfg.CountryLimitPercent,
|
|
cfg.ASNBytesPercent, cfg.CountryBytesPercent, cfg.UnknownLimitPercent)
|
|
}
|
|
|
|
// AS numbers and countries in either case, an AS number with leading
|
|
// zeros, 0 and 100.
|
|
cfg = fromEnvironment(t, environment{
|
|
asnLimitPercent: "AS14061:50, as16276:0,AS045102:100",
|
|
countryLimitPercent: "cn:25,RU:50",
|
|
asnBytesPercent: "as16276:75",
|
|
countryBytesPercent: "ru:10",
|
|
unknownLimitPercent: "0",
|
|
})
|
|
|
|
for name, tc := range map[string]struct{ got, want map[string]int64 }{
|
|
asnLimitPercent: {
|
|
cfg.ASNLimitPercent,
|
|
map[string]int64{"AS14061": 50, "AS16276": 0, "AS45102": 100},
|
|
},
|
|
countryLimitPercent: {cfg.CountryLimitPercent, map[string]int64{"CN": 25, "RU": 50}},
|
|
asnBytesPercent: {cfg.ASNBytesPercent, map[string]int64{"AS16276": 75}},
|
|
countryBytesPercent: {cfg.CountryBytesPercent, map[string]int64{"RU": 10}},
|
|
} {
|
|
if !maps.Equal(tc.got, tc.want) {
|
|
t.Errorf("%s gave %v, want %v", name, tc.got, tc.want)
|
|
}
|
|
}
|
|
|
|
if cfg.UnknownLimitPercent != 0 {
|
|
t.Errorf("%s gave %d, want 0", unknownLimitPercent, cfg.UnknownLimitPercent)
|
|
}
|
|
}
|
|
|
|
func TestInvalidBiasedThresholdStopsTheStartSayingWhatIsWrong(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
const (
|
|
notASN = " is not an AS number such as AS64496"
|
|
notItem = " is not a code, : and a percentage, such as AS64496:50 or cn:25"
|
|
notPercent = " is not a percentage, a whole number from 0 to 100"
|
|
)
|
|
|
|
for _, tc := range []struct{ name, value, want string }{
|
|
{asnLimitPercent, "14061:50", `"14061"` + notASN},
|
|
{asnLimitPercent, "AS4294967296:50", `"AS4294967296"` + notASN},
|
|
{asnLimitPercent, "AS14061", `"AS14061"` + notItem},
|
|
{asnLimitPercent, "AS14061:101", `"101"` + notPercent},
|
|
{asnLimitPercent, "AS14061:50,as14061:25", `"as14061" is listed twice`},
|
|
{
|
|
countryLimitPercent, "nk:25",
|
|
`"nk" is not a two-letter country code such as de or kp`,
|
|
},
|
|
{countryLimitPercent, "cn:25,CN:50", `"CN" is listed twice`},
|
|
{asnBytesPercent, "AS14061:-1", `"-1"` + notPercent},
|
|
{countryBytesPercent, "cn:50%", `"50%"` + notPercent},
|
|
{unknownLimitPercent, "101", `"101"` + notPercent},
|
|
{unknownLimitPercent, off, `"off"` + notPercent},
|
|
} {
|
|
t.Run(tc.name+"="+tc.value, func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
_, err := config.FromEnvironment(environment{tc.name: tc.value}.lookupEnv)
|
|
|
|
want := tc.name + ": " + tc.want
|
|
if err == nil || err.Error() != want {
|
|
t.Errorf("error %v, want %s", err, want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// dropURL and torURL are blocklists, and asnURL a file of AS:percent
|
|
// lines.
|
|
const (
|
|
dropURL = "https://www.spamhaus.org/drop/drop.txt"
|
|
torURL = "https://lists.example/tor-exits.txt"
|
|
asnURL = "https://lists.example/asn.txt"
|
|
)
|
|
|
|
// The actions of SWWAF_BLOCKLIST_ACTION, as Config gives them.
|
|
const (
|
|
actionDeny = "deny"
|
|
actionLimit = "limit"
|
|
actionLog = "log"
|
|
)
|
|
|
|
func TestReputationSettingsAsSet(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
cfg := fromEnvironment(t, environment{})
|
|
if len(cfg.BlocklistURLs) != 0 || cfg.BlocklistRefresh != 24*time.Hour ||
|
|
cfg.BlocklistAction != actionDeny || cfg.ASNLimitPercentURL != "" {
|
|
t.Errorf("%s, %s, %s and %s gave %v, %s, %s and %q by default, "+
|
|
"want none, 24h, deny and none", blocklistURLs, blocklistRefresh,
|
|
blocklistAction, asnLimitPercentURL, cfg.BlocklistURLs, cfg.BlocklistRefresh,
|
|
cfg.BlocklistAction, cfg.ASNLimitPercentURL)
|
|
}
|
|
|
|
for _, tc := range []struct {
|
|
value, action string
|
|
percent int64
|
|
}{
|
|
{actionDeny, actionDeny, 0},
|
|
{actionLog, actionLog, 0},
|
|
{"limit:25", actionLimit, 25},
|
|
{"limit:0", actionLimit, 0},
|
|
} {
|
|
// An hour, the shortest refresh allowed.
|
|
cfg := fromEnvironment(t, environment{
|
|
blocklistURLs: dropURL + ", " + torURL, blocklistRefresh: "1h",
|
|
blocklistAction: tc.value, asnLimitPercentURL: asnURL,
|
|
})
|
|
|
|
if !slices.Equal(cfg.BlocklistURLs, []string{dropURL, torURL}) ||
|
|
cfg.BlocklistRefresh != time.Hour || cfg.BlocklistAction != tc.action ||
|
|
cfg.BlocklistLimitPercent != tc.percent || cfg.ASNLimitPercentURL != asnURL {
|
|
t.Errorf("%s=%s gave %v, %s, %s, %d and %s", blocklistAction, tc.value,
|
|
cfg.BlocklistURLs, cfg.BlocklistRefresh, cfg.BlocklistAction,
|
|
cfg.BlocklistLimitPercent, cfg.ASNLimitPercentURL)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestInvalidReputationSettingStopsTheStartSayingWhatIsWrong(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
const (
|
|
notURL = " is not an http or https URL without a user or a fragment, " +
|
|
"such as https://www.spamhaus.org/drop/drop.txt"
|
|
notAnHour = " is not a duration of 1h or more, such as 24h"
|
|
notAction = " is not deny, limit:<percent> such as limit:25, or log"
|
|
)
|
|
|
|
for _, tc := range []struct{ name, value, want string }{
|
|
{
|
|
blocklistURLs, "ftp://lists.example/drop.txt",
|
|
`"ftp://lists.example/drop.txt"` + notURL,
|
|
},
|
|
{blocklistURLs, "lists.example/drop.txt", `"lists.example/drop.txt"` + notURL},
|
|
{
|
|
blocklistURLs, "https://me:secret@lists.example/drop.txt",
|
|
`"https://me:secret@lists.example/drop.txt"` + notURL,
|
|
},
|
|
{
|
|
blocklistURLs, dropURL + "," + torURL + "," + dropURL,
|
|
`"` + dropURL + `" is listed twice`,
|
|
},
|
|
{asnLimitPercentURL, asnURL + "#top", `"` + asnURL + `#top"` + notURL},
|
|
{blocklistRefresh, "59m", `"59m"` + notAnHour},
|
|
{blocklistRefresh, off, `"off"` + notAnHour},
|
|
{blocklistRefresh, "a day", `"a day"` + notAnHour},
|
|
{blocklistAction, "block", `"block"` + notAction},
|
|
{blocklistAction, actionLimit, `"limit"` + notAction},
|
|
{blocklistAction, "limit:101", `"limit:101"` + notAction},
|
|
} {
|
|
t.Run(tc.name+"="+tc.value, func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
_, err := config.FromEnvironment(environment{tc.name: tc.value}.lookupEnv)
|
|
|
|
want := tc.name + ": " + tc.want
|
|
if err == nil || err.Error() != want {
|
|
t.Errorf("error %v, want %s", err, want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestASNLimitPercentURLThatIsABlocklistStopsTheStart(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
_, err := config.FromEnvironment(environment{
|
|
blocklistURLs: dropURL + "," + asnURL, asnLimitPercentURL: asnURL,
|
|
}.lookupEnv)
|
|
|
|
want := asnLimitPercentURL + `: "` + asnURL + `" is in SWWAF_BLOCKLIST_URLS too`
|
|
if err == nil || err.Error() != want {
|
|
t.Errorf("error %v, want %s", err, want)
|
|
}
|
|
}
|
|
|
|
// dronebl is a DNSBL zone, and spamhaus one of Spamhaus's, a name
|
|
// containing spamhausKey, the key of its keyed query service, which the
|
|
// log shows as spamhausMasked.
|
|
const (
|
|
dronebl = "dnsbl.dronebl.org"
|
|
spamhausKey = "abcdefghijklmnopqrstuvwxyz"
|
|
spamhaus = spamhausKey + ".xbl.dq.spamhaus.net"
|
|
spamhausMasked = "********.xbl.dq.spamhaus.net"
|
|
)
|
|
|
|
func TestDNSBLSettingsAsSet(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
cfg := fromEnvironment(t, environment{})
|
|
if len(cfg.DNSBLZones) != 0 || cfg.DNSBLResolver.IsValid() ||
|
|
cfg.ReputationAction != actionLimit || cfg.ReputationLimitPercent != 25 ||
|
|
cfg.ReputationCacheTTL != 24*time.Hour || cfg.ReputationTimeout != 2*time.Second {
|
|
t.Errorf("by default, the zones %v, the resolver %s, the action %s:%d, the TTL %s "+
|
|
"and the timeout %s, want no zone, no resolver, limit:25, 24h and 2s",
|
|
cfg.DNSBLZones,
|
|
cfg.DNSBLResolver, cfg.ReputationAction, cfg.ReputationLimitPercent,
|
|
cfg.ReputationCacheTTL, cfg.ReputationTimeout)
|
|
}
|
|
|
|
// The longest zone allowed, of 189 characters, with labels of 63, the
|
|
// longest allowed.
|
|
longest := strings.Repeat("a", 63) + "." + strings.Repeat("b", 63) + "." +
|
|
strings.Repeat("c", 61)
|
|
|
|
for _, tc := range []struct {
|
|
zones, resolver, action string
|
|
// want are the zones, resolver, action and percent Config gives.
|
|
want []string
|
|
wantResolver string
|
|
wantAction string
|
|
wantPercent int64
|
|
}{
|
|
{
|
|
dronebl + ", " + spamhaus, "192.0.2.53", actionDeny,
|
|
[]string{dronebl, spamhaus}, "192.0.2.53:53", actionDeny, 0,
|
|
},
|
|
{
|
|
longest, "192.0.2.53:5353", actionLog,
|
|
[]string{longest}, "192.0.2.53:5353", actionLog, 0,
|
|
},
|
|
{
|
|
"Dnsbl-1.Example", "2001:db8::53", "limit:10",
|
|
[]string{"Dnsbl-1.Example"}, "[2001:db8::53]:53", actionLimit, 10,
|
|
},
|
|
{
|
|
dronebl, "[2001:db8::53]:5353", "limit:0",
|
|
[]string{dronebl}, "[2001:db8::53]:5353", actionLimit, 0,
|
|
},
|
|
} {
|
|
cfg := fromEnvironment(t, environment{
|
|
dnsblZones: tc.zones, dnsblResolver: tc.resolver, reputationAction: tc.action,
|
|
reputationCacheTTL: "12h", reputationTimeout: "3s",
|
|
})
|
|
|
|
if !slices.Equal(cfg.DNSBLZones, tc.want) ||
|
|
cfg.DNSBLResolver.String() != tc.wantResolver ||
|
|
cfg.ReputationAction != tc.wantAction ||
|
|
cfg.ReputationLimitPercent != tc.wantPercent ||
|
|
cfg.ReputationCacheTTL != 12*time.Hour ||
|
|
cfg.ReputationTimeout != 3*time.Second {
|
|
t.Errorf("%s=%s, %s=%s and %s=%s gave %v, %s, %s:%d, %s and %s", dnsblZones,
|
|
tc.zones, dnsblResolver, tc.resolver, reputationAction, tc.action,
|
|
cfg.DNSBLZones, cfg.DNSBLResolver, cfg.ReputationAction,
|
|
cfg.ReputationLimitPercent, cfg.ReputationCacheTTL, cfg.ReputationTimeout)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestInvalidDNSBLSettingStopsTheStartSayingWhatIsWrong(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
const (
|
|
notZone = " is not a DNS zone such as dnsbl.dronebl.org"
|
|
notResolver = " is not an IP address with an optional port, such as 192.0.2.53 " +
|
|
"or [2001:db8::53]:5353"
|
|
notAboveZero = " is not a duration above zero, such as 1h or 7d"
|
|
)
|
|
|
|
label64 := strings.Repeat("a", 64) + ".example"
|
|
tooLong := strings.Repeat("a", 63) + "." + strings.Repeat("b", 63) + "." +
|
|
strings.Repeat("c", 62)
|
|
|
|
for _, tc := range []struct{ name, value, want string }{
|
|
{dnsblZones, "dnsbl..example", `"dnsbl..example"` + notZone},
|
|
{dnsblZones, "dnsbl.example.", `"dnsbl.example."` + notZone},
|
|
{dnsblZones, "-dnsbl.example", `"-dnsbl.example"` + notZone},
|
|
{dnsblZones, "dnsbl-.example", `"dnsbl-.example"` + notZone},
|
|
{dnsblZones, "dns_bl.example", `"dns_bl.example"` + notZone},
|
|
{dnsblZones, label64, `"` + label64 + `"` + notZone},
|
|
{
|
|
dnsblZones, tooLong,
|
|
`"` + tooLong + `" is longer than 189 characters, too long for the names ` +
|
|
`IPv6 clients are asked about by`,
|
|
},
|
|
{
|
|
dnsblZones, dronebl + "," + spamhaus + "," + dronebl,
|
|
`"` + dronebl + `" is listed twice`,
|
|
},
|
|
// DNS names ignore case.
|
|
{dnsblZones, "dnsbl.example,DNSBL.example", `"DNSBL.example" is listed twice`},
|
|
{dnsblResolver, "resolver.example", `"resolver.example"` + notResolver},
|
|
{dnsblResolver, "192.0.2.53:0", `"192.0.2.53:0"` + notResolver},
|
|
{dnsblResolver, "192.0.2.53:65536", `"192.0.2.53:65536"` + notResolver},
|
|
{dnsblResolver, "[2001:db8::53]", `"[2001:db8::53]"` + notResolver},
|
|
{
|
|
reputationAction, "ban",
|
|
`"ban" is not deny, limit:<percent> such as limit:25, or log`,
|
|
},
|
|
{reputationCacheTTL, off, `"off"` + notAboveZero},
|
|
{reputationTimeout, "0s", `"0s"` + notAboveZero},
|
|
} {
|
|
t.Run(tc.name+"="+tc.value, func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
_, err := config.FromEnvironment(environment{tc.name: tc.value}.lookupEnv)
|
|
|
|
want := tc.name + ": " + tc.want
|
|
if err == nil || err.Error() != want {
|
|
t.Errorf("error %v, want %s", err, want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestMaskZoneKeyMasksTheFirstLabelOfAZoneUnderDqSpamhausNet(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
for zone, want := range map[string]string{
|
|
spamhaus: spamhausMasked,
|
|
spamhaus + ".": spamhausMasked + ".",
|
|
"KEY.ZEN.DQ.SPAMHAUS.NET": "********.ZEN.DQ.SPAMHAUS.NET",
|
|
dronebl: dronebl,
|
|
"dq.spamhaus.net": "dq.spamhaus.net",
|
|
spamhaus + ".example": spamhaus + ".example",
|
|
} {
|
|
if got := config.MaskZoneKey(zone); got != want {
|
|
t.Errorf("MaskZoneKey(%q) is %q, want %q", zone, got, want)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestDNSBLZoneKeyIsLoggedMaskedAndNeverShown(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
cfg := fromEnvironment(t, environment{dnsblZones: dronebl + ", " + spamhaus})
|
|
|
|
var out bytes.Buffer
|
|
|
|
slog.New(slog.NewJSONHandler(&out, nil)).Info("starting", "settings", cfg)
|
|
|
|
logged := out.String()
|
|
if strings.Contains(logged, spamhausKey) ||
|
|
!strings.Contains(logged, `"`+dnsblZones+`":"`+dronebl+","+spamhausMasked+`"`) {
|
|
t.Errorf("the zones are not logged with the key masked: %s", logged)
|
|
}
|
|
|
|
// Nor does an error that stops the start show a key, in any case.
|
|
const (
|
|
notZone = " is not a DNS zone such as dnsbl.dronebl.org"
|
|
otherKey = "zyxwvutsrqponmlkjihgfedcba"
|
|
otherZone = otherKey + ".xbl.dq.spamhaus.net"
|
|
)
|
|
|
|
// 205 characters, 187 with the key masked.
|
|
labels := strings.Repeat("a", 63) + "." + strings.Repeat("b", 63) + "." +
|
|
strings.Repeat("c", 30) + ".xbl.dq.spamhaus.net"
|
|
|
|
for _, tc := range []struct{ value, want string }{
|
|
{spamhaus + ".", `"` + spamhausMasked + `."` + notZone},
|
|
{spamhausKey + "_.xbl.dq.spamhaus.net", `"` + spamhausMasked + `"` + notZone},
|
|
{
|
|
spamhausKey + "." + labels,
|
|
`"********.` + labels + `" is longer than 189 characters, too long ` +
|
|
`for the names IPv6 clients are asked about by`,
|
|
},
|
|
{
|
|
spamhaus + "," + strings.ToUpper(spamhaus),
|
|
`"********.XBL.DQ.SPAMHAUS.NET" is listed twice`,
|
|
},
|
|
{spamhaus + "," + otherZone, `"` + spamhausMasked + `" is listed twice`},
|
|
{spamhaus + ",,", "has an empty item in its list"},
|
|
} {
|
|
_, err := config.FromEnvironment(environment{dnsblZones: tc.value}.lookupEnv)
|
|
|
|
want := dnsblZones + ": " + tc.want
|
|
if err == nil || err.Error() != want {
|
|
t.Errorf("%s=%s gave the error %v, want %s", dnsblZones, tc.value, err, want)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestAbuseIPDBSettingsAsSet(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
cfg := fromEnvironment(t, environment{})
|
|
if cfg.AbuseIPDBKey != "" || cfg.AbuseIPDBMinScore != 75 ||
|
|
cfg.AbuseIPDBDailyBudget != 900 {
|
|
t.Errorf("by default, the key %q, the minimum score %d and the daily budget %d, "+
|
|
"want none, 75 and 900", cfg.AbuseIPDBKey, cfg.AbuseIPDBMinScore,
|
|
cfg.AbuseIPDBDailyBudget)
|
|
}
|
|
|
|
cfg = fromEnvironment(t, environment{
|
|
abuseIPDBKey: token, abuseIPDBMinScore: "0", abuseIPDBDailyBudget: "1",
|
|
})
|
|
if cfg.AbuseIPDBKey != token || cfg.AbuseIPDBMinScore != 0 ||
|
|
cfg.AbuseIPDBDailyBudget != 1 {
|
|
t.Errorf("set, the key %q, the minimum score %d and the daily budget %d, "+
|
|
"want %s, 0 and 1", cfg.AbuseIPDBKey, cfg.AbuseIPDBMinScore,
|
|
cfg.AbuseIPDBDailyBudget, token)
|
|
}
|
|
}
|
|
|
|
func TestInvalidAbuseIPDBSettingStopsTheStartSayingWhatIsWrong(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
const (
|
|
notScore = " is not a percentage, a whole number from 0 to 100"
|
|
notBudget = " is not a whole number above zero, such as 5000"
|
|
)
|
|
|
|
for _, tc := range []struct{ name, value, want string }{
|
|
{abuseIPDBMinScore, "101", `"101"` + notScore},
|
|
{abuseIPDBMinScore, off, `"off"` + notScore},
|
|
{abuseIPDBDailyBudget, "0", `"0"` + notBudget},
|
|
{abuseIPDBDailyBudget, off, `"off"` + notBudget},
|
|
// The key itself is never shown.
|
|
{
|
|
abuseIPDBKey, token + "\r",
|
|
"holds a control character, such as the carriage return of a Windows line end",
|
|
},
|
|
} {
|
|
t.Run(tc.name+"="+tc.value, func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
_, err := config.FromEnvironment(environment{tc.name: tc.value}.lookupEnv)
|
|
|
|
want := tc.name + ": " + tc.want
|
|
if err == nil || err.Error() != want {
|
|
t.Errorf("error %v, want %s", err, want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestAbuseIPDBKeyIsLoggedMasked(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
cfg := fromEnvironment(t, environment{abuseIPDBKey: token})
|
|
|
|
var out bytes.Buffer
|
|
|
|
slog.New(slog.NewJSONHandler(&out, nil)).Info("starting", "settings", cfg)
|
|
|
|
logged := out.String()
|
|
if strings.Contains(logged, token) ||
|
|
!strings.Contains(logged, `"`+abuseIPDBKey+`":"********"`) {
|
|
t.Errorf("the key is not logged masked: %s", logged)
|
|
}
|
|
}
|
|
|
|
func TestCrowdSecSettingsGiveTheDecisionListAndTheKey(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
cfg := fromEnvironment(t, environment{})
|
|
if cfg.CrowdSecDecisionsURL != "" || cfg.CrowdSecKey != "" {
|
|
t.Errorf("by default, the decision list %q and the key %q, want neither",
|
|
cfg.CrowdSecDecisionsURL, cfg.CrowdSecKey)
|
|
}
|
|
|
|
for lapi, want := range map[string]string{
|
|
"http://172.17.0.1:8080": "http://172.17.0.1:8080/v1/decisions",
|
|
"http://172.17.0.1:8080/": "http://172.17.0.1:8080/v1/decisions",
|
|
"https://crowdsec.example/lapi/": "https://crowdsec.example/lapi/v1/decisions",
|
|
"https://crowdsec.example:8443/x": "https://crowdsec.example:8443/x/v1/decisions",
|
|
} {
|
|
cfg := fromEnvironment(t, environment{crowdSecURL: lapi, crowdSecKey: token})
|
|
if cfg.CrowdSecDecisionsURL != want || cfg.CrowdSecKey != token {
|
|
t.Errorf("%s=%s gave the decision list %q and the key %q, want %s and %s",
|
|
crowdSecURL, lapi, cfg.CrowdSecDecisionsURL, cfg.CrowdSecKey, want, token)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestInvalidCrowdSecSettingStopsTheStartSayingWhatIsWrong(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
const (
|
|
lapi = "http://172.17.0.1:8080"
|
|
notLAPIURL = " is not an http or https URL without a user or a fragment, " +
|
|
"such as http://172.17.0.1:8080"
|
|
anotherList = `gives the decision list "` + lapi + `/v1/decisions", which is in ` +
|
|
`SWWAF_BLOCKLIST_URLS or is SWWAF_ASN_LIMIT_PERCENT_URL too`
|
|
)
|
|
|
|
for _, tc := range []struct {
|
|
name string
|
|
env environment
|
|
want string
|
|
}{
|
|
{
|
|
"a URL that is not http",
|
|
environment{crowdSecURL: "ftp://172.17.0.1", crowdSecKey: token},
|
|
crowdSecURL + `: "ftp://172.17.0.1"` + notLAPIURL,
|
|
},
|
|
{
|
|
"a URL with a user",
|
|
environment{crowdSecURL: "http://bouncer@172.17.0.1:8080", crowdSecKey: token},
|
|
crowdSecURL + `: "http://bouncer@172.17.0.1:8080"` + notLAPIURL,
|
|
},
|
|
{
|
|
"the URL without the key",
|
|
environment{crowdSecURL: lapi},
|
|
crowdSecURL + ": is set while " + crowdSecKey + " is unset; the engine " +
|
|
"answers no request without it",
|
|
},
|
|
{
|
|
"the key without the URL",
|
|
environment{crowdSecKey: token},
|
|
crowdSecKey + ": is set while " + crowdSecURL + " is unset; it is sent only " +
|
|
"to the engine at that URL",
|
|
},
|
|
{
|
|
"the decision list as a blocklist too",
|
|
environment{
|
|
crowdSecURL: lapi, crowdSecKey: token,
|
|
blocklistURLs: "https://lists.example/drop.txt," + lapi + "/v1/decisions",
|
|
},
|
|
crowdSecURL + ": " + anotherList,
|
|
},
|
|
{
|
|
"the decision list as the file of AS:percent lines too",
|
|
environment{
|
|
crowdSecURL: lapi + "/", crowdSecKey: token,
|
|
asnLimitPercentURL: lapi + "/v1/decisions",
|
|
},
|
|
crowdSecURL + ": " + anotherList,
|
|
},
|
|
// The key itself is never shown.
|
|
{
|
|
"a key with a control character",
|
|
environment{crowdSecURL: lapi, crowdSecKey: token + "\r"},
|
|
crowdSecKey + ": holds a control character, such as the carriage return " +
|
|
"of a Windows line end",
|
|
},
|
|
} {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
_, err := config.FromEnvironment(tc.env.lookupEnv)
|
|
if err == nil || err.Error() != tc.want {
|
|
t.Errorf("error %v, want %s", err, tc.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestCrowdSecKeyIsLoggedMasked(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
cfg := fromEnvironment(t, environment{
|
|
crowdSecURL: "http://172.17.0.1:8080", crowdSecKey: token,
|
|
})
|
|
|
|
var out bytes.Buffer
|
|
|
|
slog.New(slog.NewJSONHandler(&out, nil)).Info("starting", "settings", cfg)
|
|
|
|
logged := out.String()
|
|
if strings.Contains(logged, token) ||
|
|
!strings.Contains(logged, `"`+crowdSecKey+`":"********"`) ||
|
|
!strings.Contains(logged, `"`+crowdSecURL+`":"http://172.17.0.1:8080"`) {
|
|
t.Errorf("the key is not logged masked beside the URL: %s", logged)
|
|
}
|
|
}
|
|
|
|
func TestSizesAndOff(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
cfg := fromEnvironment(t, environment{
|
|
requestMaxBytes: "3G",
|
|
responseMaxBytes: off,
|
|
clientRequestTimeout: off,
|
|
clientIdleTimeout: off,
|
|
})
|
|
|
|
if cfg.RequestMaxBytes != 3<<30 || cfg.ResponseMaxBytes != 0 ||
|
|
cfg.ClientRequestTimeout != 0 || cfg.ClientIdleTimeout != 0 {
|
|
t.Errorf("3G, off, off and off read as %d, %d, %s and %s",
|
|
cfg.RequestMaxBytes, cfg.ResponseMaxBytes, cfg.ClientRequestTimeout,
|
|
cfg.ClientIdleTimeout)
|
|
}
|
|
}
|
|
|
|
func TestRequestHeaderMaxBytesJustOver4K(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
cfg := fromEnvironment(t, environment{clientHeaderMaxBytes: "4097"})
|
|
if cfg.ClientRequestHeaderMaxBytes != 4097 {
|
|
t.Errorf("4097 read as %d", cfg.ClientRequestHeaderMaxBytes)
|
|
}
|
|
}
|
|
|
|
func TestRequestHeaderMaxBytesRefusalNeverOffersOff(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
for _, value := range []string{"32KB", "0", "4K", off} {
|
|
t.Run(value, func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
_, err := config.FromEnvironment(
|
|
environment{clientHeaderMaxBytes: value}.lookupEnv)
|
|
|
|
want := clientHeaderMaxBytes + `: "` + value +
|
|
`" is not a size of more than 4K, such as 32K`
|
|
if err == nil || err.Error() != want {
|
|
t.Errorf("error %v, want %s", err, want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
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()
|
|
|
|
wantStartStopped(t, []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"},
|
|
{mode, "Observe"}, {mode, "block"}, {mode, ""},
|
|
{trustedProxies, "10.0.0.0/33"},
|
|
{trustedProxies, "traefik"},
|
|
{trustedProxies, "10.0.0.0/8,,192.168.0.0/16"},
|
|
{trustedProxies, "fe80::1%eth0"},
|
|
{ipv6GroupPrefix, "31"}, {ipv6GroupPrefix, "129"}, {ipv6GroupPrefix, "/64"},
|
|
{ipv6GroupPrefix, off}, {ipv6GroupPrefix, ""},
|
|
{maxTrackedClients, "0"}, {maxTrackedClients, "-1"}, {maxTrackedClients, off},
|
|
{maxTrackedClients, "20K"},
|
|
{allowNets, "192.0.2.0/24,monitoring"},
|
|
{rateLimitExemptNets, "2001:db8::/129"},
|
|
{denyNets, "198.51.100.0/24,"},
|
|
{clientRequestTimeout, "60"}, {clientRequestTimeout, ""},
|
|
{clientIdleTimeout, "0s"},
|
|
{clientIdleTimeout, "2 minutes"},
|
|
{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"},
|
|
{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"},
|
|
{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"},
|
|
{asnLimitPercent, "AS14061:50,,AS16276:50"}, {asnLimitPercent, "ASX:50"},
|
|
{asnLimitPercent, "AS14061:"}, {asnLimitPercent, "AS14061 :50"},
|
|
{asnLimitPercent, "AS14061:1.5"}, {asnLimitPercent, "AS-1:50"},
|
|
{countryLimitPercent, "cn"}, {countryLimitPercent, "cn:"},
|
|
{countryLimitPercent, "cn:25:50"}, {countryLimitPercent, "china:25"},
|
|
{asnBytesPercent, "AS14061:101"}, {countryBytesPercent, "su:50"},
|
|
{unknownLimitPercent, ""}, {unknownLimitPercent, "-1"},
|
|
{unknownLimitPercent, "50%"},
|
|
{metricsTopN, off}, {metricsTopN, "0"}, {metricsTopN, "-1"},
|
|
{logRequestHeaders, "accept,,origin"}, {logRequestHeaders, "accept;origin"},
|
|
{logRequestHeaders, "accept language"}, {logRequestHeaders, "x-foo:"},
|
|
{logRequestHeaders, "host"}, {logRequestHeaders, "accept,Host"},
|
|
{logRequestHeaders, "transfer-encoding"}, {logRequestHeaders, "TRANSFER-ENCODING"},
|
|
{logLevel, "INFO"}, {logLevel, "warning"}, {logLevel, "trace"}, {logLevel, ""},
|
|
{rulesEnabled, "yes"}, {rulesEnabled, "True"},
|
|
})
|
|
}
|
|
|
|
func TestInvalidBanOrStateValueStopsTheStart(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
wantStartStopped(t, []struct{ name, value string }{
|
|
{banResponse, "404"}, {banResponse, "drop"}, {banResponse, ""},
|
|
{limitBanDuration, off}, {limitBanDuration, "0s"}, {limitBanDuration, "1"},
|
|
{limitBanRepeatWindow, off}, {limitBanRepeatWindow, "-1h"},
|
|
{maxBanDuration, off}, {maxBanDuration, "1w"}, {attackBanDuration, off},
|
|
{maxBans, off}, {maxBans, "0"}, {maxBans, "5K"},
|
|
{banScopeV4Prefix, "33"}, {banScopeV4Prefix, "-1"}, {banScopeV4Prefix, "/24"},
|
|
{stateDir, ""}, {stateDir, "state"}, {stateDir, "./var/lib/smallwebwaf"},
|
|
{stateWriteDelay, off}, {stateWriteDelay, "0s"},
|
|
{stateCounterInterval, off}, {stateCounterInterval, "15"},
|
|
})
|
|
}
|
|
|
|
// wantStartStopped checks that each setting, set to its value, stops the
|
|
// start with an error that names the setting.
|
|
func wantStartStopped(t *testing.T, invalid []struct{ name, value string }) {
|
|
t.Helper()
|
|
|
|
for _, tc := range invalid {
|
|
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 TestHostOrTransferEncodingStopsTheStart(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
// Only Host's message points to the field host.
|
|
for value, want := range map[string]string{
|
|
"Host": `"Host" is taken out of every request by Go's HTTP server, ` +
|
|
"so it can never be logged; the request's host is the field host",
|
|
"transfer-encoding": `"transfer-encoding" is taken out of every ` +
|
|
"request by Go's HTTP server, so it can never be logged",
|
|
} {
|
|
t.Run(value, func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
_, err := config.FromEnvironment(environment{logRequestHeaders: value}.lookupEnv)
|
|
if err == nil || err.Error() != logRequestHeaders+": "+want {
|
|
t.Errorf("error %v, want %s: %s", err, logRequestHeaders, want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestShortTokenStopsTheStartWithoutShowingIt(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
// Characters are counted, not bytes: each é takes two.
|
|
for _, name := range []string{adminToken, metricsToken} {
|
|
for _, value := range []string{"", token[1:], strings.Repeat("é", 31)} {
|
|
t.Run(name+"="+value, func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
_, err := config.FromEnvironment(environment{name: value}.lookupEnv)
|
|
|
|
want := name + ": is shorter than 32 characters"
|
|
if err == nil || err.Error() != want {
|
|
t.Errorf("error %v, want %s", err, want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestTokensAreReadAndLoggedMasked(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
cfg := fromEnvironment(t, environment{adminToken: otherToken, metricsToken: token})
|
|
if cfg.AdminToken != otherToken || cfg.MetricsToken != token {
|
|
t.Errorf("admin token %q and metrics token %q, want %q and %q",
|
|
cfg.AdminToken, cfg.MetricsToken, otherToken, token)
|
|
}
|
|
|
|
var out bytes.Buffer
|
|
|
|
slog.New(slog.NewJSONHandler(&out, nil)).Info("starting", "settings", cfg)
|
|
|
|
logged := out.String()
|
|
if strings.Contains(logged, token) || strings.Contains(logged, otherToken) ||
|
|
!strings.Contains(logged, `"`+adminToken+`":"********"`) ||
|
|
!strings.Contains(logged, `"`+metricsToken+`":"********"`) {
|
|
t.Errorf("the tokens are not logged masked: %s", logged)
|
|
}
|
|
}
|
|
|
|
func TestSettingFromFileLosesOneNewlineAndNoMore(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
for contents, want := range map[string]string{
|
|
token: token,
|
|
token + "\n": token,
|
|
token + "\n\n": token + "\n",
|
|
token + " \n": token + " ",
|
|
} {
|
|
cfg := fromEnvironment(t, environment{
|
|
metricsToken + "_FILE": writeFile(t, contents),
|
|
})
|
|
if cfg.MetricsToken != want {
|
|
t.Errorf("file holding %q gave %s %q, want %q", contents, metricsToken,
|
|
cfg.MetricsToken, want)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestSettingFromFileIsCheckedAsTheSettingItself(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
_, err := config.FromEnvironment(environment{
|
|
requestMaxBytes + "_FILE": writeFile(t, "lots\n"),
|
|
}.lookupEnv)
|
|
|
|
want := requestMaxBytes + `: "lots" is not a size such as 512K, 100M or 5G, or off`
|
|
if err == nil || err.Error() != want {
|
|
t.Errorf("error %v, want %s", err, want)
|
|
}
|
|
|
|
_, err = config.FromEnvironment(environment{
|
|
logRemoteURL: remoteURL,
|
|
instanceName: instance,
|
|
logRemoteAppName + "_FILE": writeFile(t, "my app\n"),
|
|
}.lookupEnv)
|
|
|
|
want = logRemoteAppName + `: "my app" is not 1 to 48 printable ASCII ` +
|
|
`characters without a space, such as gitea`
|
|
if err == nil || err.Error() != want {
|
|
t.Errorf("error %v, want %s", err, want)
|
|
}
|
|
}
|
|
|
|
func TestSettingAndItsFileBothSetStopsTheStart(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
_, err := config.FromEnvironment(environment{
|
|
metricsToken: token,
|
|
metricsToken + "_FILE": writeFile(t, token),
|
|
}.lookupEnv)
|
|
|
|
want := metricsToken + ": is set, and so is " + metricsToken +
|
|
"_FILE; set only one of them"
|
|
if err == nil || err.Error() != want {
|
|
t.Errorf("error %v, want %s", err, want)
|
|
}
|
|
}
|
|
|
|
func TestUnreadableSettingFileStopsTheStart(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
dir := t.TempDir()
|
|
|
|
for _, path := range []string{filepath.Join(dir, "missing"), dir} {
|
|
_, err := config.FromEnvironment(environment{metricsToken + "_FILE": path}.lookupEnv)
|
|
|
|
want := metricsToken + "_FILE: cannot be read: "
|
|
if err == nil || !strings.HasPrefix(err.Error(), want) {
|
|
t.Errorf("%s: error %v, want one starting %s", path, err, want)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestTokenFromFileIsLoggedMaskedWithTheFile(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
path := writeFile(t, token+"\n")
|
|
cfg := fromEnvironment(t, environment{metricsToken + "_FILE": path})
|
|
|
|
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)
|
|
}
|
|
|
|
if strings.Contains(out.String(), token) ||
|
|
line.Settings[metricsToken] != "********" ||
|
|
line.Settings[metricsToken+"_FILE"] != path {
|
|
t.Errorf("the token is not logged masked, with its file %s: %s", path,
|
|
out.String())
|
|
}
|
|
}
|
|
|
|
func TestRemoteLogCAFileIsNotReadAsAFileInItsTurn(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
cfg := fromEnvironment(t, environment{
|
|
logRemoteTLSCAFile + "_FILE": writeFile(t, "/nonexistent/ca.pem\n"),
|
|
})
|
|
if cfg.LogRemoteTLSCAs != nil {
|
|
t.Errorf("%s_FILE gave certificates", logRemoteTLSCAFile)
|
|
}
|
|
}
|
|
|
|
// writeFile writes contents to a file in a directory of its own, removed
|
|
// when the test ends, and returns the file's path.
|
|
func writeFile(t *testing.T, contents string) string {
|
|
t.Helper()
|
|
|
|
path := filepath.Join(t.TempDir(), "setting")
|
|
|
|
err := os.WriteFile(path, []byte(contents), 0o600)
|
|
if err != nil {
|
|
t.Fatalf("write %s: %v", path, err)
|
|
}
|
|
|
|
return path
|
|
}
|
|
|
|
//nolint:funlen // one line for each setting, a list that grows with them
|
|
func TestLogsEachSettingWithItsValue(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
cfg := fromEnvironment(t, environment{clientRequestTimeout: "45s"})
|
|
hostname, _ := os.Hostname()
|
|
|
|
want := map[string]string{
|
|
listenAddr: ":8080",
|
|
upstreamURL: "http://127.0.0.1:8081",
|
|
mode: "enforce",
|
|
trustedProxies: "10.0.0.0/8,172.16.0.0/12,192.168.0.0/16",
|
|
ipv6GroupPrefix: "64",
|
|
maxTrackedClients: "20000",
|
|
clientRequestTimeout: "45s",
|
|
clientHeaderMaxBytes: "32K",
|
|
clientIdleTimeout: "120s",
|
|
clientResponseTimeout: "30m",
|
|
upstreamRequestTimeout: "60s",
|
|
upstreamResponseTimeout: "30m",
|
|
requestMaxBytes: "100M",
|
|
responseMaxBytes: "5G",
|
|
allowNets: "",
|
|
rateLimitExemptNets: "",
|
|
denyNets: "",
|
|
rateLimitPerMinute: "1000",
|
|
rateLimitPerHour: "10000",
|
|
rateLimitPerDay: "50000",
|
|
rateLimitExemptPaths: "",
|
|
bytesLimitPerMinute: "10G",
|
|
bytesLimitPerHour: "20G",
|
|
bytesLimitPerDay: "50G",
|
|
bytesCount: "both",
|
|
lookupSource: defaultLookupSource,
|
|
lookupDBPath: "",
|
|
lookupTimeout: "1s",
|
|
addLookupHeaders: "false",
|
|
deniedCountries: "",
|
|
allowedCountries: "",
|
|
asnLimitPercent: "",
|
|
countryLimitPercent: "",
|
|
asnBytesPercent: "",
|
|
countryBytesPercent: "",
|
|
unknownLimitPercent: "100",
|
|
asnLimitPercentURL: "",
|
|
blocklistURLs: "",
|
|
blocklistRefresh: "24h",
|
|
blocklistAction: actionDeny,
|
|
dnsblZones: "",
|
|
dnsblResolver: "",
|
|
abuseIPDBKey: "",
|
|
abuseIPDBMinScore: "75",
|
|
abuseIPDBDailyBudget: "900",
|
|
reputationAction: "limit:25",
|
|
reputationCacheTTL: defaultReputationCacheTTL,
|
|
reputationTimeout: "2s",
|
|
crowdSecURL: "",
|
|
crowdSecKey: "",
|
|
banResponse: "403",
|
|
limitBanDuration: "1h",
|
|
limitBanRepeatWindow: "24h",
|
|
maxBanDuration: "7d",
|
|
attackBanDuration: "7d",
|
|
maxBans: "5000",
|
|
banScopeV4Prefix: "32",
|
|
stateDir: "/var/lib/smallwebwaf",
|
|
stateWriteDelay: "10s",
|
|
stateCounterInterval: "15m",
|
|
adminToken: "",
|
|
metricsToken: "",
|
|
metricsTopN: "50",
|
|
instanceName: hostname,
|
|
logRequestHeaders: defaultLogRequestHeaders,
|
|
logLevel: "info",
|
|
rulesDir: "/etc/smallwebwaf/rules.d",
|
|
rulesEnabled: "true",
|
|
wafMode: config.WAFModeBlock,
|
|
wafParanoiaLevel: "1",
|
|
wafAnomalyThreshold: "5",
|
|
wafDisabledRules: defaultWAFDisabledRules,
|
|
wafExemptPaths: "",
|
|
wafBodyLimit: off,
|
|
trapPaths: "",
|
|
errorBurstThreshold: "30",
|
|
logRemoteURL: "",
|
|
logRemoteTLSCAFile: "",
|
|
logRemoteBuffer: "10000",
|
|
logRemoteFacility: "local0",
|
|
logRemoteAppName: hostname,
|
|
alertWebhookURL: "",
|
|
alertWebhookHeaders: "",
|
|
alertSlackWebhookURL: "",
|
|
alertNtfyURL: "",
|
|
alertNtfyToken: "",
|
|
alertEvents: defaultAlertEvents,
|
|
alertCooldown: defaultAlertCooldown,
|
|
alertMaxPerHour: "60",
|
|
}
|
|
maps.Copy(want, loggedAnomalyDefaults())
|
|
|
|
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()
|
|
|
|
if got.ListenAddr != want.ListenAddr ||
|
|
got.Observe != want.Observe ||
|
|
got.ClientRequestTimeout != want.ClientRequestTimeout ||
|
|
got.ClientRequestHeaderMaxBytes != want.ClientRequestHeaderMaxBytes ||
|
|
got.ClientIdleTimeout != want.ClientIdleTimeout ||
|
|
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)
|
|
}
|
|
|
|
// 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()
|
|
|
|
if got.LookupSource != want.LookupSource || got.LookupTimeout != want.LookupTimeout ||
|
|
got.AddLookupHeaders != want.AddLookupHeaders {
|
|
t.Errorf("lookups from %q, waited for %s, headers %t; want %q, %s, %t",
|
|
got.LookupSource, got.LookupTimeout, got.AddLookupHeaders,
|
|
want.LookupSource, want.LookupTimeout, want.AddLookupHeaders)
|
|
}
|
|
}
|
|
|
|
// wantBanSettings checks the settings for bans, the state files, the
|
|
// metrics and the rule files.
|
|
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.AttackBanDuration != want.AttackBanDuration ||
|
|
got.MaxBans != want.MaxBans ||
|
|
got.BanScopeV4Prefix != want.BanScopeV4Prefix {
|
|
t.Errorf("ban settings\n%+v\nwant\n%+v", got, want)
|
|
}
|
|
|
|
if got.RulesDir != want.RulesDir || got.RulesEnabled != want.RulesEnabled {
|
|
t.Errorf("rule files in %q, on: %t, want %q, %t",
|
|
got.RulesDir, got.RulesEnabled, want.RulesDir, want.RulesEnabled)
|
|
}
|
|
|
|
if got.StateDir != want.StateDir ||
|
|
got.StateWriteDelay != want.StateWriteDelay ||
|
|
got.StateCounterInterval != want.StateCounterInterval {
|
|
t.Errorf("state settings\n%+v\nwant\n%+v", got, want)
|
|
}
|
|
|
|
if got.MetricsToken != want.MetricsToken || got.MetricsTopN != want.MetricsTopN {
|
|
t.Errorf("metrics token %q and top %d, want %q and %d",
|
|
got.MetricsToken, got.MetricsTopN, want.MetricsToken, want.MetricsTopN)
|
|
}
|
|
}
|
|
|
|
// 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)
|
|
}
|
|
}
|