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 before the colon of a part's header. 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
2225 lines
78 KiB
Go
2225 lines
78 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, a rule or the Core Rule Set 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
|
|
// IPv6GroupPrefix is the length of the IPv6 netblock that is one client
|
|
// (SWWAF_IPV6_GROUP_PREFIX), from 32 to 128.
|
|
IPv6GroupPrefix int
|
|
// MaxTrackedClients is the most clients the table of clients holds, in
|
|
// memory and in clients.json (SWWAF_MAX_TRACKED_CLIENTS).
|
|
MaxTrackedClients int
|
|
// 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
|
|
// CrowdSecDecisionsURL is where the decision list of the CrowdSec
|
|
// engine whose local API SWWAF_CROWDSEC_LAPI_URL names is fetched from:
|
|
// that URL with v1/decisions added to its path, "" while it is unset and
|
|
// none is. CrowdSecKey is the key the engine is asked with
|
|
// (SWWAF_CROWDSEC_LAPI_KEY).
|
|
CrowdSecDecisionsURL string
|
|
CrowdSecKey string
|
|
// 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
|
|
// LogLevel is the least severe of the process's own messages that are
|
|
// written (SWWAF_LOG_LEVEL). It holds back no request log line.
|
|
LogLevel slog.Level
|
|
// 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
|
|
// WAFMode is what the Core Rule Set does (SWWAF_WAF_MODE): WAFModeOff,
|
|
// WAFModeDetect or WAFModeBlock. WAFParanoiaLevel is its paranoia
|
|
// level, from 1 to 4 (SWWAF_WAF_PARANOIA_LEVEL), and
|
|
// WAFAnomalyThreshold the anomaly score at which a request is a match
|
|
// (SWWAF_WAF_ANOMALY_THRESHOLD), 0 while it is off. WAFDisabledRules
|
|
// are the ids of its rules switched off (SWWAF_WAF_DISABLED_RULES),
|
|
// WAFExemptPaths the path prefixes it does not inspect
|
|
// (SWWAF_WAF_EXEMPT_PATHS), and WAFBodyLimit the most of a request body
|
|
// it reads (SWWAF_WAF_BODY_LIMIT), 0 while it is off and it reads none.
|
|
WAFMode string
|
|
WAFParanoiaLevel int
|
|
WAFAnomalyThreshold int
|
|
WAFDisabledRules []int
|
|
WAFExemptPaths []string
|
|
WAFBodyLimit int64
|
|
// TrapPaths are the paths a request for which is a clear sign of
|
|
// attack (SWWAF_TRAP_PATHS), each starting with / and without a ?.
|
|
TrapPaths []string
|
|
// ErrorBurstThreshold is the most requests of a client within a minute
|
|
// that smallwebwaf may refuse after a rule file or Core Rule Set match
|
|
// or for a missing or wrong token; one more breaks a limit
|
|
// (SWWAF_ERROR_BURST_THRESHOLD). 0 is off.
|
|
ErrorBurstThreshold int64
|
|
// 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"
|
|
|
|
// The values of SWWAF_WAF_MODE.
|
|
const (
|
|
// WAFModeOff runs no request through the Core Rule Set.
|
|
WAFModeOff = off
|
|
// WAFModeDetect logs and alerts a match, and refuses nothing.
|
|
WAFModeDetect = "detect"
|
|
// WAFModeBlock refuses a match with 403.
|
|
WAFModeBlock = "block"
|
|
)
|
|
|
|
// 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
|
|
// minIPv6GroupPrefix is the shortest SWWAF_IPV6_GROUP_PREFIX, the
|
|
// netblock a provider is usually given: a shorter one would make one
|
|
// client of the customers of several providers.
|
|
minIPv6GroupPrefix = 32
|
|
// minTokenLength is the fewest characters a token may have.
|
|
minTokenLength = 32
|
|
// maxParanoiaLevel is the Core Rule Set's highest paranoia level.
|
|
maxParanoiaLevel = 4
|
|
// firstSetupRuleID to lastSetupRuleID are the ids the Core Rule Set
|
|
// keeps for the rules that set it up, which smallwebwaf's own rules
|
|
// have too (see internal/waf). Switching one off would undo a change
|
|
// that no setting undoes.
|
|
firstSetupRuleID = 900000
|
|
lastSetupRuleID = 900999
|
|
// 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")
|
|
errOver1G = errors.New("is more than 1G, the most Coraza reads")
|
|
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")
|
|
errNotIPv6GroupPrefix = errors.New(
|
|
"is not the length of an IPv6 netblock, from 32 to 128, such as 64")
|
|
errNotLogLevel = errors.New("is not debug, info, warn or error")
|
|
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/")
|
|
errNotTrapPath = errors.New(
|
|
"is not a path starting with / and without a ?, such as /wp-login.php")
|
|
errNotWAFMode = errors.New("is not off, detect or block")
|
|
errNotParanoiaLevel = errors.New("is not a paranoia level, from 1 to 4")
|
|
errNotRuleID = errors.New(
|
|
"is not the id of a Core Rule Set rule, a whole number such as 942100")
|
|
errSetupRuleID = errors.New(
|
|
"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")
|
|
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")
|
|
errNotLAPIURL = errors.New(
|
|
"is not an http or https URL without a user or a fragment, " +
|
|
"such as http://172.17.0.1:8080")
|
|
errNeedsLAPIKey = errors.New("the engine answers no request without it")
|
|
errLAPIKeyUnused = errors.New("it is sent only to the engine at that URL")
|
|
errAnotherList = errors.New(
|
|
"is in SWWAF_BLOCKLIST_URLS or is SWWAF_ASN_LIMIT_PERCENT_URL too")
|
|
)
|
|
|
|
// 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),
|
|
IPv6GroupPrefix: env.ipv6GroupPrefix("SWWAF_IPV6_GROUP_PREFIX", "64"),
|
|
MaxTrackedClients: env.numberNotOff("SWWAF_MAX_TRACKED_CLIENTS", "20000"),
|
|
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"),
|
|
CrowdSecDecisionsURL: env.crowdSecDecisionsURL("SWWAF_CROWDSEC_LAPI_URL"),
|
|
CrowdSecKey: env.secret("SWWAF_CROWDSEC_LAPI_KEY"),
|
|
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"),
|
|
LogLevel: env.logLevel("SWWAF_LOG_LEVEL", "info"),
|
|
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"),
|
|
WAFMode: env.wafMode("SWWAF_WAF_MODE", WAFModeBlock),
|
|
WAFParanoiaLevel: env.paranoiaLevel("SWWAF_WAF_PARANOIA_LEVEL", "1"),
|
|
WAFAnomalyThreshold: env.numberOrOff("SWWAF_WAF_ANOMALY_THRESHOLD", "5"),
|
|
WAFDisabledRules: env.ruleIDs("SWWAF_WAF_DISABLED_RULES",
|
|
"920340,920420,920440,920640,930130,930140"),
|
|
WAFExemptPaths: env.pathPrefixes("SWWAF_WAF_EXEMPT_PATHS", ""),
|
|
WAFBodyLimit: env.wafBodyLimit("SWWAF_WAF_BODY_LIMIT", off),
|
|
TrapPaths: env.trapPaths("SWWAF_TRAP_PATHS"),
|
|
ErrorBurstThreshold: env.count("SWWAF_ERROR_BURST_THRESHOLD", "30"),
|
|
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)
|
|
env.checkCrowdSec(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
|
|
}
|
|
|
|
// wafBodyLimit reads the setting that is the most of a request body the
|
|
// Core Rule Set reads.
|
|
func (e *environment) wafBodyLimit(name, defaultValue string) int64 {
|
|
limit, err := parseWAFBodyLimit(e.value(name, defaultValue))
|
|
e.check(name, err)
|
|
|
|
return limit
|
|
}
|
|
|
|
// 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
|
|
}
|
|
|
|
// trapPaths reads the setting that is the list of trap paths. It is empty
|
|
// by default.
|
|
func (e *environment) trapPaths(name string) []string {
|
|
paths, err := parseTrapPaths(e.value(name, ""))
|
|
e.check(name, err)
|
|
|
|
return paths
|
|
}
|
|
|
|
// wafMode reads the setting that is what the Core Rule Set does: off,
|
|
// detect or block.
|
|
func (e *environment) wafMode(name, defaultValue string) string {
|
|
mode := e.value(name, defaultValue)
|
|
if mode != WAFModeOff && mode != WAFModeDetect && mode != WAFModeBlock {
|
|
e.check(name, fmt.Errorf("%q %w", mode, errNotWAFMode))
|
|
}
|
|
|
|
return mode
|
|
}
|
|
|
|
// paranoiaLevel reads the setting that is the Core Rule Set's paranoia
|
|
// level, from 1 to 4.
|
|
func (e *environment) paranoiaLevel(name, defaultValue string) int {
|
|
value := e.value(name, defaultValue)
|
|
|
|
level, err := strconv.Atoi(value)
|
|
if err != nil || level < 1 || level > maxParanoiaLevel {
|
|
e.check(name, fmt.Errorf("%q %w", value, errNotParanoiaLevel))
|
|
}
|
|
|
|
return level
|
|
}
|
|
|
|
// ruleIDs reads the setting that is a list of the ids of Core Rule Set
|
|
// rules.
|
|
func (e *environment) ruleIDs(name, defaultValue string) []int {
|
|
ids, err := parseRuleIDs(e.value(name, defaultValue))
|
|
e.check(name, err)
|
|
|
|
return ids
|
|
}
|
|
|
|
// 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
|
|
}
|
|
|
|
// crowdSecDecisionsURL reads the setting that is the URL of the CrowdSec
|
|
// engine's local API, such as http://172.17.0.1:8080, and returns the URL
|
|
// its decision list is fetched from, that URL with v1/decisions added to
|
|
// its path, "" while it is unset or empty.
|
|
func (e *environment) crowdSecDecisionsURL(name string) string {
|
|
value := e.value(name, "")
|
|
if value == "" {
|
|
return ""
|
|
}
|
|
|
|
lapi, err := url.Parse(value)
|
|
if err != nil || !isHTTPURL(lapi) {
|
|
e.check(name, fmt.Errorf("%q %w", value, errNotLAPIURL))
|
|
|
|
return ""
|
|
}
|
|
|
|
return lapi.JoinPath("v1", "decisions").String()
|
|
}
|
|
|
|
// 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))
|
|
}
|
|
}
|
|
|
|
// checkCrowdSec refuses SWWAF_CROWDSEC_LAPI_URL without
|
|
// SWWAF_CROWDSEC_LAPI_KEY, the key without the URL, and a decision list
|
|
// that is fetched as another list too.
|
|
func (e *environment) checkCrowdSec(cfg *Config) {
|
|
decisionsURL := cfg.CrowdSecDecisionsURL
|
|
|
|
switch {
|
|
case decisionsURL != "" && cfg.CrowdSecKey == "":
|
|
e.check("SWWAF_CROWDSEC_LAPI_URL", fmt.Errorf(
|
|
"is set while SWWAF_CROWDSEC_LAPI_KEY is unset; %w", errNeedsLAPIKey))
|
|
case decisionsURL == "" && cfg.CrowdSecKey != "":
|
|
e.check("SWWAF_CROWDSEC_LAPI_KEY", fmt.Errorf(
|
|
"is set while SWWAF_CROWDSEC_LAPI_URL is unset; %w", errLAPIKeyUnused))
|
|
case decisionsURL != "" && (slices.Contains(cfg.BlocklistURLs, decisionsURL) ||
|
|
decisionsURL == cfg.ASNLimitPercentURL):
|
|
e.check("SWWAF_CROWDSEC_LAPI_URL", fmt.Errorf("gives the decision list %q, which %w",
|
|
decisionsURL, errAnotherList))
|
|
}
|
|
}
|
|
|
|
// 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
|
|
}
|
|
|
|
// ipv6GroupPrefix reads the setting that is the length of the IPv6
|
|
// netblock that is one client, from minIPv6GroupPrefix to 128.
|
|
func (e *environment) ipv6GroupPrefix(name, defaultValue string) int {
|
|
value := e.value(name, defaultValue)
|
|
|
|
length, err := strconv.Atoi(value)
|
|
if err != nil || length < minIPv6GroupPrefix || length > ipv6Bits {
|
|
e.check(name, fmt.Errorf("%q %w", value, errNotIPv6GroupPrefix))
|
|
}
|
|
|
|
return length
|
|
}
|
|
|
|
// logLevel reads the setting that is the least severe of the process's
|
|
// own messages that are written: debug, info, warn or error.
|
|
func (e *environment) logLevel(name, defaultValue string) slog.Level {
|
|
value := e.value(name, defaultValue)
|
|
|
|
level, known := map[string]slog.Level{
|
|
"debug": slog.LevelDebug,
|
|
"info": slog.LevelInfo,
|
|
"warn": slog.LevelWarn,
|
|
"error": slog.LevelError,
|
|
}[value]
|
|
if !known {
|
|
e.check(name, fmt.Errorf("%q %w", value, errNotLogLevel))
|
|
}
|
|
|
|
return level
|
|
}
|
|
|
|
// 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
|
|
}
|
|
|
|
// parseWAFBodyLimit reads the most of a request body the Core Rule Set
|
|
// reads: a size as parseSize reads it, or off, but at most 1G, since
|
|
// Coraza, which runs the Core Rule Set, refuses to load with more.
|
|
func parseWAFBodyLimit(value string) (int64, error) {
|
|
limit, err := parseSize(value)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
|
|
if limit > gibibyte {
|
|
return 0, fmt.Errorf("%q %w", value, errOver1G)
|
|
}
|
|
|
|
return limit, 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
|
|
}
|
|
|
|
// parseTrapPaths reads a comma-separated list of trap paths. Each is
|
|
// matched against a request's path as a path rule is, without the query,
|
|
// so a path that does not start with / or holds a ? would never match.
|
|
func parseTrapPaths(value string) ([]string, error) {
|
|
paths, err := parseList(value)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
for _, path := range paths {
|
|
if !strings.HasPrefix(path, "/") || strings.Contains(path, "?") {
|
|
return nil, fmt.Errorf("%q %w", path, errNotTrapPath)
|
|
}
|
|
}
|
|
|
|
return paths, nil
|
|
}
|
|
|
|
// parseRuleIDs reads a comma-separated list of the ids of Core Rule Set
|
|
// rules, each a whole number above zero and outside firstSetupRuleID to
|
|
// lastSetupRuleID.
|
|
func parseRuleIDs(value string) ([]int, error) {
|
|
items, err := parseList(value)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
ids := make([]int, len(items))
|
|
|
|
for i, item := range items {
|
|
ids[i], err = strconv.Atoi(item)
|
|
if err != nil || ids[i] <= 0 {
|
|
return nil, fmt.Errorf("%q %w", item, errNotRuleID)
|
|
}
|
|
|
|
if ids[i] >= firstSetupRuleID && ids[i] <= lastSetupRuleID {
|
|
return nil, fmt.Errorf("%q %w", item, errSetupRuleID)
|
|
}
|
|
}
|
|
|
|
return ids, 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
|
|
}
|