check / check (push) Waiting to run
With SWWAF_ABUSEIPDB_KEY set, a client whose history counts an offence (a broken limit, a ban rule's match or a block rule's refusal, counted by kind) is checked in the background, at most SWWAF_ABUSEIPDB_DAILY_BUDGET checks a day, the count kept in reputation.json. A client, an IPv4 address or an IPv6 /64, is checked by the address it sent from, and its score serves all its addresses. A score at or over SWWAF_ABUSEIPDB_MIN_SCORE is a hit for SWWAF_REPUTATION_ACTION, logged as abuseipdb and alerted with its score. A failure or the used-up budget gives no score and raises source_failure. The key goes only in the Key header. Judgement call: the budget's day is UTC; AbuseIPDB documents no reset time. Judgement call: each check sent spends budget; a minute's pause after a failure. Model: opus-5-5
1948 lines
67 KiB
Go
1948 lines
67 KiB
Go
// Package config reads smallwebwaf's settings. Every setting is an
|
|
// environment variable whose name starts with SWWAF_, or a file such a
|
|
// variable names, every setting has a default, and this package is the
|
|
// one place they are read.
|
|
package config
|
|
|
|
import (
|
|
"crypto/x509"
|
|
"errors"
|
|
"fmt"
|
|
"log/slog"
|
|
"math"
|
|
"net"
|
|
"net/http"
|
|
"net/netip"
|
|
"net/url"
|
|
"os"
|
|
"path/filepath"
|
|
"slices"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
"unicode"
|
|
"unicode/utf8"
|
|
|
|
"sneak.berlin/go/smallwebwaf/internal/alerts"
|
|
"sneak.berlin/go/smallwebwaf/internal/anomaly"
|
|
"sneak.berlin/go/smallwebwaf/internal/remotelog"
|
|
)
|
|
|
|
// Config is smallwebwaf's settings. A timeout, size or rate limit of zero
|
|
// is off.
|
|
type Config struct {
|
|
// ListenAddr is where smallwebwaf listens (SWWAF_LISTEN_ADDR).
|
|
ListenAddr string
|
|
// UpstreamURL is the app (SWWAF_UPSTREAM_URL).
|
|
UpstreamURL *url.URL
|
|
// InstanceName is the name every log line and alert gives as instance,
|
|
// and every metric carries as its label instance (SWWAF_INSTANCE_NAME),
|
|
// by default the host's name, which docker sets to the first 12
|
|
// characters of the container's id.
|
|
InstanceName string
|
|
// Observe is true in observe mode, when SWWAF_MODE is observe rather
|
|
// than enforce: a request that SWWAF_DENY_NETS, a ban, the country
|
|
// lists, a rate limit or a rule would refuse is passed to the app
|
|
// instead, and no ban is made.
|
|
Observe bool
|
|
// TrustedProxies are the netblocks whose X-Forwarded-For is
|
|
// believed (SWWAF_TRUSTED_PROXIES).
|
|
TrustedProxies []netip.Prefix
|
|
// ClientRequestTimeout bounds reading the whole request from the
|
|
// client (SWWAF_CLIENT_REQUEST_TIMEOUT).
|
|
ClientRequestTimeout time.Duration
|
|
// ClientRequestHeaderMaxBytes is the largest request line and headers
|
|
// a client may send (SWWAF_CLIENT_REQUEST_HEADER_MAX_BYTES). It is
|
|
// never off, and always more than 4K.
|
|
ClientRequestHeaderMaxBytes int64
|
|
// ClientIdleTimeout bounds how long a kept-open client connection
|
|
// may wait for its next request (SWWAF_CLIENT_IDLE_TIMEOUT).
|
|
ClientIdleTimeout time.Duration
|
|
// ClientResponseTimeout bounds writing the whole response to the
|
|
// client (SWWAF_CLIENT_RESPONSE_TIMEOUT).
|
|
ClientResponseTimeout time.Duration
|
|
// UpstreamRequestTimeout bounds connecting to the app and writing
|
|
// the whole request to it (SWWAF_UPSTREAM_REQUEST_TIMEOUT).
|
|
UpstreamRequestTimeout time.Duration
|
|
// UpstreamResponseTimeout bounds reading the whole response from
|
|
// the app (SWWAF_UPSTREAM_RESPONSE_TIMEOUT).
|
|
UpstreamResponseTimeout time.Duration
|
|
// RequestMaxBytes is the largest request body
|
|
// (SWWAF_REQUEST_MAX_BYTES).
|
|
RequestMaxBytes int64
|
|
// ResponseMaxBytes is the largest response body
|
|
// (SWWAF_RESPONSE_MAX_BYTES).
|
|
ResponseMaxBytes int64
|
|
// AllowNets are the netblocks whose clients skip every check
|
|
// (SWWAF_ALLOW_NETS). RateLimitExemptNets are those whose clients the
|
|
// rate limits neither count nor refuse (SWWAF_RATE_LIMIT_EXEMPT_NETS).
|
|
// DenyNets are those whose clients are always refused
|
|
// (SWWAF_DENY_NETS).
|
|
AllowNets []netip.Prefix
|
|
RateLimitExemptNets []netip.Prefix
|
|
DenyNets []netip.Prefix
|
|
// RateLimitPerMinute, RateLimitPerHour and RateLimitPerDay are the
|
|
// most requests a client may make in a minute, an hour and a day
|
|
// (SWWAF_RATE_LIMIT_PER_MINUTE, SWWAF_RATE_LIMIT_PER_HOUR and
|
|
// SWWAF_RATE_LIMIT_PER_DAY).
|
|
RateLimitPerMinute int64
|
|
RateLimitPerHour int64
|
|
RateLimitPerDay int64
|
|
// RateLimitExemptPaths are the path prefixes whose requests the rate
|
|
// limits neither count nor refuse (SWWAF_RATE_LIMIT_EXEMPT_PATHS).
|
|
// Each starts with /.
|
|
RateLimitExemptPaths []string
|
|
// BytesLimitPerMinute, BytesLimitPerHour and BytesLimitPerDay are the
|
|
// most bytes a client's requests may carry in a minute, an hour and a
|
|
// day (SWWAF_BYTES_LIMIT_PER_MINUTE, SWWAF_BYTES_LIMIT_PER_HOUR and
|
|
// SWWAF_BYTES_LIMIT_PER_DAY). BytesCount is which body bytes count
|
|
// toward them (SWWAF_BYTES_COUNT): response, request or both.
|
|
BytesLimitPerMinute int64
|
|
BytesLimitPerHour int64
|
|
BytesLimitPerDay int64
|
|
BytesCount string
|
|
// LookupSource is where each client's AS number and country are
|
|
// looked up (SWWAF_LOOKUP_SOURCE): geojs, file, or off for nowhere.
|
|
// LookupDBPath is the lookup database, the IPinfo Lite file looked up
|
|
// in while LookupSource is file (SWWAF_LOOKUP_DB_PATH), and "" for any
|
|
// other source. A request waits up to LookupTimeout for its client's
|
|
// first answer from GeoJS while a setting needs it
|
|
// (SWWAF_LOOKUP_TIMEOUT), which cannot be off. AddLookupHeaders is true
|
|
// when the app is passed the client's AS number and country in headers
|
|
// (SWWAF_ADD_LOOKUP_HEADERS).
|
|
LookupSource string
|
|
LookupDBPath string
|
|
LookupTimeout time.Duration
|
|
AddLookupHeaders bool
|
|
// DeniedCountries are the countries whose clients are refused
|
|
// (SWWAF_DENIED_COUNTRIES). ExclusivelyAllowedCountries, when not
|
|
// empty, are the only countries whose clients are let through
|
|
// (SWWAF_EXCLUSIVELY_ALLOWED_COUNTRIES). Both hold two-letter codes in
|
|
// capitals, as GeoJS gives them.
|
|
DeniedCountries []string
|
|
ExclusivelyAllowedCountries []string
|
|
// The biased thresholds. ASNLimitPercent and CountryLimitPercent give
|
|
// the clients of the AS numbers and the countries they list that
|
|
// percentage of every rate limit and byte limit
|
|
// (SWWAF_ASN_LIMIT_PERCENT and SWWAF_COUNTRY_LIMIT_PERCENT).
|
|
// ASNBytesPercent and CountryBytesPercent give those they list a
|
|
// percentage of the byte limits in place of that one
|
|
// (SWWAF_ASN_BYTES_PERCENT and SWWAF_COUNTRY_BYTES_PERCENT). Each holds
|
|
// percentages from 0 to 100, by AS number, written as AS64496, or by
|
|
// country, a two-letter code in capitals, as the lookup gives them.
|
|
// UnknownLimitPercent is the percentage of every limit a client without
|
|
// a country gets (SWWAF_UNKNOWN_LIMIT_PERCENT). ASNLimitPercentURL is
|
|
// where a file of AS:percent lines is fetched from, whose percentages
|
|
// count as those of ASNLimitPercent do (SWWAF_ASN_LIMIT_PERCENT_URL), ""
|
|
// while it is unset.
|
|
ASNLimitPercent map[string]int64
|
|
CountryLimitPercent map[string]int64
|
|
ASNBytesPercent map[string]int64
|
|
CountryBytesPercent map[string]int64
|
|
UnknownLimitPercent int64
|
|
ASNLimitPercentURL string
|
|
// BlocklistURLs are where the blocklists are fetched from
|
|
// (SWWAF_BLOCKLIST_URLS). Each list, and ASNLimitPercentURL's, is
|
|
// fetched again BlocklistRefresh after it was last fetched or tried
|
|
// (SWWAF_BLOCKLIST_REFRESH), which is never less than an hour.
|
|
// BlocklistAction is what is done with a client a blocklist lists
|
|
// (SWWAF_BLOCKLIST_ACTION): deny, limit or log; for limit,
|
|
// BlocklistLimitPercent is the percentage of every limit it gets.
|
|
BlocklistURLs []string
|
|
BlocklistRefresh time.Duration
|
|
BlocklistAction string
|
|
BlocklistLimitPercent int64
|
|
// DNSBLZones are the DNSBL zones clients are asked about
|
|
// (SWWAF_DNSBL_ZONES), through DNSBLResolver (SWWAF_DNSBL_RESOLVER), or
|
|
// the host's resolver while that is the zero AddrPort.
|
|
// AbuseIPDBKey is the key of the AbuseIPDB account clients are checked
|
|
// with (SWWAF_ABUSEIPDB_KEY), "" while it is unset and none is. A score
|
|
// of AbuseIPDBMinScore or more is a hit (SWWAF_ABUSEIPDB_MIN_SCORE), and
|
|
// at most AbuseIPDBDailyBudget checks are made a day
|
|
// (SWWAF_ABUSEIPDB_DAILY_BUDGET).
|
|
// ReputationAction is what is done with a client a zone's verdict lists,
|
|
// or whose score is a hit (SWWAF_REPUTATION_ACTION): deny, limit or log;
|
|
// for limit, ReputationLimitPercent is the percentage of every limit it
|
|
// gets. A verdict or a score is used for ReputationCacheTTL after it was
|
|
// fetched (SWWAF_REPUTATION_CACHE_TTL), and a query or a check may take
|
|
// ReputationTimeout (SWWAF_REPUTATION_TIMEOUT). Neither can be off.
|
|
DNSBLZones []string
|
|
DNSBLResolver netip.AddrPort
|
|
AbuseIPDBKey string
|
|
AbuseIPDBMinScore int64
|
|
AbuseIPDBDailyBudget int
|
|
ReputationAction string
|
|
ReputationLimitPercent int64
|
|
ReputationCacheTTL time.Duration
|
|
ReputationTimeout time.Duration
|
|
// BanResponse is the status a refused client is answered with, 403
|
|
// or 429, or 0 to close the connection without an answer
|
|
// (SWWAF_BAN_RESPONSE). It answers a banned client, a request that
|
|
// breaks a rate limit or matches a ban rule, SWWAF_DENY_NETS and the
|
|
// country lists.
|
|
BanResponse int
|
|
// LimitBanDuration is the ban for a first broken rate limit
|
|
// (SWWAF_LIMIT_BAN_DURATION). A limit broken again within
|
|
// LimitBanRepeatWindow after the last ban ended bans for three times
|
|
// as long as that ban (SWWAF_LIMIT_BAN_REPEAT_WINDOW), and a ban that
|
|
// would be longer than MaxBanDuration is permanent instead
|
|
// (SWWAF_MAX_BAN_DURATION). None of them can be off.
|
|
LimitBanDuration time.Duration
|
|
LimitBanRepeatWindow time.Duration
|
|
MaxBanDuration time.Duration
|
|
// AttackBanDuration is the ban for a first clear sign of attack
|
|
// (SWWAF_ATTACK_BAN_DURATION). It cannot be off.
|
|
AttackBanDuration time.Duration
|
|
// MaxBans is the most bans held (SWWAF_MAX_BANS).
|
|
MaxBans int
|
|
// BanScopeV4Prefix is the length of the netblock around an IPv4
|
|
// client that a ban covers (SWWAF_BAN_SCOPE_V4_PREFIX).
|
|
BanScopeV4Prefix int
|
|
// StateDir is the directory of the state files, an absolute path
|
|
// (SWWAF_STATE_DIR). bans.json is written StateWriteDelay after a ban
|
|
// is made (SWWAF_STATE_WRITE_DELAY), and every state file every
|
|
// StateCounterInterval (SWWAF_STATE_COUNTER_INTERVAL). Neither can be
|
|
// off.
|
|
StateDir string
|
|
StateWriteDelay time.Duration
|
|
StateCounterInterval time.Duration
|
|
// LogRequestHeaders are the request headers whose values the request
|
|
// log gives, in lower case (SWWAF_LOG_REQUEST_HEADERS).
|
|
LogRequestHeaders []string
|
|
// AdminToken is the bearer token an admin sends for the ban endpoints
|
|
// and /_smallwebwaf/clients/<ip> (SWWAF_ADMIN_TOKEN), "" while it is
|
|
// unset and they are off.
|
|
AdminToken string
|
|
// MetricsToken is the bearer token a scraper sends for the metrics
|
|
// (SWWAF_METRICS_TOKEN), "" while it is unset and the metrics are off.
|
|
// MetricsTopN is how many AS numbers and how many countries get series
|
|
// of their own in the metrics (SWWAF_METRICS_TOP_N).
|
|
MetricsToken string
|
|
MetricsTopN int
|
|
// RulesDir is the directory of the rule files (SWWAF_RULES_DIR), read
|
|
// unless RulesEnabled is false (SWWAF_RULES_ENABLED).
|
|
RulesDir string
|
|
RulesEnabled bool
|
|
// LogRemoteURL is where every line on stdout is also sent
|
|
// (SWWAF_LOG_REMOTE_URL), nil while it is unset and nothing is sent.
|
|
// LogRemoteTLSCAs are the certificates a syslog+tls endpoint's
|
|
// certificate must chain to (SWWAF_LOG_REMOTE_TLS_CA_FILE), nil while
|
|
// it is unset and the host's own are used. LogRemoteBuffer is the most
|
|
// lines held while they wait to be sent (SWWAF_LOG_REMOTE_BUFFER).
|
|
// LogRemoteFacility is the number of the syslog facility
|
|
// (SWWAF_LOG_REMOTE_FACILITY), and LogRemoteAppName the APP-NAME
|
|
// (SWWAF_LOG_REMOTE_APP_NAME, by default InstanceName), of the records
|
|
// the lines are sent in.
|
|
LogRemoteURL *url.URL
|
|
LogRemoteTLSCAs *x509.CertPool
|
|
LogRemoteBuffer int
|
|
LogRemoteFacility int
|
|
LogRemoteAppName string
|
|
// AlertWebhookURL is where each alert is posted as JSON
|
|
// (SWWAF_ALERT_WEBHOOK_URL), nil while it is unset. AlertWebhookHeaders
|
|
// are sent with each (SWWAF_ALERT_WEBHOOK_HEADERS).
|
|
// AlertSlackWebhookURL is the Slack incoming webhook each alert is
|
|
// posted to as a message (SWWAF_ALERT_SLACK_WEBHOOK_URL), and
|
|
// AlertNtfyURL the ntfy topic each is published to
|
|
// (SWWAF_ALERT_NTFY_URL), each nil while it is unset; AlertNtfyToken,
|
|
// unless empty, is sent to ntfy with each (SWWAF_ALERT_NTFY_TOKEN).
|
|
// With none of the three URLs set, no alert is sent. AlertEvents are
|
|
// the events alerts are sent for (SWWAF_ALERT_EVENTS). A repeat of an
|
|
// alert within AlertCooldown is held back (SWWAF_ALERT_COOLDOWN), and
|
|
// so is an alert past AlertMaxPerHour in an hour, for the hour's
|
|
// summary (SWWAF_ALERT_MAX_PER_HOUR); 0 is off for both.
|
|
AlertWebhookURL *url.URL
|
|
AlertWebhookHeaders http.Header
|
|
AlertSlackWebhookURL *url.URL
|
|
AlertNtfyURL *url.URL
|
|
AlertNtfyToken string
|
|
AlertEvents []string
|
|
AlertCooldown time.Duration
|
|
AlertMaxPerHour int
|
|
// The anomaly thresholds, which only raise alerts: the most requests
|
|
// and bytes a minute and an hour per client (SWWAF_ANOMALY_CLIENT_*),
|
|
// per netblock around a client (SWWAF_ANOMALY_NET_*), per AS number
|
|
// (SWWAF_ANOMALY_ASN_*), for the whole service (SWWAF_ANOMALY_TOTAL_*)
|
|
// and per named netblock (SWWAF_WATCH_*), each 0 while it is off.
|
|
// AnomalyNetV4Prefix and AnomalyNetV6Prefix are the lengths of the
|
|
// netblock around a client (SWWAF_ANOMALY_NET_V4_PREFIX and
|
|
// SWWAF_ANOMALY_NET_V6_PREFIX), and WatchNets the named netblocks
|
|
// (SWWAF_WATCH_NETS).
|
|
AnomalyClient anomaly.Thresholds
|
|
AnomalyNet anomaly.Thresholds
|
|
AnomalyASN anomaly.Thresholds
|
|
AnomalyTotal anomaly.Thresholds
|
|
AnomalyWatch anomaly.Thresholds
|
|
AnomalyNetV4Prefix int
|
|
AnomalyNetV6Prefix int
|
|
WatchNets []anomaly.NamedNetblock
|
|
|
|
// settings are the values read, as given or by default, and the
|
|
// files they were read from, for the log line at start.
|
|
settings []slog.Attr
|
|
}
|
|
|
|
// off is the value that switches a timeout, a size limit or a rate limit
|
|
// off.
|
|
const off = "off"
|
|
|
|
// fileSource is the SWWAF_LOOKUP_SOURCE that looks clients up in the
|
|
// lookup database, the file SWWAF_LOOKUP_DB_PATH names.
|
|
const fileSource = "file"
|
|
|
|
const (
|
|
day = 24 * time.Hour
|
|
kibibyte = 1 << 10
|
|
mebibyte = 1 << 20
|
|
gibibyte = 1 << 30
|
|
ipv4Bits = 32
|
|
ipv6Bits = 128
|
|
// minTokenLength is the fewest characters a token may have.
|
|
minTokenLength = 32
|
|
// masked is what the log shows for a token that is set, and in place of
|
|
// a secret in another setting.
|
|
masked = "********"
|
|
// defaultListenAddr and defaultUpstreamURL are the defaults of
|
|
// SWWAF_LISTEN_ADDR and SWWAF_UPSTREAM_URL.
|
|
defaultListenAddr = ":8080"
|
|
defaultUpstreamURL = "http://127.0.0.1:8081"
|
|
)
|
|
|
|
var (
|
|
errNotDuration = errors.New(
|
|
"is not a duration such as 90s, 15m or 7d, or off")
|
|
errNotSize = errors.New(
|
|
"is not a size such as 512K, 100M or 5G, or off")
|
|
errNotCount = errors.New(
|
|
"is not a whole number of requests such as 1000, or off")
|
|
errNotPositive = errors.New("must be more than zero, or off")
|
|
errEmptyItem = errors.New("has an empty item in its list")
|
|
errNotNetblock = errors.New(
|
|
"is not a netblock such as 10.0.0.0/8, or an address")
|
|
errNotListenAddr = errors.New(
|
|
"is not an address to listen on, such as :8080")
|
|
errNotUpstreamURL = errors.New(
|
|
"is not a URL with only a scheme, a host and an optional port, " +
|
|
"such as http://127.0.0.1:8081")
|
|
errNotCountry = errors.New(
|
|
"is not a two-letter country code such as de or kp")
|
|
errNotHeaderName = errors.New(
|
|
"is not a header name such as accept-language")
|
|
errHeaderTakenOut = errors.New(
|
|
"is taken out of every request by Go's HTTP server, so it can never " +
|
|
"be logged")
|
|
errOnBothLists = errors.New("is in SWWAF_DENIED_COUNTRIES too")
|
|
errNotLookupSource = errors.New("is not geojs, file or off")
|
|
errNeedsLookups = errors.New("it needs each client looked up")
|
|
errNeedsDBPath = errors.New("it names the file to look clients up in")
|
|
errDBPathUnused = errors.New("only file reads it")
|
|
errNotOver4K = errors.New("is not a size of more than 4K, such as 32K")
|
|
errNotDurationAboveZero = errors.New(
|
|
"is not a duration above zero, such as 1h or 7d")
|
|
errNotNumberAboveZero = errors.New(
|
|
"is not a whole number above zero, such as 5000")
|
|
errNotBanResponse = errors.New("is not 403, 429 or close")
|
|
errNotV4Prefix = errors.New(
|
|
"is not the length of an IPv4 netblock, from 0 to 32, such as 24")
|
|
errNotV6Prefix = errors.New(
|
|
"is not the length of an IPv6 netblock, from 0 to 128, such as 48")
|
|
errNotNamedNetblock = errors.New(
|
|
"is not a name, = and a netblock, such as office=203.0.113.0/24")
|
|
errNotAbsolutePath = errors.New(
|
|
"is not an absolute path, such as /var/lib/smallwebwaf")
|
|
errShortToken = errors.New("is shorter than 32 characters")
|
|
errNotMode = errors.New("is not enforce or observe")
|
|
errNotBytesCount = errors.New("is not response, request or both")
|
|
errNotPathPrefix = errors.New(
|
|
"is not a path prefix starting with /, such as /assets/")
|
|
errNotBoolean = errors.New("is not true or false")
|
|
errNotLogRemoteURL = errors.New(
|
|
"is not syslog+udp, syslog+tcp or syslog+tls with a host and a port, " +
|
|
"and nothing more, such as syslog+tls://logs.example:6514")
|
|
errNoCertificate = errors.New("holds no PEM certificate")
|
|
errNotFacility = errors.New("is not a syslog facility such as local0 or daemon")
|
|
errNotAppName = errors.New(
|
|
"is not 1 to 48 printable ASCII characters without a space, such as gitea")
|
|
errSetTwice = errors.New("set only one of them")
|
|
errNotWebhookURL = errors.New(
|
|
"is not an http or https URL without a user or a fragment, " +
|
|
"such as https://alerts.example/smallwebwaf")
|
|
errNotWebhookHeader = errors.New(
|
|
"is not a header name followed by : and the header's value, " +
|
|
"such as Authorization:Bearer <token>")
|
|
errControlCharacter = errors.New(
|
|
"holds a control character, such as the carriage return of a Windows line end")
|
|
errNotAlertEvent = errors.New(
|
|
"is not ban, permanent_ban, waf_block, anomaly, reputation_hit, " +
|
|
"source_failure or file_error")
|
|
errNotNumberOrOff = errors.New("is not a whole number above zero, such as 60, or off")
|
|
errNotUTF8 = errors.New("is not valid UTF-8")
|
|
errNotASN = errors.New("is not an AS number such as AS64496")
|
|
errNotPercentItem = errors.New(
|
|
"is not a code, : and a percentage, such as AS64496:50 or cn:25")
|
|
errNotPercent = errors.New("is not a percentage, a whole number from 0 to 100")
|
|
errListedTwice = errors.New("is listed twice")
|
|
errNotListURL = errors.New(
|
|
"is not an http or https URL without a user or a fragment, " +
|
|
"such as https://www.spamhaus.org/drop/drop.txt")
|
|
errInBlocklistURLs = errors.New("is in SWWAF_BLOCKLIST_URLS too")
|
|
errNotAnHourOrMore = errors.New("is not a duration of 1h or more, such as 24h")
|
|
errNotAction = errors.New(
|
|
"is not deny, limit:<percent> such as limit:25, or log")
|
|
errNotZone = errors.New("is not a DNS zone such as dnsbl.dronebl.org")
|
|
errZoneTooLong = errors.New("is longer than 189 characters, too long for the " +
|
|
"names IPv6 clients are asked about by")
|
|
errNotResolver = errors.New("is not an IP address with an optional port, " +
|
|
"such as 192.0.2.53 or [2001:db8::53]:5353")
|
|
)
|
|
|
|
// FromEnvironment reads the settings with lookupEnv, normally
|
|
// os.LookupEnv. A setting may instead be given as a file: the variable
|
|
// named by the setting's name with _FILE added names the file, which is
|
|
// read now (see lookup). A setting that is not set takes its default. A
|
|
// setting that is set but invalid is an error that names it.
|
|
//
|
|
//nolint:funlen // one line for each setting, a list that grows with them
|
|
func FromEnvironment(lookupEnv func(string) (string, bool)) (*Config, error) {
|
|
env := &environment{lookupEnv: lookupEnv}
|
|
cfg := &Config{
|
|
ListenAddr: env.address("SWWAF_LISTEN_ADDR", defaultListenAddr),
|
|
UpstreamURL: env.appURL("SWWAF_UPSTREAM_URL", defaultUpstreamURL),
|
|
InstanceName: env.instanceName(),
|
|
Observe: env.observe("SWWAF_MODE", "enforce"),
|
|
TrustedProxies: env.netblocks("SWWAF_TRUSTED_PROXIES", privateRanges),
|
|
ClientRequestTimeout: env.duration("SWWAF_CLIENT_REQUEST_TIMEOUT", "60s"),
|
|
ClientRequestHeaderMaxBytes: env.headerSize(
|
|
"SWWAF_CLIENT_REQUEST_HEADER_MAX_BYTES", "32K"),
|
|
ClientIdleTimeout: env.duration("SWWAF_CLIENT_IDLE_TIMEOUT", "120s"),
|
|
ClientResponseTimeout: env.duration("SWWAF_CLIENT_RESPONSE_TIMEOUT", "30m"),
|
|
UpstreamRequestTimeout: env.duration("SWWAF_UPSTREAM_REQUEST_TIMEOUT", "60s"),
|
|
UpstreamResponseTimeout: env.duration("SWWAF_UPSTREAM_RESPONSE_TIMEOUT", "30m"),
|
|
RequestMaxBytes: env.size("SWWAF_REQUEST_MAX_BYTES", "100M"),
|
|
ResponseMaxBytes: env.size("SWWAF_RESPONSE_MAX_BYTES", "5G"),
|
|
AllowNets: env.netblocks("SWWAF_ALLOW_NETS", ""),
|
|
RateLimitExemptNets: env.netblocks("SWWAF_RATE_LIMIT_EXEMPT_NETS", ""),
|
|
DenyNets: env.netblocks("SWWAF_DENY_NETS", ""),
|
|
RateLimitPerMinute: env.count("SWWAF_RATE_LIMIT_PER_MINUTE", "1000"),
|
|
RateLimitPerHour: env.count("SWWAF_RATE_LIMIT_PER_HOUR", "10000"),
|
|
RateLimitPerDay: env.count("SWWAF_RATE_LIMIT_PER_DAY", "50000"),
|
|
RateLimitExemptPaths: env.pathPrefixes("SWWAF_RATE_LIMIT_EXEMPT_PATHS", ""),
|
|
BytesLimitPerMinute: env.size("SWWAF_BYTES_LIMIT_PER_MINUTE", "10G"),
|
|
BytesLimitPerHour: env.size("SWWAF_BYTES_LIMIT_PER_HOUR", "20G"),
|
|
BytesLimitPerDay: env.size("SWWAF_BYTES_LIMIT_PER_DAY", "50G"),
|
|
BytesCount: env.bytesCount("SWWAF_BYTES_COUNT", "both"),
|
|
LookupSource: env.lookupSource("SWWAF_LOOKUP_SOURCE", "geojs"),
|
|
LookupDBPath: env.value("SWWAF_LOOKUP_DB_PATH", ""),
|
|
LookupTimeout: env.durationNotOff("SWWAF_LOOKUP_TIMEOUT", "1s"),
|
|
AddLookupHeaders: env.boolean("SWWAF_ADD_LOOKUP_HEADERS", "false"),
|
|
DeniedCountries: env.countries("SWWAF_DENIED_COUNTRIES", ""),
|
|
ExclusivelyAllowedCountries: env.countries(
|
|
"SWWAF_EXCLUSIVELY_ALLOWED_COUNTRIES", ""),
|
|
ASNLimitPercent: env.percents("SWWAF_ASN_LIMIT_PERCENT", ParseASN),
|
|
CountryLimitPercent: env.percents("SWWAF_COUNTRY_LIMIT_PERCENT", parseCountry),
|
|
ASNBytesPercent: env.percents("SWWAF_ASN_BYTES_PERCENT", ParseASN),
|
|
CountryBytesPercent: env.percents("SWWAF_COUNTRY_BYTES_PERCENT", parseCountry),
|
|
UnknownLimitPercent: env.percent("SWWAF_UNKNOWN_LIMIT_PERCENT", "100"),
|
|
ASNLimitPercentURL: env.listURL("SWWAF_ASN_LIMIT_PERCENT_URL"),
|
|
BlocklistURLs: env.listURLs("SWWAF_BLOCKLIST_URLS"),
|
|
BlocklistRefresh: env.refresh("SWWAF_BLOCKLIST_REFRESH", "24h"),
|
|
DNSBLZones: env.zones("SWWAF_DNSBL_ZONES"),
|
|
DNSBLResolver: env.resolver("SWWAF_DNSBL_RESOLVER"),
|
|
AbuseIPDBKey: env.secret("SWWAF_ABUSEIPDB_KEY"),
|
|
AbuseIPDBMinScore: env.percent("SWWAF_ABUSEIPDB_MIN_SCORE", "75"),
|
|
AbuseIPDBDailyBudget: env.numberNotOff("SWWAF_ABUSEIPDB_DAILY_BUDGET", "900"),
|
|
ReputationCacheTTL: env.durationNotOff("SWWAF_REPUTATION_CACHE_TTL", "24h"),
|
|
ReputationTimeout: env.durationNotOff("SWWAF_REPUTATION_TIMEOUT", "2s"),
|
|
BanResponse: env.banResponse("SWWAF_BAN_RESPONSE", "403"),
|
|
LimitBanDuration: env.durationNotOff("SWWAF_LIMIT_BAN_DURATION", "1h"),
|
|
LimitBanRepeatWindow: env.durationNotOff("SWWAF_LIMIT_BAN_REPEAT_WINDOW", "24h"),
|
|
MaxBanDuration: env.durationNotOff("SWWAF_MAX_BAN_DURATION", "7d"),
|
|
AttackBanDuration: env.durationNotOff("SWWAF_ATTACK_BAN_DURATION", "7d"),
|
|
MaxBans: env.numberNotOff("SWWAF_MAX_BANS", "5000"),
|
|
BanScopeV4Prefix: env.v4Prefix("SWWAF_BAN_SCOPE_V4_PREFIX", "32"),
|
|
StateDir: env.absolutePath("SWWAF_STATE_DIR", "/var/lib/smallwebwaf"),
|
|
StateWriteDelay: env.durationNotOff("SWWAF_STATE_WRITE_DELAY", "10s"),
|
|
StateCounterInterval: env.durationNotOff("SWWAF_STATE_COUNTER_INTERVAL", "15m"),
|
|
LogRequestHeaders: env.headerNames("SWWAF_LOG_REQUEST_HEADERS",
|
|
"accept,accept-language,accept-encoding,content-type,origin,range"),
|
|
AdminToken: env.token("SWWAF_ADMIN_TOKEN"),
|
|
MetricsToken: env.token("SWWAF_METRICS_TOKEN"),
|
|
MetricsTopN: env.numberNotOff("SWWAF_METRICS_TOP_N", "50"),
|
|
RulesDir: env.value("SWWAF_RULES_DIR", "/etc/smallwebwaf/rules.d"),
|
|
RulesEnabled: env.boolean("SWWAF_RULES_ENABLED", "true"),
|
|
LogRemoteURL: env.logRemoteURL("SWWAF_LOG_REMOTE_URL"),
|
|
LogRemoteTLSCAs: env.certificates("SWWAF_LOG_REMOTE_TLS_CA_FILE"),
|
|
LogRemoteBuffer: env.numberNotOff("SWWAF_LOG_REMOTE_BUFFER", "10000"),
|
|
LogRemoteFacility: env.facility("SWWAF_LOG_REMOTE_FACILITY", "local0"),
|
|
AlertWebhookURL: env.webhookURL("SWWAF_ALERT_WEBHOOK_URL"),
|
|
AlertWebhookHeaders: env.webhookHeaders("SWWAF_ALERT_WEBHOOK_HEADERS"),
|
|
AlertSlackWebhookURL: env.webhookURL("SWWAF_ALERT_SLACK_WEBHOOK_URL"),
|
|
AlertNtfyURL: env.webhookURL("SWWAF_ALERT_NTFY_URL"),
|
|
AlertNtfyToken: env.secret("SWWAF_ALERT_NTFY_TOKEN"),
|
|
AlertEvents: env.alertEvents("SWWAF_ALERT_EVENTS",
|
|
strings.Join(alerts.Events(), ",")),
|
|
AlertCooldown: env.duration("SWWAF_ALERT_COOLDOWN", "15m"),
|
|
AlertMaxPerHour: env.numberOrOff("SWWAF_ALERT_MAX_PER_HOUR", "60"),
|
|
AnomalyClient: env.thresholds("SWWAF_ANOMALY_CLIENT_"),
|
|
AnomalyNet: env.thresholds("SWWAF_ANOMALY_NET_"),
|
|
AnomalyASN: env.thresholds("SWWAF_ANOMALY_ASN_"),
|
|
AnomalyTotal: env.thresholds("SWWAF_ANOMALY_TOTAL_"),
|
|
AnomalyWatch: env.thresholds("SWWAF_WATCH_"),
|
|
AnomalyNetV4Prefix: env.v4Prefix("SWWAF_ANOMALY_NET_V4_PREFIX", "24"),
|
|
AnomalyNetV6Prefix: env.v6Prefix("SWWAF_ANOMALY_NET_V6_PREFIX", "48"),
|
|
WatchNets: env.namedNetblocks("SWWAF_WATCH_NETS"),
|
|
}
|
|
|
|
cfg.LogRemoteAppName = env.appName("SWWAF_LOG_REMOTE_APP_NAME",
|
|
cfg.InstanceName, cfg.LogRemoteURL != nil)
|
|
cfg.BlocklistAction, cfg.BlocklistLimitPercent = env.action(
|
|
"SWWAF_BLOCKLIST_ACTION", "deny")
|
|
cfg.ReputationAction, cfg.ReputationLimitPercent = env.action(
|
|
"SWWAF_REPUTATION_ACTION", "limit:25")
|
|
|
|
env.checkInstanceNameForNtfy(cfg.InstanceName, cfg.AlertNtfyURL != nil)
|
|
env.checkLookupDBPath(cfg)
|
|
env.checkCountriesAndLookups(cfg)
|
|
env.checkASNLimitPercentURL(cfg)
|
|
|
|
if env.err != nil {
|
|
return nil, env.err
|
|
}
|
|
|
|
cfg.settings = env.settings
|
|
|
|
return cfg, nil
|
|
}
|
|
|
|
// ListenAddrAndUpstreamURL reads only SWWAF_LISTEN_ADDR and
|
|
// SWWAF_UPSTREAM_URL, either of which may be given as a file, as
|
|
// FromEnvironment does. The health check needs no other setting, so it
|
|
// reads no other, nor a file that another names.
|
|
func ListenAddrAndUpstreamURL(
|
|
lookupEnv func(string) (string, bool),
|
|
) (string, *url.URL, error) {
|
|
env := &environment{lookupEnv: lookupEnv}
|
|
listenAddr := env.address("SWWAF_LISTEN_ADDR", defaultListenAddr)
|
|
upstreamURL := env.appURL("SWWAF_UPSTREAM_URL", defaultUpstreamURL)
|
|
|
|
if env.err != nil {
|
|
return "", nil, env.err
|
|
}
|
|
|
|
return listenAddr, upstreamURL, nil
|
|
}
|
|
|
|
// InstanceName reads only SWWAF_INSTANCE_NAME, which may be given as a
|
|
// file, as FromEnvironment does, so that the line saying a setting is
|
|
// invalid carries it too. A file that cannot be read gives the default
|
|
// here, and FromEnvironment then stops the start over it.
|
|
func InstanceName(lookupEnv func(string) (string, bool)) string {
|
|
env := &environment{lookupEnv: lookupEnv}
|
|
|
|
return env.instanceName()
|
|
}
|
|
|
|
// privateRanges are the private address ranges, the default trusted
|
|
// proxies.
|
|
const privateRanges = "10.0.0.0/8,172.16.0.0/12,192.168.0.0/16"
|
|
|
|
// LogValue makes a Config log as each setting's name with the value it was
|
|
// given, or its default.
|
|
func (c *Config) LogValue() slog.Value {
|
|
return slog.GroupValue(c.settings...)
|
|
}
|
|
|
|
// environment is where FromEnvironment reads the settings: it notes each
|
|
// value for the log, and keeps the first error.
|
|
type environment struct {
|
|
lookupEnv func(string) (string, bool)
|
|
settings []slog.Attr
|
|
err error
|
|
}
|
|
|
|
// value returns a setting's value, or its default when it is not set,
|
|
// and notes it for the log.
|
|
func (e *environment) value(name, defaultValue string) string {
|
|
value, set := e.lookup(name)
|
|
if !set {
|
|
value = defaultValue
|
|
}
|
|
|
|
e.settings = append(e.settings, slog.String(name, value))
|
|
|
|
return value
|
|
}
|
|
|
|
// lookup returns a setting's value and whether it is set: the value of the
|
|
// variable name, or the contents of the file that the variable name_FILE
|
|
// names, less one newline at their end. It notes that file's path for the
|
|
// log. Both variables set, or a file that cannot be read, is an error.
|
|
func (e *environment) lookup(name string) (string, bool) {
|
|
value, set := e.lookupEnv(name)
|
|
fileName := name + "_FILE"
|
|
|
|
path, inFile := e.lookupEnv(fileName)
|
|
if !inFile {
|
|
return value, set
|
|
}
|
|
|
|
if set {
|
|
e.check(name, fmt.Errorf("is set, and so is %s; %w", fileName, errSetTwice))
|
|
|
|
return value, set
|
|
}
|
|
|
|
e.settings = append(e.settings, slog.String(fileName, path))
|
|
|
|
contents, err := os.ReadFile(path) //nolint:gosec // a file the admin names
|
|
if err != nil {
|
|
e.check(fileName, fmt.Errorf("cannot be read: %w", err))
|
|
|
|
return "", false
|
|
}
|
|
|
|
return strings.TrimSuffix(string(contents), "\n"), true
|
|
}
|
|
|
|
// check keeps the first error, naming the setting it is about.
|
|
func (e *environment) check(name string, err error) {
|
|
if err != nil && e.err == nil {
|
|
e.err = fmt.Errorf("%s: %w", name, err)
|
|
}
|
|
}
|
|
|
|
// address reads a setting that is an address to listen on.
|
|
func (e *environment) address(name, defaultValue string) string {
|
|
address, err := parseListenAddr(e.value(name, defaultValue))
|
|
e.check(name, err)
|
|
|
|
return address
|
|
}
|
|
|
|
// appURL reads a setting that is the app's URL.
|
|
func (e *environment) appURL(name, defaultValue string) *url.URL {
|
|
upstream, err := parseUpstreamURL(e.value(name, defaultValue))
|
|
e.check(name, err)
|
|
|
|
return upstream
|
|
}
|
|
|
|
// observe reads the setting that is the mode, enforce or observe, and
|
|
// reports whether it is observe.
|
|
func (e *environment) observe(name, defaultValue string) bool {
|
|
mode := e.value(name, defaultValue)
|
|
if mode != "enforce" && mode != "observe" {
|
|
e.check(name, fmt.Errorf("%q %w", mode, errNotMode))
|
|
}
|
|
|
|
return mode == "observe"
|
|
}
|
|
|
|
// boolean reads a setting that is true or false.
|
|
func (e *environment) boolean(name, defaultValue string) bool {
|
|
value := e.value(name, defaultValue)
|
|
if value != "true" && value != "false" {
|
|
e.check(name, fmt.Errorf("%q %w", value, errNotBoolean))
|
|
}
|
|
|
|
return value == "true"
|
|
}
|
|
|
|
// netblocks reads a setting that is a list of netblocks.
|
|
func (e *environment) netblocks(name, defaultValue string) []netip.Prefix {
|
|
netblocks, err := parseNetblocks(e.value(name, defaultValue))
|
|
e.check(name, err)
|
|
|
|
return netblocks
|
|
}
|
|
|
|
// duration reads a setting that is a duration.
|
|
func (e *environment) duration(name, defaultValue string) time.Duration {
|
|
duration, err := parseDuration(e.value(name, defaultValue))
|
|
e.check(name, err)
|
|
|
|
return duration
|
|
}
|
|
|
|
// size reads a setting that is a number of bytes.
|
|
func (e *environment) size(name, defaultValue string) int64 {
|
|
size, err := parseSize(e.value(name, defaultValue))
|
|
e.check(name, err)
|
|
|
|
return size
|
|
}
|
|
|
|
// headerSize reads the setting that is the largest request line and
|
|
// headers.
|
|
func (e *environment) headerSize(name, defaultValue string) int64 {
|
|
size, err := parseHeaderSize(e.value(name, defaultValue))
|
|
e.check(name, err)
|
|
|
|
return size
|
|
}
|
|
|
|
// count reads a setting that is a number of requests.
|
|
func (e *environment) count(name, defaultValue string) int64 {
|
|
count, err := parseCount(e.value(name, defaultValue))
|
|
e.check(name, err)
|
|
|
|
return count
|
|
}
|
|
|
|
// bytesCount reads the setting that is which body bytes count toward the
|
|
// byte limits: response, request or both.
|
|
func (e *environment) bytesCount(name, defaultValue string) string {
|
|
value := e.value(name, defaultValue)
|
|
if value != "response" && value != "request" && value != "both" {
|
|
e.check(name, fmt.Errorf("%q %w", value, errNotBytesCount))
|
|
}
|
|
|
|
return value
|
|
}
|
|
|
|
// pathPrefixes reads a setting that is a list of path prefixes.
|
|
func (e *environment) pathPrefixes(name, defaultValue string) []string {
|
|
prefixes, err := parsePathPrefixes(e.value(name, defaultValue))
|
|
e.check(name, err)
|
|
|
|
return prefixes
|
|
}
|
|
|
|
// countries reads a setting that is a list of countries.
|
|
func (e *environment) countries(name, defaultValue string) []string {
|
|
countries, err := parseCountries(e.value(name, defaultValue))
|
|
e.check(name, err)
|
|
|
|
return countries
|
|
}
|
|
|
|
// percents reads a setting that is a list of AS numbers or countries,
|
|
// which parseCode reads, each with a percentage. It is empty by default.
|
|
func (e *environment) percents(
|
|
name string, parseCode func(string) (string, error),
|
|
) map[string]int64 {
|
|
percents, err := parsePercents(e.value(name, ""), parseCode)
|
|
e.check(name, err)
|
|
|
|
return percents
|
|
}
|
|
|
|
// percent reads a setting that is a percentage, from 0 to 100.
|
|
func (e *environment) percent(name, defaultValue string) int64 {
|
|
percent, err := ParsePercent(e.value(name, defaultValue))
|
|
e.check(name, err)
|
|
|
|
return percent
|
|
}
|
|
|
|
// listURL reads a setting that is the URL a list is fetched from, "" while
|
|
// it is unset or empty.
|
|
func (e *environment) listURL(name string) string {
|
|
value := e.value(name, "")
|
|
if value != "" && !isListURL(value) {
|
|
e.check(name, fmt.Errorf("%q %w", value, errNotListURL))
|
|
}
|
|
|
|
return value
|
|
}
|
|
|
|
// listURLs reads a setting that is a list of the URLs lists are fetched
|
|
// from. It is empty by default.
|
|
func (e *environment) listURLs(name string) []string {
|
|
urls, err := parseListURLs(e.value(name, ""))
|
|
e.check(name, err)
|
|
|
|
return urls
|
|
}
|
|
|
|
// refresh reads the setting that is how long after a list was last
|
|
// fetched or tried it is fetched again: a duration of an hour or more,
|
|
// since the Spamhaus lists may be fetched no more often, which cannot be
|
|
// off.
|
|
func (e *environment) refresh(name, defaultValue string) time.Duration {
|
|
value := e.value(name, defaultValue)
|
|
|
|
duration, err := parseDuration(value)
|
|
if err != nil || duration < time.Hour {
|
|
e.check(name, fmt.Errorf("%q %w", value, errNotAnHourOrMore))
|
|
}
|
|
|
|
return duration
|
|
}
|
|
|
|
// action reads a setting that is what is done with a client a blocklist
|
|
// or a DNSBL zone lists: deny, log, or limit:<percent>, which it returns
|
|
// as limit and the percentage.
|
|
func (e *environment) action(name, defaultValue string) (string, int64) {
|
|
value := e.value(name, defaultValue)
|
|
if value == "deny" || value == "log" {
|
|
return value, 0
|
|
}
|
|
|
|
percentText, isLimit := strings.CutPrefix(value, "limit:")
|
|
|
|
percent, err := ParsePercent(percentText)
|
|
if !isLimit || err != nil {
|
|
e.check(name, fmt.Errorf("%q %w", value, errNotAction))
|
|
}
|
|
|
|
return "limit", percent
|
|
}
|
|
|
|
// zones reads the setting that is the list of DNSBL zones. It is empty by
|
|
// default. The log shows each zone with its key masked, as MaskZoneKey
|
|
// masks it.
|
|
func (e *environment) zones(name string) []string {
|
|
value, _ := e.lookup(name)
|
|
zones, err := parseZones(value)
|
|
e.check(name, err)
|
|
|
|
logged := make([]string, len(zones))
|
|
for i, zone := range zones {
|
|
logged[i] = MaskZoneKey(zone)
|
|
}
|
|
|
|
e.settings = append(e.settings, slog.String(name, strings.Join(logged, ",")))
|
|
|
|
return zones
|
|
}
|
|
|
|
// resolver reads the setting that is the resolver the DNSBL zones are
|
|
// asked through, the zero AddrPort while it is unset or empty.
|
|
func (e *environment) resolver(name string) netip.AddrPort {
|
|
resolver, err := parseResolver(e.value(name, ""))
|
|
e.check(name, err)
|
|
|
|
return resolver
|
|
}
|
|
|
|
// lookupSource reads the setting that is where clients are looked up:
|
|
// geojs, file, or off.
|
|
func (e *environment) lookupSource(name, defaultValue string) string {
|
|
source := e.value(name, defaultValue)
|
|
if source != "geojs" && source != fileSource && source != off {
|
|
e.check(name, fmt.Errorf("%q %w", source, errNotLookupSource))
|
|
}
|
|
|
|
return source
|
|
}
|
|
|
|
// checkLookupDBPath refuses SWWAF_LOOKUP_SOURCE=file without
|
|
// SWWAF_LOOKUP_DB_PATH, and SWWAF_LOOKUP_DB_PATH with any other source:
|
|
// one source at a time.
|
|
func (e *environment) checkLookupDBPath(cfg *Config) {
|
|
switch {
|
|
case cfg.LookupSource == fileSource && cfg.LookupDBPath == "":
|
|
e.check("SWWAF_LOOKUP_SOURCE", fmt.Errorf(
|
|
"is file while SWWAF_LOOKUP_DB_PATH is unset; %w", errNeedsDBPath))
|
|
case cfg.LookupSource != fileSource && cfg.LookupDBPath != "":
|
|
e.check("SWWAF_LOOKUP_DB_PATH", fmt.Errorf(
|
|
"is set while SWWAF_LOOKUP_SOURCE is %s; %w", cfg.LookupSource, errDBPathUnused))
|
|
}
|
|
}
|
|
|
|
// checkCountriesAndLookups refuses a country on both country lists, and,
|
|
// while SWWAF_LOOKUP_SOURCE is off, each setting that needs clients looked
|
|
// up: the country lists, SWWAF_ADD_LOOKUP_HEADERS, the biased thresholds,
|
|
// SWWAF_ASN_LIMIT_PERCENT_URL among them, of which
|
|
// SWWAF_UNKNOWN_LIMIT_PERCENT needs them only below 100, where it lowers a
|
|
// limit, and the anomaly thresholds per AS number.
|
|
func (e *environment) checkCountriesAndLookups(cfg *Config) {
|
|
for _, country := range cfg.ExclusivelyAllowedCountries {
|
|
if slices.Contains(cfg.DeniedCountries, country) {
|
|
e.check("SWWAF_EXCLUSIVELY_ALLOWED_COUNTRIES",
|
|
fmt.Errorf("%q %w", country, errOnBothLists))
|
|
}
|
|
}
|
|
|
|
if cfg.LookupSource != off {
|
|
return
|
|
}
|
|
|
|
for _, setting := range []struct {
|
|
name string
|
|
set bool
|
|
}{
|
|
{"SWWAF_DENIED_COUNTRIES", len(cfg.DeniedCountries) > 0},
|
|
{"SWWAF_EXCLUSIVELY_ALLOWED_COUNTRIES", len(cfg.ExclusivelyAllowedCountries) > 0},
|
|
{"SWWAF_ADD_LOOKUP_HEADERS", cfg.AddLookupHeaders},
|
|
{"SWWAF_ASN_LIMIT_PERCENT", len(cfg.ASNLimitPercent) > 0},
|
|
{"SWWAF_COUNTRY_LIMIT_PERCENT", len(cfg.CountryLimitPercent) > 0},
|
|
{"SWWAF_ASN_BYTES_PERCENT", len(cfg.ASNBytesPercent) > 0},
|
|
{"SWWAF_COUNTRY_BYTES_PERCENT", len(cfg.CountryBytesPercent) > 0},
|
|
{"SWWAF_UNKNOWN_LIMIT_PERCENT", cfg.UnknownLimitPercent < 100},
|
|
{"SWWAF_ASN_LIMIT_PERCENT_URL", cfg.ASNLimitPercentURL != ""},
|
|
{"SWWAF_ANOMALY_ASN_REQUESTS_PER_MINUTE", cfg.AnomalyASN.RequestsPerMinute > 0},
|
|
{"SWWAF_ANOMALY_ASN_REQUESTS_PER_HOUR", cfg.AnomalyASN.RequestsPerHour > 0},
|
|
{"SWWAF_ANOMALY_ASN_BYTES_PER_MINUTE", cfg.AnomalyASN.BytesPerMinute > 0},
|
|
{"SWWAF_ANOMALY_ASN_BYTES_PER_HOUR", cfg.AnomalyASN.BytesPerHour > 0},
|
|
} {
|
|
if setting.set {
|
|
e.check(setting.name, fmt.Errorf("is set while SWWAF_LOOKUP_SOURCE is off; %w",
|
|
errNeedsLookups))
|
|
}
|
|
}
|
|
}
|
|
|
|
// checkASNLimitPercentURL refuses SWWAF_ASN_LIMIT_PERCENT_URL naming a
|
|
// blocklist too: the file at a URL is fetched as one list or the other.
|
|
func (e *environment) checkASNLimitPercentURL(cfg *Config) {
|
|
if slices.Contains(cfg.BlocklistURLs, cfg.ASNLimitPercentURL) {
|
|
e.check("SWWAF_ASN_LIMIT_PERCENT_URL",
|
|
fmt.Errorf("%q %w", cfg.ASNLimitPercentURL, errInBlocklistURLs))
|
|
}
|
|
}
|
|
|
|
// headerNames reads a setting that is a list of header names, and
|
|
// returns them in lower case.
|
|
func (e *environment) headerNames(name, defaultValue string) []string {
|
|
headers, err := parseHeaderNames(e.value(name, defaultValue))
|
|
e.check(name, err)
|
|
|
|
return headers
|
|
}
|
|
|
|
// durationNotOff reads a setting that is a duration and, unlike a
|
|
// timeout, cannot be off.
|
|
func (e *environment) durationNotOff(name, defaultValue string) time.Duration {
|
|
duration, err := ParseDurationNotOff(e.value(name, defaultValue))
|
|
e.check(name, err)
|
|
|
|
return duration
|
|
}
|
|
|
|
// numberNotOff reads a setting that is a whole number above zero, which
|
|
// cannot be off.
|
|
func (e *environment) numberNotOff(name, defaultValue string) int {
|
|
number, err := parseNumberNotOff(e.value(name, defaultValue))
|
|
e.check(name, err)
|
|
|
|
return number
|
|
}
|
|
|
|
// banResponse reads a setting that is how a refused client is answered.
|
|
func (e *environment) banResponse(name, defaultValue string) int {
|
|
status, err := parseBanResponse(e.value(name, defaultValue))
|
|
e.check(name, err)
|
|
|
|
return status
|
|
}
|
|
|
|
// v4Prefix reads a setting that is the length of an IPv4 netblock.
|
|
func (e *environment) v4Prefix(name, defaultValue string) int {
|
|
length, err := parseV4Prefix(e.value(name, defaultValue))
|
|
e.check(name, err)
|
|
|
|
return length
|
|
}
|
|
|
|
// v6Prefix reads a setting that is the length of an IPv6 netblock.
|
|
func (e *environment) v6Prefix(name, defaultValue string) int {
|
|
length, err := parseV6Prefix(e.value(name, defaultValue))
|
|
e.check(name, err)
|
|
|
|
return length
|
|
}
|
|
|
|
// thresholds reads the four anomaly thresholds whose settings' names
|
|
// start with prefix: requests and bytes per minute and per hour. Each is
|
|
// off by default.
|
|
func (e *environment) thresholds(prefix string) anomaly.Thresholds {
|
|
return anomaly.Thresholds{
|
|
RequestsPerMinute: e.count(prefix+"REQUESTS_PER_MINUTE", off),
|
|
RequestsPerHour: e.count(prefix+"REQUESTS_PER_HOUR", off),
|
|
BytesPerMinute: e.size(prefix+"BYTES_PER_MINUTE", off),
|
|
BytesPerHour: e.size(prefix+"BYTES_PER_HOUR", off),
|
|
}
|
|
}
|
|
|
|
// namedNetblocks reads a setting that is a list of named netblocks. It is
|
|
// empty by default.
|
|
func (e *environment) namedNetblocks(name string) []anomaly.NamedNetblock {
|
|
named, err := parseNamedNetblocks(e.value(name, ""))
|
|
e.check(name, err)
|
|
|
|
return named
|
|
}
|
|
|
|
// absolutePath reads a setting that is an absolute path.
|
|
func (e *environment) absolutePath(name, defaultValue string) string {
|
|
path := e.value(name, defaultValue)
|
|
if !filepath.IsAbs(path) {
|
|
e.check(name, fmt.Errorf("%q %w", path, errNotAbsolutePath))
|
|
}
|
|
|
|
return path
|
|
}
|
|
|
|
// token reads a setting that is a bearer token. Unset, it is "", which
|
|
// switches off what it guards; set, it must be at least minTokenLength
|
|
// characters. Neither the log nor an error shows its value.
|
|
func (e *environment) token(name string) string {
|
|
value, set := e.lookup(name)
|
|
if !set {
|
|
e.settings = append(e.settings, slog.String(name, ""))
|
|
|
|
return ""
|
|
}
|
|
|
|
e.settings = append(e.settings, slog.String(name, masked))
|
|
|
|
if utf8.RuneCountInString(value) < minTokenLength {
|
|
e.check(name, errShortToken)
|
|
}
|
|
|
|
return value
|
|
}
|
|
|
|
// logRemoteURL reads the setting that is where every log line is also
|
|
// sent. Unset or empty, it is nil, and nothing is sent.
|
|
func (e *environment) logRemoteURL(name string) *url.URL {
|
|
value := e.value(name, "")
|
|
if value == "" {
|
|
return nil
|
|
}
|
|
|
|
remote, err := parseLogRemoteURL(value)
|
|
e.check(name, err)
|
|
|
|
return remote
|
|
}
|
|
|
|
// certificates reads a setting that is the path of a file of PEM
|
|
// certificates. Unset or empty, it is nil. Its value names a file
|
|
// already, so, unlike the other settings, it has no _FILE form.
|
|
func (e *environment) certificates(name string) *x509.CertPool {
|
|
path, _ := e.lookupEnv(name)
|
|
e.settings = append(e.settings, slog.String(name, path))
|
|
|
|
if path == "" {
|
|
return nil
|
|
}
|
|
|
|
pem, err := os.ReadFile(path) //nolint:gosec // a file the admin names
|
|
if err != nil {
|
|
e.check(name, fmt.Errorf("cannot be read: %w", err))
|
|
|
|
return nil
|
|
}
|
|
|
|
pool := x509.NewCertPool()
|
|
if !pool.AppendCertsFromPEM(pem) {
|
|
e.check(name, fmt.Errorf("%q %w", path, errNoCertificate))
|
|
|
|
return nil
|
|
}
|
|
|
|
return pool
|
|
}
|
|
|
|
// facility reads a setting that is a syslog facility, and returns its
|
|
// number.
|
|
func (e *environment) facility(name, defaultValue string) int {
|
|
number, err := parseFacility(e.value(name, defaultValue))
|
|
e.check(name, err)
|
|
|
|
return number
|
|
}
|
|
|
|
// instanceName reads SWWAF_INSTANCE_NAME, by default the host's name. It
|
|
// must be valid UTF-8: the metrics library panics on a label that is not.
|
|
func (e *environment) instanceName() string {
|
|
hostname, _ := os.Hostname() // "" when the host has no name to give
|
|
|
|
value := e.value("SWWAF_INSTANCE_NAME", hostname)
|
|
if !utf8.ValidString(value) {
|
|
e.check("SWWAF_INSTANCE_NAME", fmt.Errorf("%q %w", value, errNotUTF8))
|
|
}
|
|
|
|
return value
|
|
}
|
|
|
|
// appName reads the setting that is the APP-NAME of the records the log
|
|
// lines are sent in, by default the instance name. Its value is checked
|
|
// when it is set, and, while lines are sent, when it is the instance name.
|
|
func (e *environment) appName(name, instanceName string, sending bool) string {
|
|
value, set := e.lookup(name)
|
|
if !set {
|
|
value = instanceName
|
|
}
|
|
|
|
e.settings = append(e.settings, slog.String(name, value))
|
|
|
|
switch {
|
|
case isAppName(value):
|
|
case set:
|
|
e.check(name, fmt.Errorf("%q %w", value, errNotAppName))
|
|
case sending:
|
|
e.check(name, fmt.Errorf("is unset, and SWWAF_INSTANCE_NAME %q, its default, %w",
|
|
value, errNotAppName))
|
|
}
|
|
|
|
return value
|
|
}
|
|
|
|
// checkInstanceNameForNtfy refuses an instance name that holds a control
|
|
// character while ntfySet, SWWAF_ALERT_NTFY_URL being set: ntfy is sent
|
|
// the instance name in a header, which cannot hold one.
|
|
func (e *environment) checkInstanceNameForNtfy(instanceName string, ntfySet bool) {
|
|
if ntfySet && strings.ContainsFunc(instanceName, unicode.IsControl) {
|
|
e.check("SWWAF_INSTANCE_NAME", fmt.Errorf(
|
|
"%q %w, and is sent to ntfy in a header while SWWAF_ALERT_NTFY_URL is set",
|
|
instanceName, errControlCharacter))
|
|
}
|
|
}
|
|
|
|
// webhookURL reads a setting that is a URL each alert is posted to:
|
|
// SWWAF_ALERT_WEBHOOK_URL, SWWAF_ALERT_SLACK_WEBHOOK_URL or
|
|
// SWWAF_ALERT_NTFY_URL. Unset or empty, it is nil, and no alert is posted
|
|
// there. The log shows ******** in place of its path and query, and an
|
|
// error shows none of it, since a webhook or an ntfy topic can carry its
|
|
// secret there.
|
|
func (e *environment) webhookURL(name string) *url.URL {
|
|
value, _ := e.lookup(name)
|
|
webhook, logged, err := parseWebhookURL(value)
|
|
e.settings = append(e.settings, slog.String(name, logged))
|
|
e.check(name, err)
|
|
|
|
return webhook
|
|
}
|
|
|
|
// webhookHeaders reads the setting that is the headers sent with each
|
|
// alert. The log shows each header's value as ********, since a header
|
|
// such as Authorization carries a secret.
|
|
func (e *environment) webhookHeaders(name string) http.Header {
|
|
value, _ := e.lookup(name)
|
|
headers, logged, err := parseWebhookHeaders(value)
|
|
e.settings = append(e.settings, slog.String(name, logged))
|
|
e.check(name, err)
|
|
|
|
return headers
|
|
}
|
|
|
|
// secret reads a setting that is a secret another service gave, such as
|
|
// an ntfy token or an AbuseIPDB key, "" while it is unset. It is sent in
|
|
// a header, which cannot hold a control character, so one in it is an
|
|
// error. The log shows ******** in place of a value that is not empty, and
|
|
// an error shows none of it.
|
|
func (e *environment) secret(name string) string {
|
|
value, _ := e.lookup(name)
|
|
|
|
logged := ""
|
|
if value != "" {
|
|
logged = masked
|
|
}
|
|
|
|
e.settings = append(e.settings, slog.String(name, logged))
|
|
|
|
if strings.ContainsFunc(value, unicode.IsControl) {
|
|
e.check(name, errControlCharacter)
|
|
}
|
|
|
|
return value
|
|
}
|
|
|
|
// alertEvents reads the setting that is the events alerts are sent for.
|
|
func (e *environment) alertEvents(name, defaultValue string) []string {
|
|
events, err := parseAlertEvents(e.value(name, defaultValue))
|
|
e.check(name, err)
|
|
|
|
return events
|
|
}
|
|
|
|
// numberOrOff reads a setting that is a whole number above zero, or off,
|
|
// which is 0.
|
|
func (e *environment) numberOrOff(name, defaultValue string) int {
|
|
number, err := parseNumberOrOff(e.value(name, defaultValue))
|
|
e.check(name, err)
|
|
|
|
return number
|
|
}
|
|
|
|
// parseDuration reads a duration in Go's syntax, such as 90s or 15m, a
|
|
// whole number of days such as 7d, or off.
|
|
func parseDuration(value string) (time.Duration, error) {
|
|
if value == off {
|
|
return 0, nil
|
|
}
|
|
|
|
duration, err := durationOrDays(value)
|
|
if err != nil {
|
|
return 0, fmt.Errorf("%q %w", value, errNotDuration)
|
|
}
|
|
|
|
if duration <= 0 {
|
|
return 0, fmt.Errorf("%q %w", value, errNotPositive)
|
|
}
|
|
|
|
return duration, nil
|
|
}
|
|
|
|
// durationOrDays reads Go's duration syntax, or a whole number of days.
|
|
func durationOrDays(value string) (time.Duration, error) {
|
|
days, isDays := strings.CutSuffix(value, "d")
|
|
if !isDays {
|
|
return time.ParseDuration(value)
|
|
}
|
|
|
|
n, err := strconv.ParseInt(days, 10, 64)
|
|
if err != nil || n < 0 || n > math.MaxInt64/int64(day) {
|
|
return 0, errNotDuration
|
|
}
|
|
|
|
return time.Duration(n) * day, nil
|
|
}
|
|
|
|
// parseSize reads a number of bytes with an optional K, M or G suffix, in
|
|
// powers of 1024 (1K is 1024 bytes), or off.
|
|
func parseSize(value string) (int64, error) {
|
|
if value == off {
|
|
return 0, nil
|
|
}
|
|
|
|
number, unit := splitUnit(value)
|
|
|
|
n, err := strconv.ParseInt(number, 10, 64)
|
|
if err != nil || n > math.MaxInt64/unit {
|
|
return 0, fmt.Errorf("%q %w", value, errNotSize)
|
|
}
|
|
|
|
if n <= 0 {
|
|
return 0, fmt.Errorf("%q %w", value, errNotPositive)
|
|
}
|
|
|
|
return n * unit, nil
|
|
}
|
|
|
|
// parseHeaderSize reads the largest request line and headers: a size as
|
|
// parseSize reads it, but more than 4K and never off. Go's server reads 4K
|
|
// past the limit it is given before it refuses, so proxy.New gives it this
|
|
// size less 4K, which must leave a limit.
|
|
func parseHeaderSize(value string) (int64, error) {
|
|
size, err := parseSize(value)
|
|
if err != nil || size <= 4*kibibyte {
|
|
return 0, fmt.Errorf("%q %w", value, errNotOver4K)
|
|
}
|
|
|
|
return size, nil
|
|
}
|
|
|
|
// splitUnit splits a size into its number and the bytes its suffix
|
|
// stands for.
|
|
func splitUnit(value string) (string, int64) {
|
|
switch {
|
|
case strings.HasSuffix(value, "K"):
|
|
return strings.TrimSuffix(value, "K"), kibibyte
|
|
case strings.HasSuffix(value, "M"):
|
|
return strings.TrimSuffix(value, "M"), mebibyte
|
|
case strings.HasSuffix(value, "G"):
|
|
return strings.TrimSuffix(value, "G"), gibibyte
|
|
default:
|
|
return value, 1
|
|
}
|
|
}
|
|
|
|
// parseCount reads a whole number of requests, or off.
|
|
func parseCount(value string) (int64, error) {
|
|
if value == off {
|
|
return 0, nil
|
|
}
|
|
|
|
n, err := strconv.ParseInt(value, 10, 64)
|
|
if err != nil {
|
|
return 0, fmt.Errorf("%q %w", value, errNotCount)
|
|
}
|
|
|
|
if n <= 0 {
|
|
return 0, fmt.Errorf("%q %w", value, errNotPositive)
|
|
}
|
|
|
|
return n, nil
|
|
}
|
|
|
|
// ParseDurationNotOff reads a duration above zero, as parseDuration does,
|
|
// but not off. The ban endpoint reads the duration of a ban with it too.
|
|
func ParseDurationNotOff(value string) (time.Duration, error) {
|
|
duration, err := parseDuration(value)
|
|
if err != nil || duration == 0 {
|
|
return 0, fmt.Errorf("%q %w", value, errNotDurationAboveZero)
|
|
}
|
|
|
|
return duration, nil
|
|
}
|
|
|
|
// parseNumberNotOff reads a whole number above zero.
|
|
func parseNumberNotOff(value string) (int, error) {
|
|
n, err := strconv.Atoi(value)
|
|
if err != nil || n <= 0 {
|
|
return 0, fmt.Errorf("%q %w", value, errNotNumberAboveZero)
|
|
}
|
|
|
|
return n, nil
|
|
}
|
|
|
|
// parseBanResponse reads how a refused client is answered: 403, 429, or
|
|
// close, which is 0.
|
|
func parseBanResponse(value string) (int, error) {
|
|
switch value {
|
|
case "403":
|
|
return http.StatusForbidden, nil
|
|
case "429":
|
|
return http.StatusTooManyRequests, nil
|
|
case "close":
|
|
return 0, nil
|
|
default:
|
|
return 0, fmt.Errorf("%q %w", value, errNotBanResponse)
|
|
}
|
|
}
|
|
|
|
// parseV4Prefix reads the length of an IPv4 netblock, from 0 to 32.
|
|
func parseV4Prefix(value string) (int, error) {
|
|
n, err := strconv.Atoi(value)
|
|
if err != nil || n < 0 || n > ipv4Bits {
|
|
return 0, fmt.Errorf("%q %w", value, errNotV4Prefix)
|
|
}
|
|
|
|
return n, nil
|
|
}
|
|
|
|
// parseV6Prefix reads the length of an IPv6 netblock, from 0 to 128.
|
|
func parseV6Prefix(value string) (int, error) {
|
|
n, err := strconv.Atoi(value)
|
|
if err != nil || n < 0 || n > ipv6Bits {
|
|
return 0, fmt.Errorf("%q %w", value, errNotV6Prefix)
|
|
}
|
|
|
|
return n, nil
|
|
}
|
|
|
|
// parseList splits a comma-separated list and trims the spaces around
|
|
// each item. An empty value is an empty list.
|
|
func parseList(value string) ([]string, error) {
|
|
if strings.TrimSpace(value) == "" {
|
|
return []string{}, nil
|
|
}
|
|
|
|
items := strings.Split(value, ",")
|
|
for i, item := range items {
|
|
items[i] = strings.TrimSpace(item)
|
|
if items[i] == "" {
|
|
return nil, fmt.Errorf("%q %w", value, errEmptyItem)
|
|
}
|
|
}
|
|
|
|
return items, nil
|
|
}
|
|
|
|
// parseNetblocks reads a comma-separated list of netblocks.
|
|
func parseNetblocks(value string) ([]netip.Prefix, error) {
|
|
items, err := parseList(value)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
netblocks := make([]netip.Prefix, 0, len(items))
|
|
|
|
for _, item := range items {
|
|
netblock, err := ParseNetblock(item)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
netblocks = append(netblocks, netblock)
|
|
}
|
|
|
|
return netblocks, nil
|
|
}
|
|
|
|
// ParseNetblock reads a netblock in CIDR form, such as 10.0.0.0/8. A bare
|
|
// address is a netblock of that address alone, a /32 or a /128. A
|
|
// blocklist's lines are read with it too.
|
|
func ParseNetblock(value string) (netip.Prefix, error) {
|
|
if strings.Contains(value, "/") {
|
|
netblock, err := netip.ParsePrefix(value)
|
|
if err != nil {
|
|
return netip.Prefix{}, fmt.Errorf("%q %w", value, errNotNetblock)
|
|
}
|
|
|
|
return netblock.Masked(), nil
|
|
}
|
|
|
|
addr, err := netip.ParseAddr(value)
|
|
if err != nil || addr.Zone() != "" {
|
|
return netip.Prefix{}, fmt.Errorf("%q %w", value, errNotNetblock)
|
|
}
|
|
|
|
return netip.PrefixFrom(addr, addr.BitLen()), nil
|
|
}
|
|
|
|
// parseNamedNetblocks reads a comma-separated list of named netblocks,
|
|
// each a name, = and a netblock, such as office=203.0.113.0/24. An empty
|
|
// value is an empty list. A name listed twice is an error.
|
|
func parseNamedNetblocks(value string) ([]anomaly.NamedNetblock, error) {
|
|
items, err := parseList(value)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
named := make([]anomaly.NamedNetblock, 0, len(items))
|
|
|
|
for _, item := range items {
|
|
name, netblockText, found := strings.Cut(item, "=")
|
|
name = strings.TrimSpace(name)
|
|
|
|
if !found || name == "" {
|
|
return nil, fmt.Errorf("%q %w", item, errNotNamedNetblock)
|
|
}
|
|
|
|
netblock, err := ParseNetblock(strings.TrimSpace(netblockText))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
if slices.ContainsFunc(named, func(n anomaly.NamedNetblock) bool {
|
|
return n.Name == name
|
|
}) {
|
|
return nil, fmt.Errorf("%q %w", name, errListedTwice)
|
|
}
|
|
|
|
named = append(named, anomaly.NamedNetblock{Name: name, Netblock: netblock})
|
|
}
|
|
|
|
return named, nil
|
|
}
|
|
|
|
// parsePathPrefixes reads a comma-separated list of path prefixes, each
|
|
// starting with /.
|
|
func parsePathPrefixes(value string) ([]string, error) {
|
|
prefixes, err := parseList(value)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
for _, prefix := range prefixes {
|
|
if !strings.HasPrefix(prefix, "/") {
|
|
return nil, fmt.Errorf("%q %w", prefix, errNotPathPrefix)
|
|
}
|
|
}
|
|
|
|
return prefixes, nil
|
|
}
|
|
|
|
// countryCodes are the two-letter codes ISO 3166-1 assigns today, and XK,
|
|
// the code in common use for Kosovo. golang.org/x/text/language cannot
|
|
// check them: it also takes withdrawn codes such as su, and reserved ones
|
|
// such as ac, as countries.
|
|
const countryCodes = `
|
|
AD AE AF AG AI AL AM AO AQ AR AS AT AU AW AX AZ
|
|
BA BB BD BE BF BG BH BI BJ BL BM BN BO BQ BR BS BT BV BW BY BZ
|
|
CA CC CD CF CG CH CI CK CL CM CN CO CR CU CV CW CX CY CZ
|
|
DE DJ DK DM DO DZ
|
|
EC EE EG EH ER ES ET
|
|
FI FJ FK FM FO FR
|
|
GA GB GD GE GF GG GH GI GL GM GN GP GQ GR GS GT GU GW GY
|
|
HK HM HN HR HT HU
|
|
ID IE IL IM IN IO IQ IR IS IT
|
|
JE JM JO JP
|
|
KE KG KH KI KM KN KP KR KW KY KZ
|
|
LA LB LC LI LK LR LS LT LU LV LY
|
|
MA MC MD ME MF MG MH MK ML MM MN MO MP MQ MR MS MT MU MV MW MX MY MZ
|
|
NA NC NE NF NG NI NL NO NP NR NU NZ
|
|
OM
|
|
PA PE PF PG PH PK PL PM PN PR PS PT PW PY
|
|
QA
|
|
RE RO RS RU RW
|
|
SA SB SC SD SE SG SH SI SJ SK SL SM SN SO SR SS ST SV SX SY SZ
|
|
TC TD TF TG TH TJ TK TL TM TN TO TR TT TV TW TZ
|
|
UA UG UM US UY UZ
|
|
VA VC VE VG VI VN VU
|
|
WF WS
|
|
XK
|
|
YE YT
|
|
ZA ZM ZW
|
|
`
|
|
|
|
// parseCountries reads a comma-separated list of country codes in either
|
|
// case, and returns them in capitals.
|
|
func parseCountries(value string) ([]string, error) {
|
|
items, err := parseList(value)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
countries := make([]string, 0, len(items))
|
|
|
|
for _, item := range items {
|
|
country, err := parseCountry(item)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
countries = append(countries, country)
|
|
}
|
|
|
|
return countries, nil
|
|
}
|
|
|
|
// parseCountry reads a country code in either case, and returns it in
|
|
// capitals.
|
|
func parseCountry(value string) (string, error) {
|
|
country := strings.ToUpper(value)
|
|
if !slices.Contains(strings.Fields(countryCodes), country) {
|
|
return "", fmt.Errorf("%q %w", value, errNotCountry)
|
|
}
|
|
|
|
return country, nil
|
|
}
|
|
|
|
// ParseASN reads an AS number such as AS64496, in either case, and
|
|
// returns it as the lookup gives it: AS and the number, in capitals and
|
|
// without leading zeros. The file SWWAF_ASN_LIMIT_PERCENT_URL names is
|
|
// read with it too.
|
|
func ParseASN(value string) (string, error) {
|
|
digits, hasAS := strings.CutPrefix(strings.ToUpper(value), "AS")
|
|
|
|
number, err := strconv.ParseUint(digits, 10, 32)
|
|
if !hasAS || err != nil {
|
|
return "", fmt.Errorf("%q %w", value, errNotASN)
|
|
}
|
|
|
|
return "AS" + strconv.FormatUint(number, 10), nil
|
|
}
|
|
|
|
// parsePercents reads a comma-separated list of items, each an AS number
|
|
// or a country, which parseCode reads, then : and a percentage, such as
|
|
// AS64496:50 or cn:25, and returns each one's percentage. An empty value
|
|
// is an empty list. An AS number or country listed twice is an error.
|
|
func parsePercents(
|
|
value string, parseCode func(string) (string, error),
|
|
) (map[string]int64, error) {
|
|
items, err := parseList(value)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
percents := make(map[string]int64, len(items))
|
|
|
|
for _, item := range items {
|
|
codeText, percentText, found := strings.Cut(item, ":")
|
|
if !found {
|
|
return nil, fmt.Errorf("%q %w", item, errNotPercentItem)
|
|
}
|
|
|
|
code, err := parseCode(codeText)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
percent, err := ParsePercent(percentText)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
if _, listed := percents[code]; listed {
|
|
return nil, fmt.Errorf("%q %w", codeText, errListedTwice)
|
|
}
|
|
|
|
percents[code] = percent
|
|
}
|
|
|
|
return percents, nil
|
|
}
|
|
|
|
// ParsePercent reads a percentage, a whole number from 0 to 100. The file
|
|
// SWWAF_ASN_LIMIT_PERCENT_URL names is read with it too.
|
|
func ParsePercent(value string) (int64, error) {
|
|
percent, err := strconv.ParseInt(value, 10, 64)
|
|
if err != nil || percent < 0 || percent > 100 {
|
|
return 0, fmt.Errorf("%q %w", value, errNotPercent)
|
|
}
|
|
|
|
return percent, nil
|
|
}
|
|
|
|
// headerNameChars are the characters RFC 9110 allows in a header name:
|
|
// letters, digits and these marks.
|
|
const headerNameChars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz" +
|
|
"0123456789!#$%&'*+-.^_`|~"
|
|
|
|
// IsHeaderName reports whether name can be a header name: one or more of
|
|
// the characters RFC 9110 allows in one.
|
|
func IsHeaderName(name string) bool {
|
|
if name == "" {
|
|
return false
|
|
}
|
|
|
|
for _, char := range name {
|
|
if !strings.ContainsRune(headerNameChars, char) {
|
|
return false
|
|
}
|
|
}
|
|
|
|
return true
|
|
}
|
|
|
|
// parseHeaderNames reads a comma-separated list of header names in either
|
|
// case, and returns them in lower case. Host and Transfer-Encoding are
|
|
// refused: Go's HTTP server takes them out of the request's headers.
|
|
func parseHeaderNames(value string) ([]string, error) {
|
|
items, err := parseList(value)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
headers := make([]string, 0, len(items))
|
|
|
|
for _, item := range items {
|
|
if !IsHeaderName(item) {
|
|
return nil, fmt.Errorf("%q %w", item, errNotHeaderName)
|
|
}
|
|
|
|
header := strings.ToLower(item)
|
|
switch header {
|
|
case "host":
|
|
return nil, fmt.Errorf("%q %w; the request's host is the field host",
|
|
item, errHeaderTakenOut)
|
|
case "transfer-encoding":
|
|
return nil, fmt.Errorf("%q %w", item, errHeaderTakenOut)
|
|
}
|
|
|
|
headers = append(headers, header)
|
|
}
|
|
|
|
return headers, nil
|
|
}
|
|
|
|
// parseListenAddr checks an address to listen on: an optional host and a
|
|
// port number.
|
|
func parseListenAddr(value string) (string, error) {
|
|
_, port, err := net.SplitHostPort(value)
|
|
if err != nil {
|
|
return "", fmt.Errorf("%q %w", value, errNotListenAddr)
|
|
}
|
|
|
|
_, err = strconv.ParseUint(port, 10, 16)
|
|
if err != nil {
|
|
return "", fmt.Errorf("%q %w", value, errNotListenAddr)
|
|
}
|
|
|
|
return value, nil
|
|
}
|
|
|
|
// parseUpstreamURL reads the app's URL: http or https, a host and an
|
|
// optional port from 1 to 65535, and nothing else, since the request's
|
|
// own path and query go to the app unchanged.
|
|
func parseUpstreamURL(value string) (*url.URL, error) {
|
|
upstream, err := url.Parse(value)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("%q %w", value, errNotUpstreamURL)
|
|
}
|
|
|
|
onlySchemeAndHost := (upstream.Scheme == "http" || upstream.Scheme == "https") &&
|
|
upstream.Hostname() != "" && upstream.User == nil && upstream.Opaque == "" &&
|
|
(upstream.Path == "" || upstream.Path == "/") &&
|
|
upstream.RawQuery == "" && upstream.Fragment == ""
|
|
if !onlySchemeAndHost {
|
|
return nil, fmt.Errorf("%q %w", value, errNotUpstreamURL)
|
|
}
|
|
|
|
if upstream.Port() != "" {
|
|
port, err := strconv.ParseUint(upstream.Port(), 10, 16)
|
|
if err != nil || port == 0 {
|
|
return nil, fmt.Errorf("%q %w", value, errNotUpstreamURL)
|
|
}
|
|
}
|
|
|
|
return upstream, nil
|
|
}
|
|
|
|
// parseLogRemoteURL reads where every log line is also sent:
|
|
// syslog+udp, syslog+tcp or syslog+tls, a host and a port from 1 to
|
|
// 65535, and nothing else.
|
|
func parseLogRemoteURL(value string) (*url.URL, error) {
|
|
remote, err := url.Parse(value)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("%q %w", value, errNotLogRemoteURL)
|
|
}
|
|
|
|
schemes := []string{remotelog.SchemeUDP, remotelog.SchemeTCP, remotelog.SchemeTLS}
|
|
port, err := strconv.ParseUint(remote.Port(), 10, 16)
|
|
|
|
onlySchemeHostAndPort := slices.Contains(schemes, remote.Scheme) &&
|
|
remote.Hostname() != "" && err == nil && port != 0 &&
|
|
remote.User == nil && remote.Opaque == "" &&
|
|
(remote.Path == "" || remote.Path == "/") &&
|
|
remote.RawQuery == "" && remote.Fragment == ""
|
|
if !onlySchemeHostAndPort {
|
|
return nil, fmt.Errorf("%q %w", value, errNotLogRemoteURL)
|
|
}
|
|
|
|
return remote, nil
|
|
}
|
|
|
|
// parseFacility reads the name of a syslog facility, and returns its
|
|
// number, as RFC 5424 numbers them.
|
|
func parseFacility(value string) (int, error) {
|
|
//nolint:mnd // the facilities' numbers in RFC 5424
|
|
number, known := map[string]int{
|
|
"kern": 0, "user": 1, "mail": 2, "daemon": 3, "auth": 4, "syslog": 5,
|
|
"lpr": 6, "news": 7, "uucp": 8, "cron": 9, "authpriv": 10, "ftp": 11,
|
|
"local0": 16, "local1": 17, "local2": 18, "local3": 19,
|
|
"local4": 20, "local5": 21, "local6": 22, "local7": 23,
|
|
}[value]
|
|
if !known {
|
|
return 0, fmt.Errorf("%q %w", value, errNotFacility)
|
|
}
|
|
|
|
return number, nil
|
|
}
|
|
|
|
// parseWebhookURL reads where each alert is posted: http or https, a
|
|
// host, and an optional port from 1 to 65535, path and query, without a
|
|
// user or a fragment. It returns the URL, and how the log shows it: its
|
|
// scheme and host, and ******** in place of its path and query, if it has
|
|
// either. An error shows no part of the value. An empty value is no URL.
|
|
func parseWebhookURL(value string) (*url.URL, string, error) {
|
|
if value == "" {
|
|
return nil, "", nil
|
|
}
|
|
|
|
webhook, err := url.Parse(value)
|
|
if err != nil || !isHTTPURL(webhook) {
|
|
return nil, "", errNotWebhookURL
|
|
}
|
|
|
|
logged := webhook.Scheme + "://" + webhook.Host
|
|
if webhook.Path != "" || webhook.RawQuery != "" {
|
|
logged += "/" + masked
|
|
}
|
|
|
|
return webhook, logged, nil
|
|
}
|
|
|
|
// isHTTPURL reports whether u is http or https, with a host, and an
|
|
// optional port from 1 to 65535, path and query, without a user or a
|
|
// fragment.
|
|
func isHTTPURL(u *url.URL) bool {
|
|
port, err := strconv.ParseUint(u.Port(), 10, 16)
|
|
|
|
return (u.Scheme == "http" || u.Scheme == "https") && u.Hostname() != "" &&
|
|
(u.Port() == "" || (err == nil && port != 0)) &&
|
|
u.User == nil && u.Opaque == "" && u.Fragment == ""
|
|
}
|
|
|
|
// isListURL reports whether value is a URL a list can be fetched from, as
|
|
// isHTTPURL says.
|
|
func isListURL(value string) bool {
|
|
u, err := url.Parse(value)
|
|
|
|
return err == nil && isHTTPURL(u)
|
|
}
|
|
|
|
// parseListURLs reads a comma-separated list of the URLs lists are fetched
|
|
// from. A URL listed twice is an error: it would be fetched twice as
|
|
// often.
|
|
func parseListURLs(value string) ([]string, error) {
|
|
urls, err := parseList(value)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
for i, listURL := range urls {
|
|
if !isListURL(listURL) {
|
|
return nil, fmt.Errorf("%q %w", listURL, errNotListURL)
|
|
}
|
|
|
|
if slices.Contains(urls[:i], listURL) {
|
|
return nil, fmt.Errorf("%q %w", listURL, errListedTwice)
|
|
}
|
|
}
|
|
|
|
return urls, nil
|
|
}
|
|
|
|
const (
|
|
// maxZoneLength is the most characters a DNSBL zone may have: 253, the
|
|
// most a DNS name may have, less the 64 that come before the zone in
|
|
// the name an IPv6 client is asked about by, its 32 hex digits each
|
|
// followed by a dot.
|
|
maxZoneLength = 189
|
|
// maxLabelLength is the most characters a label of a DNS name may have.
|
|
maxLabelLength = 63
|
|
// labelChars are the characters a label of a DNS zone may hold.
|
|
labelChars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-"
|
|
// dnsPort is the port a resolver is asked on when SWWAF_DNSBL_RESOLVER
|
|
// gives none.
|
|
dnsPort = 53
|
|
)
|
|
|
|
// parseZones reads a comma-separated list of DNSBL zones, each a DNS name
|
|
// such as dnsbl.dronebl.org: labels separated by dots, each of 1 to 63
|
|
// letters, digits and hyphens, neither starting nor ending with a hyphen,
|
|
// and at most maxZoneLength characters in all. Go's resolver takes any
|
|
// other name for one that does not exist, so that the zone would list no
|
|
// client. A zone listed twice is an error, whatever the case of its
|
|
// letters, which DNS names ignore, and whatever its key, since
|
|
// MaskZoneKey shows two keys of one zone alike. An error shows a zone as
|
|
// MaskZoneKey does.
|
|
func parseZones(value string) ([]string, error) {
|
|
zones, err := parseList(value)
|
|
if err != nil {
|
|
// parseList's error, for an empty item, shows the whole value, keys
|
|
// included.
|
|
return nil, errEmptyItem
|
|
}
|
|
|
|
for i, zone := range zones {
|
|
shown := MaskZoneKey(zone)
|
|
listedBefore := slices.ContainsFunc(zones[:i], func(earlier string) bool {
|
|
return strings.EqualFold(MaskZoneKey(earlier), shown)
|
|
})
|
|
|
|
switch {
|
|
case len(zone) > maxZoneLength:
|
|
return nil, fmt.Errorf("%q %w", shown, errZoneTooLong)
|
|
case !isZone(zone):
|
|
return nil, fmt.Errorf("%q %w", shown, errNotZone)
|
|
case listedBefore:
|
|
return nil, fmt.Errorf("%q %w", shown, errListedTwice)
|
|
}
|
|
}
|
|
|
|
return zones, nil
|
|
}
|
|
|
|
// MaskZoneKey returns zone with ******** in place of its key, if it is a
|
|
// zone of Spamhaus's keyed query service, a name under dq.spamhaus.net,
|
|
// such as <key>.xbl.dq.spamhaus.net, whose first label is the key. Any
|
|
// other zone it returns as it is. A zone is shown so wherever it leaves
|
|
// the process: in the log, the alerts and the metrics.
|
|
func MaskZoneKey(zone string) string {
|
|
// DNS names ignore case, and a name may be written with a dot at its
|
|
// end.
|
|
name := strings.TrimSuffix(strings.ToLower(zone), ".")
|
|
if !strings.HasSuffix(name, ".dq.spamhaus.net") {
|
|
return zone
|
|
}
|
|
|
|
_, rest, _ := strings.Cut(zone, ".")
|
|
|
|
return masked + "." + rest
|
|
}
|
|
|
|
// isZone reports whether each label of zone is as parseZones takes it.
|
|
func isZone(zone string) bool {
|
|
for label := range strings.SplitSeq(zone, ".") {
|
|
badChar := strings.ContainsFunc(label, func(char rune) bool {
|
|
return !strings.ContainsRune(labelChars, char)
|
|
})
|
|
|
|
if label == "" || len(label) > maxLabelLength || badChar ||
|
|
strings.HasPrefix(label, "-") || strings.HasSuffix(label, "-") {
|
|
return false
|
|
}
|
|
}
|
|
|
|
return true
|
|
}
|
|
|
|
// parseResolver reads the resolver the DNSBL zones are asked through: an
|
|
// IP address with a port from 1 to 65535, such as 192.0.2.53:5353 or
|
|
// [2001:db8::53]:5353, or without one, such as 192.0.2.53 or 2001:db8::53,
|
|
// for port 53. An empty value is none, the zero AddrPort.
|
|
func parseResolver(value string) (netip.AddrPort, error) {
|
|
if value == "" {
|
|
return netip.AddrPort{}, nil
|
|
}
|
|
|
|
resolver, err := netip.ParseAddrPort(value)
|
|
if err == nil && resolver.Port() != 0 {
|
|
return resolver, nil
|
|
}
|
|
|
|
addr, err := netip.ParseAddr(value)
|
|
if err != nil {
|
|
return netip.AddrPort{}, fmt.Errorf("%q %w", value, errNotResolver)
|
|
}
|
|
|
|
return netip.AddrPortFrom(addr, dnsPort), nil
|
|
}
|
|
|
|
// parseWebhookHeaders reads a comma-separated list of headers, each its
|
|
// name, :, and its value, and returns them, and how the log shows them,
|
|
// with each value as ********. An error names the item by its place in
|
|
// the list, so that it shows no value. An empty value is an empty list.
|
|
func parseWebhookHeaders(value string) (http.Header, string, error) {
|
|
headers := http.Header{}
|
|
if strings.TrimSpace(value) == "" {
|
|
return headers, "", nil
|
|
}
|
|
|
|
logged := []string{}
|
|
|
|
for i, item := range strings.Split(value, ",") {
|
|
name, headerValue, found := strings.Cut(item, ":")
|
|
name = strings.TrimSpace(name)
|
|
|
|
if !found || !IsHeaderName(name) || strings.ContainsAny(headerValue, "\r\n\x00") {
|
|
return nil, "", fmt.Errorf("item %d %w", i+1, errNotWebhookHeader)
|
|
}
|
|
|
|
headers.Add(name, strings.TrimSpace(headerValue))
|
|
logged = append(logged, name+":"+masked)
|
|
}
|
|
|
|
return headers, strings.Join(logged, ","), nil
|
|
}
|
|
|
|
// parseAlertEvents reads a comma-separated list of the events alerts can
|
|
// be sent for.
|
|
func parseAlertEvents(value string) ([]string, error) {
|
|
events, err := parseList(value)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
for _, event := range events {
|
|
if !slices.Contains(alerts.Events(), event) {
|
|
return nil, fmt.Errorf("%q %w", event, errNotAlertEvent)
|
|
}
|
|
}
|
|
|
|
return events, nil
|
|
}
|
|
|
|
// parseNumberOrOff reads a whole number above zero, or off, which is 0.
|
|
func parseNumberOrOff(value string) (int, error) {
|
|
if value == off {
|
|
return 0, nil
|
|
}
|
|
|
|
n, err := strconv.Atoi(value)
|
|
if err != nil || n <= 0 {
|
|
return 0, fmt.Errorf("%q %w", value, errNotNumberOrOff)
|
|
}
|
|
|
|
return n, nil
|
|
}
|
|
|
|
// appNameMaxLength is the most characters RFC 5424 allows in an
|
|
// APP-NAME.
|
|
const appNameMaxLength = 48
|
|
|
|
// isAppName reports whether value can be an APP-NAME: 1 to
|
|
// appNameMaxLength printable ASCII characters, none of them a space.
|
|
func isAppName(value string) bool {
|
|
if value == "" || len(value) > appNameMaxLength {
|
|
return false
|
|
}
|
|
|
|
for _, char := range []byte(value) {
|
|
if char < '!' || char > '~' {
|
|
return false
|
|
}
|
|
}
|
|
|
|
return true
|
|
}
|