rproxy and servewaf replace the one-process smallwebwaf (closes #172, closes #165)
check / check (push) Successful in 17m6s
check / check (push) Successful in 17m6s
`smallwebwaf` with no command now prints the commands and exits with status 2. The image runs rproxy, or servewaf with SMALLWEBWAF_COMMAND=servewaf. The passing-on code, its settings and its log fields and metric go. internal/proxy keeps the checks, answers, reports and own endpoints; its tests run through the real rproxy. The documents describe the pair. Judgement call: clients.json keeps token_refused, since state files refuse unknown fields. Deviation: script/gitea-requests sets SWWAF_LOG_REQUEST_HEADERS, to label requests. Model: opus-5-5
This commit is contained in:
+51
-122
@@ -1,9 +1,9 @@
|
||||
// Package config reads smallwebwaf's settings, and is the one place they are
|
||||
// read. Every setting is an environment variable. The settings of
|
||||
// `smallwebwaf` and `smallwebwaf servewaf` start with SWWAF_, every one of them
|
||||
// has a default, and most can be given as a file instead. Those of `smallwebwaf
|
||||
// rproxy` start with SWRP_. Each of them but SWRP_WAF_BACKEND has a default, and
|
||||
// none can be given as a file, since rproxy never touches disk.
|
||||
// read. Every setting is an environment variable. The settings of `smallwebwaf
|
||||
// servewaf` start with SWWAF_, every one of them has a default, and most can be
|
||||
// given as a file instead. Those of `smallwebwaf rproxy` start with SWRP_. Each
|
||||
// of them but SWRP_WAF_BACKEND has a default, and none can be given as a file,
|
||||
// since rproxy never touches disk.
|
||||
package config
|
||||
|
||||
import (
|
||||
@@ -30,13 +30,11 @@ import (
|
||||
"sneak.berlin/go/smallwebwaf/internal/remotelog"
|
||||
)
|
||||
|
||||
// Config is smallwebwaf's settings. A timeout, size or rate limit of zero
|
||||
// is off.
|
||||
// Config is the settings of `smallwebwaf servewaf`. A time or size limit of
|
||||
// zero is off.
|
||||
type Config struct {
|
||||
// ListenAddr is where smallwebwaf listens (SWWAF_LISTEN_ADDR).
|
||||
// ListenAddr is where servewaf 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
|
||||
@@ -45,42 +43,14 @@ type Config struct {
|
||||
// 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.
|
||||
// allowed 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).
|
||||
@@ -260,9 +230,8 @@ type Config struct {
|
||||
// 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.
|
||||
// that servewaf may refuse after a rule file or Core Rule Set match; 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.
|
||||
@@ -365,8 +334,8 @@ const (
|
||||
// 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 is the default of SWWAF_LISTEN_ADDR and
|
||||
// SWRP_LISTEN_ADDR, and defaultUpstreamURL that of SWRP_UPSTREAM_URL.
|
||||
defaultListenAddr = ":8080"
|
||||
defaultUpstreamURL = "http://127.0.0.1:8081"
|
||||
)
|
||||
@@ -394,13 +363,13 @@ var (
|
||||
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")
|
||||
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")
|
||||
errOver64K = errors.New(
|
||||
"is more than 64K, the most servewaf gets of a body")
|
||||
errNotDurationAboveZero = errors.New(
|
||||
"is not a duration above zero, such as 1h or 7d")
|
||||
errNotNumberAboveZero = errors.New(
|
||||
@@ -489,38 +458,27 @@ var (
|
||||
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),
|
||||
ListenAddr: env.address("SWWAF_LISTEN_ADDR"),
|
||||
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", "1G"),
|
||||
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", "500"),
|
||||
RateLimitPerHour: env.count("SWWAF_RATE_LIMIT_PER_HOUR", "20000"),
|
||||
RateLimitPerDay: env.count("SWWAF_RATE_LIMIT_PER_DAY", "300000"),
|
||||
RateLimitExemptPaths: env.pathPrefixes("SWWAF_RATE_LIMIT_EXEMPT_PATHS", ""),
|
||||
BytesLimitPerMinute: env.size("SWWAF_BYTES_LIMIT_PER_MINUTE", "4G"),
|
||||
BytesLimitPerHour: env.size("SWWAF_BYTES_LIMIT_PER_HOUR", "20G"),
|
||||
BytesLimitPerDay: env.size("SWWAF_BYTES_LIMIT_PER_DAY", "200G"),
|
||||
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", "800ms"),
|
||||
AddLookupHeaders: env.boolean("SWWAF_ADD_LOOKUP_HEADERS", "false"),
|
||||
DeniedCountries: env.countries("SWWAF_DENIED_COUNTRIES", ""),
|
||||
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", "500"),
|
||||
RateLimitPerHour: env.count("SWWAF_RATE_LIMIT_PER_HOUR", "20000"),
|
||||
RateLimitPerDay: env.count("SWWAF_RATE_LIMIT_PER_DAY", "300000"),
|
||||
RateLimitExemptPaths: env.pathPrefixes("SWWAF_RATE_LIMIT_EXEMPT_PATHS", ""),
|
||||
BytesLimitPerMinute: env.size("SWWAF_BYTES_LIMIT_PER_MINUTE", "4G"),
|
||||
BytesLimitPerHour: env.size("SWWAF_BYTES_LIMIT_PER_HOUR", "20G"),
|
||||
BytesLimitPerDay: env.size("SWWAF_BYTES_LIMIT_PER_DAY", "200G"),
|
||||
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", "800ms"),
|
||||
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),
|
||||
@@ -612,22 +570,14 @@ func FromEnvironment(lookupEnv func(string) (string, bool)) (*Config, error) {
|
||||
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) {
|
||||
// ServeWAFListenAddr reads only SWWAF_LISTEN_ADDR, which may be given as a
|
||||
// file, as FromEnvironment does. The health check in servewaf's container needs
|
||||
// no other setting, so it reads no other, nor a file that another names.
|
||||
func ServeWAFListenAddr(lookupEnv func(string) (string, bool)) (string, error) {
|
||||
env := &environment{lookupEnv: lookupEnv}
|
||||
listenAddr := env.address("SWWAF_LISTEN_ADDR", defaultListenAddr)
|
||||
upstreamURL := env.appURL("SWWAF_UPSTREAM_URL", defaultUpstreamURL)
|
||||
listenAddr := env.address("SWWAF_LISTEN_ADDR")
|
||||
|
||||
if env.err != nil {
|
||||
return "", nil, env.err
|
||||
}
|
||||
|
||||
return listenAddr, upstreamURL, nil
|
||||
return listenAddr, env.err
|
||||
}
|
||||
|
||||
// InstanceName reads only SWWAF_INSTANCE_NAME, which may be given as a
|
||||
@@ -709,9 +659,10 @@ func (e *environment) check(name string, err error) {
|
||||
}
|
||||
}
|
||||
|
||||
// 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))
|
||||
// address reads a setting that is an address to listen on, by default
|
||||
// defaultListenAddr.
|
||||
func (e *environment) address(name string) string {
|
||||
address, err := parseListenAddr(e.value(name, defaultListenAddr))
|
||||
e.check(name, err)
|
||||
|
||||
return address
|
||||
@@ -779,15 +730,6 @@ func (e *environment) wafBodyLimit(name, defaultValue string) int64 {
|
||||
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))
|
||||
@@ -1434,30 +1376,17 @@ func parseSize(value string) (int64, error) {
|
||||
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.
|
||||
// reads: a size as parseSize reads it, or off, but at most 64K, since servewaf
|
||||
// gets no more of a body than that.
|
||||
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)
|
||||
if limit > 64*kibibyte {
|
||||
return 0, fmt.Errorf("%q %w", value, errOver64K)
|
||||
}
|
||||
|
||||
return limit, nil
|
||||
|
||||
+260
-373
@@ -23,98 +23,88 @@ import (
|
||||
|
||||
// The settings, by name.
|
||||
const (
|
||||
listenAddr = "SWWAF_LISTEN_ADDR"
|
||||
upstreamURL = "SWWAF_UPSTREAM_URL"
|
||||
mode = "SWWAF_MODE"
|
||||
trustedProxies = "SWWAF_TRUSTED_PROXIES"
|
||||
ipv6GroupPrefix = "SWWAF_IPV6_GROUP_PREFIX"
|
||||
maxTrackedClients = "SWWAF_MAX_TRACKED_CLIENTS"
|
||||
clientRequestTimeout = "SWWAF_CLIENT_REQUEST_TIMEOUT"
|
||||
clientHeaderMaxBytes = "SWWAF_CLIENT_REQUEST_HEADER_MAX_BYTES"
|
||||
clientIdleTimeout = "SWWAF_CLIENT_IDLE_TIMEOUT"
|
||||
clientResponseTimeout = "SWWAF_CLIENT_RESPONSE_TIMEOUT"
|
||||
upstreamRequestTimeout = "SWWAF_UPSTREAM_REQUEST_TIMEOUT"
|
||||
upstreamResponseTimeout = "SWWAF_UPSTREAM_RESPONSE_TIMEOUT"
|
||||
requestMaxBytes = "SWWAF_REQUEST_MAX_BYTES"
|
||||
responseMaxBytes = "SWWAF_RESPONSE_MAX_BYTES"
|
||||
allowNets = "SWWAF_ALLOW_NETS"
|
||||
rateLimitExemptNets = "SWWAF_RATE_LIMIT_EXEMPT_NETS"
|
||||
denyNets = "SWWAF_DENY_NETS"
|
||||
rateLimitPerMinute = "SWWAF_RATE_LIMIT_PER_MINUTE"
|
||||
rateLimitPerHour = "SWWAF_RATE_LIMIT_PER_HOUR"
|
||||
rateLimitPerDay = "SWWAF_RATE_LIMIT_PER_DAY"
|
||||
rateLimitExemptPaths = "SWWAF_RATE_LIMIT_EXEMPT_PATHS"
|
||||
bytesLimitPerMinute = "SWWAF_BYTES_LIMIT_PER_MINUTE"
|
||||
bytesLimitPerHour = "SWWAF_BYTES_LIMIT_PER_HOUR"
|
||||
bytesLimitPerDay = "SWWAF_BYTES_LIMIT_PER_DAY"
|
||||
bytesCount = "SWWAF_BYTES_COUNT"
|
||||
lookupSource = "SWWAF_LOOKUP_SOURCE"
|
||||
lookupDBPath = "SWWAF_LOOKUP_DB_PATH"
|
||||
lookupTimeout = "SWWAF_LOOKUP_TIMEOUT"
|
||||
addLookupHeaders = "SWWAF_ADD_LOOKUP_HEADERS"
|
||||
deniedCountries = "SWWAF_DENIED_COUNTRIES"
|
||||
allowedCountries = "SWWAF_EXCLUSIVELY_ALLOWED_COUNTRIES"
|
||||
asnLimitPercent = "SWWAF_ASN_LIMIT_PERCENT"
|
||||
countryLimitPercent = "SWWAF_COUNTRY_LIMIT_PERCENT"
|
||||
asnBytesPercent = "SWWAF_ASN_BYTES_PERCENT"
|
||||
countryBytesPercent = "SWWAF_COUNTRY_BYTES_PERCENT"
|
||||
unknownLimitPercent = "SWWAF_UNKNOWN_LIMIT_PERCENT"
|
||||
asnLimitPercentURL = "SWWAF_ASN_LIMIT_PERCENT_URL"
|
||||
blocklistURLs = "SWWAF_BLOCKLIST_URLS"
|
||||
blocklistRefresh = "SWWAF_BLOCKLIST_REFRESH"
|
||||
blocklistAction = "SWWAF_BLOCKLIST_ACTION"
|
||||
dnsblZones = "SWWAF_DNSBL_ZONES"
|
||||
dnsblResolver = "SWWAF_DNSBL_RESOLVER"
|
||||
abuseIPDBKey = "SWWAF_ABUSEIPDB_KEY"
|
||||
abuseIPDBMinScore = "SWWAF_ABUSEIPDB_MIN_SCORE"
|
||||
abuseIPDBDailyBudget = "SWWAF_ABUSEIPDB_DAILY_BUDGET"
|
||||
reputationAction = "SWWAF_REPUTATION_ACTION"
|
||||
reputationCacheTTL = "SWWAF_REPUTATION_CACHE_TTL"
|
||||
reputationTimeout = "SWWAF_REPUTATION_TIMEOUT"
|
||||
crowdSecURL = "SWWAF_CROWDSEC_LAPI_URL"
|
||||
crowdSecKey = "SWWAF_CROWDSEC_LAPI_KEY"
|
||||
banResponse = "SWWAF_BAN_RESPONSE"
|
||||
limitBanDuration = "SWWAF_LIMIT_BAN_DURATION"
|
||||
limitBanRepeatWindow = "SWWAF_LIMIT_BAN_REPEAT_WINDOW"
|
||||
maxBanDuration = "SWWAF_MAX_BAN_DURATION"
|
||||
attackBanDuration = "SWWAF_ATTACK_BAN_DURATION"
|
||||
maxBans = "SWWAF_MAX_BANS"
|
||||
banScopeV4Prefix = "SWWAF_BAN_SCOPE_V4_PREFIX"
|
||||
stateDir = "SWWAF_STATE_DIR"
|
||||
stateWriteDelay = "SWWAF_STATE_WRITE_DELAY"
|
||||
stateCounterInterval = "SWWAF_STATE_COUNTER_INTERVAL"
|
||||
adminToken = "SWWAF_ADMIN_TOKEN" //nolint:gosec // the setting's name
|
||||
metricsToken = "SWWAF_METRICS_TOKEN" //nolint:gosec // the setting's name
|
||||
metricsTopN = "SWWAF_METRICS_TOP_N"
|
||||
instanceName = "SWWAF_INSTANCE_NAME"
|
||||
logRequestHeaders = "SWWAF_LOG_REQUEST_HEADERS"
|
||||
logLevel = "SWWAF_LOG_LEVEL"
|
||||
rulesDir = "SWWAF_RULES_DIR"
|
||||
rulesEnabled = "SWWAF_RULES_ENABLED"
|
||||
wafMode = "SWWAF_WAF_MODE"
|
||||
wafParanoiaLevel = "SWWAF_WAF_PARANOIA_LEVEL"
|
||||
wafAnomalyThreshold = "SWWAF_WAF_ANOMALY_THRESHOLD"
|
||||
wafDisabledRules = "SWWAF_WAF_DISABLED_RULES"
|
||||
wafExemptPaths = "SWWAF_WAF_EXEMPT_PATHS"
|
||||
wafBodyLimit = "SWWAF_WAF_BODY_LIMIT"
|
||||
trapPaths = "SWWAF_TRAP_PATHS"
|
||||
errorBurstThreshold = "SWWAF_ERROR_BURST_THRESHOLD"
|
||||
logRemoteURL = "SWWAF_LOG_REMOTE_URL"
|
||||
logRemoteTLSCAFile = "SWWAF_LOG_REMOTE_TLS_CA_FILE"
|
||||
logRemoteBuffer = "SWWAF_LOG_REMOTE_BUFFER"
|
||||
logRemoteFacility = "SWWAF_LOG_REMOTE_FACILITY"
|
||||
logRemoteAppName = "SWWAF_LOG_REMOTE_APP_NAME"
|
||||
alertWebhookURL = "SWWAF_ALERT_WEBHOOK_URL"
|
||||
alertWebhookHeaders = "SWWAF_ALERT_WEBHOOK_HEADERS"
|
||||
alertSlackWebhookURL = "SWWAF_ALERT_SLACK_WEBHOOK_URL"
|
||||
alertNtfyURL = "SWWAF_ALERT_NTFY_URL"
|
||||
alertNtfyToken = "SWWAF_ALERT_NTFY_TOKEN" //nolint:gosec // the setting's name
|
||||
alertEvents = "SWWAF_ALERT_EVENTS"
|
||||
alertCooldown = "SWWAF_ALERT_COOLDOWN"
|
||||
alertMaxPerHour = "SWWAF_ALERT_MAX_PER_HOUR"
|
||||
anomalyNetV4Prefix = "SWWAF_ANOMALY_NET_V4_PREFIX"
|
||||
anomalyNetV6Prefix = "SWWAF_ANOMALY_NET_V6_PREFIX"
|
||||
watchNets = "SWWAF_WATCH_NETS"
|
||||
listenAddr = "SWWAF_LISTEN_ADDR"
|
||||
mode = "SWWAF_MODE"
|
||||
ipv6GroupPrefix = "SWWAF_IPV6_GROUP_PREFIX"
|
||||
maxTrackedClients = "SWWAF_MAX_TRACKED_CLIENTS"
|
||||
allowNets = "SWWAF_ALLOW_NETS"
|
||||
rateLimitExemptNets = "SWWAF_RATE_LIMIT_EXEMPT_NETS"
|
||||
denyNets = "SWWAF_DENY_NETS"
|
||||
rateLimitPerMinute = "SWWAF_RATE_LIMIT_PER_MINUTE"
|
||||
rateLimitPerHour = "SWWAF_RATE_LIMIT_PER_HOUR"
|
||||
rateLimitPerDay = "SWWAF_RATE_LIMIT_PER_DAY"
|
||||
rateLimitExemptPaths = "SWWAF_RATE_LIMIT_EXEMPT_PATHS"
|
||||
bytesLimitPerMinute = "SWWAF_BYTES_LIMIT_PER_MINUTE"
|
||||
bytesLimitPerHour = "SWWAF_BYTES_LIMIT_PER_HOUR"
|
||||
bytesLimitPerDay = "SWWAF_BYTES_LIMIT_PER_DAY"
|
||||
bytesCount = "SWWAF_BYTES_COUNT"
|
||||
lookupSource = "SWWAF_LOOKUP_SOURCE"
|
||||
lookupDBPath = "SWWAF_LOOKUP_DB_PATH"
|
||||
lookupTimeout = "SWWAF_LOOKUP_TIMEOUT"
|
||||
addLookupHeaders = "SWWAF_ADD_LOOKUP_HEADERS"
|
||||
deniedCountries = "SWWAF_DENIED_COUNTRIES"
|
||||
allowedCountries = "SWWAF_EXCLUSIVELY_ALLOWED_COUNTRIES"
|
||||
asnLimitPercent = "SWWAF_ASN_LIMIT_PERCENT"
|
||||
countryLimitPercent = "SWWAF_COUNTRY_LIMIT_PERCENT"
|
||||
asnBytesPercent = "SWWAF_ASN_BYTES_PERCENT"
|
||||
countryBytesPercent = "SWWAF_COUNTRY_BYTES_PERCENT"
|
||||
unknownLimitPercent = "SWWAF_UNKNOWN_LIMIT_PERCENT"
|
||||
asnLimitPercentURL = "SWWAF_ASN_LIMIT_PERCENT_URL"
|
||||
blocklistURLs = "SWWAF_BLOCKLIST_URLS"
|
||||
blocklistRefresh = "SWWAF_BLOCKLIST_REFRESH"
|
||||
blocklistAction = "SWWAF_BLOCKLIST_ACTION"
|
||||
dnsblZones = "SWWAF_DNSBL_ZONES"
|
||||
dnsblResolver = "SWWAF_DNSBL_RESOLVER"
|
||||
abuseIPDBKey = "SWWAF_ABUSEIPDB_KEY"
|
||||
abuseIPDBMinScore = "SWWAF_ABUSEIPDB_MIN_SCORE"
|
||||
abuseIPDBDailyBudget = "SWWAF_ABUSEIPDB_DAILY_BUDGET"
|
||||
reputationAction = "SWWAF_REPUTATION_ACTION"
|
||||
reputationCacheTTL = "SWWAF_REPUTATION_CACHE_TTL"
|
||||
reputationTimeout = "SWWAF_REPUTATION_TIMEOUT"
|
||||
crowdSecURL = "SWWAF_CROWDSEC_LAPI_URL"
|
||||
crowdSecKey = "SWWAF_CROWDSEC_LAPI_KEY"
|
||||
banResponse = "SWWAF_BAN_RESPONSE"
|
||||
limitBanDuration = "SWWAF_LIMIT_BAN_DURATION"
|
||||
limitBanRepeatWindow = "SWWAF_LIMIT_BAN_REPEAT_WINDOW"
|
||||
maxBanDuration = "SWWAF_MAX_BAN_DURATION"
|
||||
attackBanDuration = "SWWAF_ATTACK_BAN_DURATION"
|
||||
maxBans = "SWWAF_MAX_BANS"
|
||||
banScopeV4Prefix = "SWWAF_BAN_SCOPE_V4_PREFIX"
|
||||
stateDir = "SWWAF_STATE_DIR"
|
||||
stateWriteDelay = "SWWAF_STATE_WRITE_DELAY"
|
||||
stateCounterInterval = "SWWAF_STATE_COUNTER_INTERVAL"
|
||||
adminToken = "SWWAF_ADMIN_TOKEN" //nolint:gosec // the setting's name
|
||||
metricsToken = "SWWAF_METRICS_TOKEN" //nolint:gosec // the setting's name
|
||||
metricsTopN = "SWWAF_METRICS_TOP_N"
|
||||
instanceName = "SWWAF_INSTANCE_NAME"
|
||||
logRequestHeaders = "SWWAF_LOG_REQUEST_HEADERS"
|
||||
logLevel = "SWWAF_LOG_LEVEL"
|
||||
rulesDir = "SWWAF_RULES_DIR"
|
||||
rulesEnabled = "SWWAF_RULES_ENABLED"
|
||||
wafMode = "SWWAF_WAF_MODE"
|
||||
wafParanoiaLevel = "SWWAF_WAF_PARANOIA_LEVEL"
|
||||
wafAnomalyThreshold = "SWWAF_WAF_ANOMALY_THRESHOLD"
|
||||
wafDisabledRules = "SWWAF_WAF_DISABLED_RULES"
|
||||
wafExemptPaths = "SWWAF_WAF_EXEMPT_PATHS"
|
||||
wafBodyLimit = "SWWAF_WAF_BODY_LIMIT"
|
||||
trapPaths = "SWWAF_TRAP_PATHS"
|
||||
errorBurstThreshold = "SWWAF_ERROR_BURST_THRESHOLD"
|
||||
logRemoteURL = "SWWAF_LOG_REMOTE_URL"
|
||||
logRemoteTLSCAFile = "SWWAF_LOG_REMOTE_TLS_CA_FILE"
|
||||
logRemoteBuffer = "SWWAF_LOG_REMOTE_BUFFER"
|
||||
logRemoteFacility = "SWWAF_LOG_REMOTE_FACILITY"
|
||||
logRemoteAppName = "SWWAF_LOG_REMOTE_APP_NAME"
|
||||
alertWebhookURL = "SWWAF_ALERT_WEBHOOK_URL"
|
||||
alertWebhookHeaders = "SWWAF_ALERT_WEBHOOK_HEADERS"
|
||||
alertSlackWebhookURL = "SWWAF_ALERT_SLACK_WEBHOOK_URL"
|
||||
alertNtfyURL = "SWWAF_ALERT_NTFY_URL"
|
||||
alertNtfyToken = "SWWAF_ALERT_NTFY_TOKEN" //nolint:gosec // the setting's name
|
||||
alertEvents = "SWWAF_ALERT_EVENTS"
|
||||
alertCooldown = "SWWAF_ALERT_COOLDOWN"
|
||||
alertMaxPerHour = "SWWAF_ALERT_MAX_PER_HOUR"
|
||||
anomalyNetV4Prefix = "SWWAF_ANOMALY_NET_V4_PREFIX"
|
||||
anomalyNetV6Prefix = "SWWAF_ANOMALY_NET_V6_PREFIX"
|
||||
watchNets = "SWWAF_WATCH_NETS"
|
||||
)
|
||||
|
||||
// The anomaly thresholds: each of the prefixes below, which name a scope,
|
||||
@@ -252,33 +242,25 @@ func TestDefaults(t *testing.T) {
|
||||
cfg := fromEnvironment(t, environment{})
|
||||
|
||||
wantSettings(t, cfg, config.Config{
|
||||
ListenAddr: ":8080",
|
||||
Observe: false,
|
||||
ClientRequestTimeout: time.Minute,
|
||||
ClientRequestHeaderMaxBytes: 32 << 10,
|
||||
ClientIdleTimeout: 2 * time.Minute,
|
||||
ClientResponseTimeout: 30 * time.Minute,
|
||||
UpstreamRequestTimeout: time.Minute,
|
||||
UpstreamResponseTimeout: 30 * time.Minute,
|
||||
RequestMaxBytes: 100 << 20,
|
||||
ResponseMaxBytes: 1 << 30,
|
||||
RateLimitPerMinute: 500,
|
||||
RateLimitPerHour: 20000,
|
||||
RateLimitPerDay: 300000,
|
||||
BanResponse: 403,
|
||||
LimitBanDuration: time.Hour,
|
||||
LimitBanRepeatWindow: 24 * time.Hour,
|
||||
MaxBanDuration: 7 * 24 * time.Hour,
|
||||
AttackBanDuration: 7 * 24 * time.Hour,
|
||||
MaxBans: 5000,
|
||||
BanScopeV4Prefix: 32,
|
||||
StateDir: "/var/lib/smallwebwaf",
|
||||
StateWriteDelay: 10 * time.Second,
|
||||
StateCounterInterval: 15 * time.Minute,
|
||||
MetricsToken: "",
|
||||
MetricsTopN: 50,
|
||||
RulesDir: "/etc/smallwebwaf/rules.d",
|
||||
RulesEnabled: true,
|
||||
ListenAddr: ":8080",
|
||||
Observe: false,
|
||||
RateLimitPerMinute: 500,
|
||||
RateLimitPerHour: 20000,
|
||||
RateLimitPerDay: 300000,
|
||||
BanResponse: 403,
|
||||
LimitBanDuration: time.Hour,
|
||||
LimitBanRepeatWindow: 24 * time.Hour,
|
||||
MaxBanDuration: 7 * 24 * time.Hour,
|
||||
AttackBanDuration: 7 * 24 * time.Hour,
|
||||
MaxBans: 5000,
|
||||
BanScopeV4Prefix: 32,
|
||||
StateDir: "/var/lib/smallwebwaf",
|
||||
StateWriteDelay: 10 * time.Second,
|
||||
StateCounterInterval: 15 * time.Minute,
|
||||
MetricsToken: "",
|
||||
MetricsTopN: 50,
|
||||
RulesDir: "/etc/smallwebwaf/rules.d",
|
||||
RulesEnabled: true,
|
||||
})
|
||||
wantLookupSettings(t, cfg, config.Config{
|
||||
LookupSource: defaultLookupSource, LookupTimeout: 800 * time.Millisecond,
|
||||
@@ -288,12 +270,6 @@ func TestDefaults(t *testing.T) {
|
||||
BytesLimitPerDay: 200 << 30, BytesCount: "both",
|
||||
})
|
||||
|
||||
if cfg.UpstreamURL.String() != "http://127.0.0.1:8081" {
|
||||
t.Errorf("%s is %s", upstreamURL, cfg.UpstreamURL)
|
||||
}
|
||||
|
||||
wantNetblocks(t, cfg.TrustedProxies,
|
||||
"10.0.0.0/8", "172.16.0.0/12", "192.168.0.0/16")
|
||||
wantNetblocks(t, cfg.AllowNets)
|
||||
wantNetblocks(t, cfg.RateLimitExemptNets)
|
||||
wantNetblocks(t, cfg.DenyNets)
|
||||
@@ -322,8 +298,13 @@ func TestNoSingleRequestBreaksAByteLimitAtTheDefaults(t *testing.T) {
|
||||
|
||||
cfg := fromEnvironment(t, environment{})
|
||||
|
||||
rproxy, err := rproxyConfig(map[string]string{wafBackend: backendURL})
|
||||
if err != nil {
|
||||
t.Fatalf("settings: %v", err)
|
||||
}
|
||||
|
||||
// The largest request body and the largest response, both counted.
|
||||
largest := cfg.RequestMaxBytes + cfg.ResponseMaxBytes
|
||||
largest := rproxy.MaxRequestSize + rproxy.MaxResponseSize
|
||||
for _, limit := range []int64{
|
||||
cfg.BytesLimitPerMinute, cfg.BytesLimitPerHour, cfg.BytesLimitPerDay,
|
||||
} {
|
||||
@@ -337,77 +318,54 @@ func TestValuesAsSet(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cfg := fromEnvironment(t, environment{
|
||||
listenAddr: "127.0.0.1:9000",
|
||||
upstreamURL: "https://app.internal:8443/",
|
||||
mode: "observe",
|
||||
trustedProxies: " 192.0.2.1, 10.1.2.3/8 ,2001:db8::/32",
|
||||
clientRequestTimeout: "90s",
|
||||
clientHeaderMaxBytes: "8K",
|
||||
clientIdleTimeout: "5m",
|
||||
clientResponseTimeout: "7d",
|
||||
upstreamRequestTimeout: "1h30m",
|
||||
upstreamResponseTimeout: off,
|
||||
requestMaxBytes: "512K",
|
||||
responseMaxBytes: "1234",
|
||||
allowNets: "192.0.2.7",
|
||||
rateLimitExemptNets: "2001:db8::/48, 10.9.8.7",
|
||||
denyNets: "198.51.100.0/24",
|
||||
rateLimitPerMinute: "60",
|
||||
rateLimitPerHour: "600",
|
||||
rateLimitPerDay: "6000",
|
||||
deniedCountries: "cn, RU,kp,Xk",
|
||||
allowedCountries: "de",
|
||||
banResponse: "429",
|
||||
limitBanDuration: "15m",
|
||||
limitBanRepeatWindow: "2d",
|
||||
maxBanDuration: "30d",
|
||||
attackBanDuration: "1d",
|
||||
maxBans: "100",
|
||||
banScopeV4Prefix: "24",
|
||||
stateDir: "/srv/waf-state",
|
||||
stateWriteDelay: "500ms",
|
||||
stateCounterInterval: "1h",
|
||||
metricsToken: token,
|
||||
metricsTopN: "10",
|
||||
rulesDir: "/srv/waf-rules",
|
||||
rulesEnabled: "false",
|
||||
listenAddr: "127.0.0.1:9000",
|
||||
mode: "observe",
|
||||
allowNets: "192.0.2.7",
|
||||
rateLimitExemptNets: "2001:db8::/48, 10.9.8.7",
|
||||
denyNets: "198.51.100.0/24",
|
||||
rateLimitPerMinute: "60",
|
||||
rateLimitPerHour: "600",
|
||||
rateLimitPerDay: "6000",
|
||||
deniedCountries: "cn, RU,kp,Xk",
|
||||
allowedCountries: "de",
|
||||
banResponse: "429",
|
||||
limitBanDuration: "15m",
|
||||
limitBanRepeatWindow: "2d",
|
||||
maxBanDuration: "30d",
|
||||
attackBanDuration: "1d",
|
||||
maxBans: "100",
|
||||
banScopeV4Prefix: "24",
|
||||
stateDir: "/srv/waf-state",
|
||||
stateWriteDelay: "500ms",
|
||||
stateCounterInterval: "1h",
|
||||
metricsToken: token,
|
||||
metricsTopN: "10",
|
||||
rulesDir: "/srv/waf-rules",
|
||||
rulesEnabled: "false",
|
||||
})
|
||||
|
||||
wantSettings(t, cfg, config.Config{
|
||||
ListenAddr: "127.0.0.1:9000",
|
||||
Observe: true,
|
||||
ClientRequestTimeout: 90 * time.Second,
|
||||
ClientRequestHeaderMaxBytes: 8 << 10,
|
||||
ClientIdleTimeout: 5 * time.Minute,
|
||||
ClientResponseTimeout: 7 * 24 * time.Hour,
|
||||
UpstreamRequestTimeout: 90 * time.Minute,
|
||||
UpstreamResponseTimeout: 0,
|
||||
RequestMaxBytes: 512 << 10,
|
||||
ResponseMaxBytes: 1234,
|
||||
RateLimitPerMinute: 60,
|
||||
RateLimitPerHour: 600,
|
||||
RateLimitPerDay: 6000,
|
||||
BanResponse: 429,
|
||||
LimitBanDuration: 15 * time.Minute,
|
||||
LimitBanRepeatWindow: 48 * time.Hour,
|
||||
MaxBanDuration: 30 * 24 * time.Hour,
|
||||
AttackBanDuration: 24 * time.Hour,
|
||||
MaxBans: 100,
|
||||
BanScopeV4Prefix: 24,
|
||||
StateDir: "/srv/waf-state",
|
||||
StateWriteDelay: 500 * time.Millisecond,
|
||||
StateCounterInterval: time.Hour,
|
||||
MetricsToken: token,
|
||||
MetricsTopN: 10,
|
||||
RulesDir: "/srv/waf-rules",
|
||||
RulesEnabled: false,
|
||||
ListenAddr: "127.0.0.1:9000",
|
||||
Observe: true,
|
||||
RateLimitPerMinute: 60,
|
||||
RateLimitPerHour: 600,
|
||||
RateLimitPerDay: 6000,
|
||||
BanResponse: 429,
|
||||
LimitBanDuration: 15 * time.Minute,
|
||||
LimitBanRepeatWindow: 48 * time.Hour,
|
||||
MaxBanDuration: 30 * 24 * time.Hour,
|
||||
AttackBanDuration: 24 * time.Hour,
|
||||
MaxBans: 100,
|
||||
BanScopeV4Prefix: 24,
|
||||
StateDir: "/srv/waf-state",
|
||||
StateWriteDelay: 500 * time.Millisecond,
|
||||
StateCounterInterval: time.Hour,
|
||||
MetricsToken: token,
|
||||
MetricsTopN: 10,
|
||||
RulesDir: "/srv/waf-rules",
|
||||
RulesEnabled: false,
|
||||
})
|
||||
|
||||
if cfg.UpstreamURL.String() != "https://app.internal:8443/" {
|
||||
t.Errorf("%s is %s", upstreamURL, cfg.UpstreamURL)
|
||||
}
|
||||
|
||||
wantNetblocks(t, cfg.TrustedProxies, "192.0.2.1/32", "10.0.0.0/8", "2001:db8::/32")
|
||||
wantNetblocks(t, cfg.AllowNets, "192.0.2.7/32")
|
||||
wantNetblocks(t, cfg.RateLimitExemptNets, "2001:db8::/48", "10.9.8.7/32")
|
||||
wantNetblocks(t, cfg.DenyNets, "198.51.100.0/24")
|
||||
@@ -581,13 +539,13 @@ func TestCoreRuleSetSettings(t *testing.T) {
|
||||
environment{
|
||||
wafMode: config.WAFModeDetect, wafParanoiaLevel: "4", wafAnomalyThreshold: "10",
|
||||
wafDisabledRules: "942100, 920350", wafExemptPaths: "/api/, /static/",
|
||||
wafBodyLimit: "128K",
|
||||
wafBodyLimit: "32K",
|
||||
},
|
||||
config.Config{
|
||||
WAFMode: config.WAFModeDetect, WAFParanoiaLevel: 4, WAFAnomalyThreshold: 10,
|
||||
WAFDisabledRules: []int{942100, 920350},
|
||||
WAFExemptPaths: []string{"/api/", "/static/"},
|
||||
WAFBodyLimit: 128 << 10,
|
||||
WAFBodyLimit: 32 << 10,
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -615,12 +573,12 @@ func TestCoreRuleSetSettings(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestWAFBodyLimitOf1G(t *testing.T) {
|
||||
func TestWAFBodyLimitOf64K(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cfg := fromEnvironment(t, environment{wafBodyLimit: "1G"})
|
||||
if cfg.WAFBodyLimit != 1<<30 {
|
||||
t.Errorf("1G read as %d", cfg.WAFBodyLimit)
|
||||
cfg := fromEnvironment(t, environment{wafBodyLimit: "64K"})
|
||||
if cfg.WAFBodyLimit != 64<<10 {
|
||||
t.Errorf("64K read as %d", cfg.WAFBodyLimit)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -669,10 +627,10 @@ func TestInvalidCoreRuleSetSettingStopsTheStart(t *testing.T) {
|
||||
wafBodyLimit, "128KB",
|
||||
`"128KB" is not a size such as 512K, 100M or 5G, or off`,
|
||||
},
|
||||
{wafBodyLimit, "2G", `"2G" is more than 1G, the most Coraza reads`},
|
||||
{wafBodyLimit, "65K", `"65K" is more than 64K, the most servewaf gets of a body`},
|
||||
{
|
||||
wafBodyLimit, "1073741825",
|
||||
`"1073741825" is more than 1G, the most Coraza reads`,
|
||||
wafBodyLimit, "65537",
|
||||
`"65537" is more than 64K, the most servewaf gets of a body`,
|
||||
},
|
||||
} {
|
||||
t.Run(tc.name+"="+tc.value, func(t *testing.T) {
|
||||
@@ -1967,45 +1925,15 @@ func TestSizesAndOff(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cfg := fromEnvironment(t, environment{
|
||||
requestMaxBytes: "3G",
|
||||
responseMaxBytes: off,
|
||||
clientRequestTimeout: off,
|
||||
clientIdleTimeout: off,
|
||||
bytesLimitPerMinute: "3G",
|
||||
bytesLimitPerHour: off,
|
||||
alertCooldown: off,
|
||||
})
|
||||
|
||||
if cfg.RequestMaxBytes != 3<<30 || cfg.ResponseMaxBytes != 0 ||
|
||||
cfg.ClientRequestTimeout != 0 || cfg.ClientIdleTimeout != 0 {
|
||||
t.Errorf("3G, off, off and off read as %d, %d, %s and %s",
|
||||
cfg.RequestMaxBytes, cfg.ResponseMaxBytes, cfg.ClientRequestTimeout,
|
||||
cfg.ClientIdleTimeout)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRequestHeaderMaxBytesJustOver4K(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cfg := fromEnvironment(t, environment{clientHeaderMaxBytes: "4097"})
|
||||
if cfg.ClientRequestHeaderMaxBytes != 4097 {
|
||||
t.Errorf("4097 read as %d", cfg.ClientRequestHeaderMaxBytes)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRequestHeaderMaxBytesRefusalNeverOffersOff(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
for _, value := range []string{"32KB", "0", "4K", off} {
|
||||
t.Run(value, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
_, err := config.FromEnvironment(
|
||||
environment{clientHeaderMaxBytes: value}.lookupEnv)
|
||||
|
||||
want := clientHeaderMaxBytes + `: "` + value +
|
||||
`" is not a size of more than 4K, such as 32K`
|
||||
if err == nil || err.Error() != want {
|
||||
t.Errorf("error %v, want %s", err, want)
|
||||
}
|
||||
})
|
||||
if cfg.BytesLimitPerMinute != 3<<30 || cfg.BytesLimitPerHour != 0 ||
|
||||
cfg.AlertCooldown != 0 {
|
||||
t.Errorf("3G, off and off read as %d, %d and %s",
|
||||
cfg.BytesLimitPerMinute, cfg.BytesLimitPerHour, cfg.AlertCooldown)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2034,34 +1962,12 @@ func TestBanResponseCloseIsZero(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestTrustedProxiesSetButEmptyTrustNothing(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cfg := fromEnvironment(t, environment{trustedProxies: ""})
|
||||
if len(cfg.TrustedProxies) != 0 {
|
||||
t.Errorf("trusted proxies %v, want none", cfg.TrustedProxies)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInvalidValueStopsTheStart(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
wantStartStopped(t, []struct{ name, value string }{
|
||||
{listenAddr, "8080"}, {listenAddr, ":http"}, {listenAddr, ":65536"},
|
||||
{upstreamURL, "127.0.0.1:8081"},
|
||||
{upstreamURL, "ftp://127.0.0.1:8081"},
|
||||
{upstreamURL, "http://"},
|
||||
{upstreamURL, "http://:8081"},
|
||||
{upstreamURL, "http://127.0.0.1:0"},
|
||||
{upstreamURL, "http://127.0.0.1:99999"},
|
||||
{upstreamURL, "http://127.0.0.1:8081/app"},
|
||||
{upstreamURL, "http://127.0.0.1:8081/?a=1"},
|
||||
{upstreamURL, "http://user:secret@127.0.0.1:8081"},
|
||||
{mode, "Observe"}, {mode, "block"}, {mode, ""},
|
||||
{trustedProxies, "10.0.0.0/33"},
|
||||
{trustedProxies, "traefik"},
|
||||
{trustedProxies, "10.0.0.0/8,,192.168.0.0/16"},
|
||||
{trustedProxies, "fe80::1%eth0"},
|
||||
{ipv6GroupPrefix, "31"}, {ipv6GroupPrefix, "129"}, {ipv6GroupPrefix, "/64"},
|
||||
{ipv6GroupPrefix, off}, {ipv6GroupPrefix, ""},
|
||||
{maxTrackedClients, "0"}, {maxTrackedClients, "-1"}, {maxTrackedClients, off},
|
||||
@@ -2069,19 +1975,18 @@ func TestInvalidValueStopsTheStart(t *testing.T) {
|
||||
{allowNets, "192.0.2.0/24,monitoring"},
|
||||
{rateLimitExemptNets, "2001:db8::/129"},
|
||||
{denyNets, "198.51.100.0/24,"},
|
||||
{clientRequestTimeout, "60"}, {clientRequestTimeout, ""},
|
||||
{clientIdleTimeout, "0s"},
|
||||
{clientIdleTimeout, "2 minutes"},
|
||||
{clientResponseTimeout, "1y"},
|
||||
{upstreamRequestTimeout, "-1s"},
|
||||
{upstreamResponseTimeout, "0s"},
|
||||
{upstreamResponseTimeout, "1.5d"},
|
||||
{requestMaxBytes, "100MB"},
|
||||
{requestMaxBytes, "100m"},
|
||||
{requestMaxBytes, "1.5M"},
|
||||
{responseMaxBytes, "0"},
|
||||
{responseMaxBytes, "-5"},
|
||||
{responseMaxBytes, "99999999999G"},
|
||||
{alertCooldown, "60"}, {alertCooldown, ""},
|
||||
{alertCooldown, "0s"},
|
||||
{alertCooldown, "2 minutes"},
|
||||
{alertCooldown, "1y"},
|
||||
{alertCooldown, "-1s"},
|
||||
{alertCooldown, "1.5d"},
|
||||
{bytesLimitPerMinute, "100MB"},
|
||||
{bytesLimitPerMinute, "100m"},
|
||||
{bytesLimitPerMinute, "1.5M"},
|
||||
{bytesLimitPerHour, "0"},
|
||||
{bytesLimitPerHour, "-5"},
|
||||
{bytesLimitPerHour, "99999999999G"},
|
||||
{rateLimitPerMinute, ""},
|
||||
{rateLimitPerMinute, "1K"},
|
||||
{rateLimitPerHour, "0"},
|
||||
@@ -2246,10 +2151,10 @@ func TestSettingFromFileIsCheckedAsTheSettingItself(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
_, err := config.FromEnvironment(environment{
|
||||
requestMaxBytes + "_FILE": writeFile(t, "lots\n"),
|
||||
bytesLimitPerMinute + "_FILE": writeFile(t, "lots\n"),
|
||||
}.lookupEnv)
|
||||
|
||||
want := requestMaxBytes + `: "lots" is not a size such as 512K, 100M or 5G, or off`
|
||||
want := bytesLimitPerMinute + `: "lots" is not a size such as 512K, 100M or 5G, or off`
|
||||
if err == nil || err.Error() != want {
|
||||
t.Errorf("error %v, want %s", err, want)
|
||||
}
|
||||
@@ -2354,99 +2259,89 @@ func writeFile(t *testing.T, contents string) string {
|
||||
func TestLogsEachSettingWithItsValue(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cfg := fromEnvironment(t, environment{clientRequestTimeout: "45s"})
|
||||
cfg := fromEnvironment(t, environment{rateLimitPerMinute: "45"})
|
||||
hostname, _ := os.Hostname()
|
||||
|
||||
want := map[string]string{
|
||||
listenAddr: ":8080",
|
||||
upstreamURL: "http://127.0.0.1:8081",
|
||||
mode: "enforce",
|
||||
trustedProxies: "10.0.0.0/8,172.16.0.0/12,192.168.0.0/16",
|
||||
ipv6GroupPrefix: "64",
|
||||
maxTrackedClients: "20000",
|
||||
clientRequestTimeout: "45s",
|
||||
clientHeaderMaxBytes: "32K",
|
||||
clientIdleTimeout: "120s",
|
||||
clientResponseTimeout: "30m",
|
||||
upstreamRequestTimeout: "60s",
|
||||
upstreamResponseTimeout: "30m",
|
||||
requestMaxBytes: "100M",
|
||||
responseMaxBytes: "1G",
|
||||
allowNets: "",
|
||||
rateLimitExemptNets: "",
|
||||
denyNets: "",
|
||||
rateLimitPerMinute: "500",
|
||||
rateLimitPerHour: "20000",
|
||||
rateLimitPerDay: "300000",
|
||||
rateLimitExemptPaths: "",
|
||||
bytesLimitPerMinute: "4G",
|
||||
bytesLimitPerHour: "20G",
|
||||
bytesLimitPerDay: "200G",
|
||||
bytesCount: "both",
|
||||
lookupSource: defaultLookupSource,
|
||||
lookupDBPath: "",
|
||||
lookupTimeout: "800ms",
|
||||
addLookupHeaders: "false",
|
||||
deniedCountries: "",
|
||||
allowedCountries: "",
|
||||
asnLimitPercent: "",
|
||||
countryLimitPercent: "",
|
||||
asnBytesPercent: "",
|
||||
countryBytesPercent: "",
|
||||
unknownLimitPercent: "100",
|
||||
asnLimitPercentURL: "",
|
||||
blocklistURLs: "",
|
||||
blocklistRefresh: "24h",
|
||||
blocklistAction: actionDeny,
|
||||
dnsblZones: "",
|
||||
dnsblResolver: "",
|
||||
abuseIPDBKey: "",
|
||||
abuseIPDBMinScore: "75",
|
||||
abuseIPDBDailyBudget: "900",
|
||||
reputationAction: "limit:25",
|
||||
reputationCacheTTL: defaultReputationCacheTTL,
|
||||
reputationTimeout: "2s",
|
||||
crowdSecURL: "",
|
||||
crowdSecKey: "",
|
||||
banResponse: "403",
|
||||
limitBanDuration: "1h",
|
||||
limitBanRepeatWindow: "24h",
|
||||
maxBanDuration: "7d",
|
||||
attackBanDuration: "7d",
|
||||
maxBans: "5000",
|
||||
banScopeV4Prefix: "32",
|
||||
stateDir: "/var/lib/smallwebwaf",
|
||||
stateWriteDelay: "10s",
|
||||
stateCounterInterval: "15m",
|
||||
adminToken: "",
|
||||
metricsToken: "",
|
||||
metricsTopN: "50",
|
||||
instanceName: hostname,
|
||||
logRequestHeaders: defaultLogRequestHeaders,
|
||||
logLevel: "info",
|
||||
rulesDir: "/etc/smallwebwaf/rules.d",
|
||||
rulesEnabled: "true",
|
||||
wafMode: config.WAFModeBlock,
|
||||
wafParanoiaLevel: "1",
|
||||
wafAnomalyThreshold: "5",
|
||||
wafDisabledRules: defaultWAFDisabledRules,
|
||||
wafExemptPaths: "",
|
||||
wafBodyLimit: off,
|
||||
trapPaths: "",
|
||||
errorBurstThreshold: "30",
|
||||
logRemoteURL: "",
|
||||
logRemoteTLSCAFile: "",
|
||||
logRemoteBuffer: "10000",
|
||||
logRemoteFacility: "local0",
|
||||
logRemoteAppName: hostname,
|
||||
alertWebhookURL: "",
|
||||
alertWebhookHeaders: "",
|
||||
alertSlackWebhookURL: "",
|
||||
alertNtfyURL: "",
|
||||
alertNtfyToken: "",
|
||||
alertEvents: defaultAlertEvents,
|
||||
alertCooldown: defaultAlertCooldown,
|
||||
alertMaxPerHour: "60",
|
||||
listenAddr: ":8080",
|
||||
mode: "enforce",
|
||||
ipv6GroupPrefix: "64",
|
||||
maxTrackedClients: "20000",
|
||||
allowNets: "",
|
||||
rateLimitExemptNets: "",
|
||||
denyNets: "",
|
||||
rateLimitPerMinute: "45",
|
||||
rateLimitPerHour: "20000",
|
||||
rateLimitPerDay: "300000",
|
||||
rateLimitExemptPaths: "",
|
||||
bytesLimitPerMinute: "4G",
|
||||
bytesLimitPerHour: "20G",
|
||||
bytesLimitPerDay: "200G",
|
||||
bytesCount: "both",
|
||||
lookupSource: defaultLookupSource,
|
||||
lookupDBPath: "",
|
||||
lookupTimeout: "800ms",
|
||||
addLookupHeaders: "false",
|
||||
deniedCountries: "",
|
||||
allowedCountries: "",
|
||||
asnLimitPercent: "",
|
||||
countryLimitPercent: "",
|
||||
asnBytesPercent: "",
|
||||
countryBytesPercent: "",
|
||||
unknownLimitPercent: "100",
|
||||
asnLimitPercentURL: "",
|
||||
blocklistURLs: "",
|
||||
blocklistRefresh: "24h",
|
||||
blocklistAction: actionDeny,
|
||||
dnsblZones: "",
|
||||
dnsblResolver: "",
|
||||
abuseIPDBKey: "",
|
||||
abuseIPDBMinScore: "75",
|
||||
abuseIPDBDailyBudget: "900",
|
||||
reputationAction: "limit:25",
|
||||
reputationCacheTTL: defaultReputationCacheTTL,
|
||||
reputationTimeout: "2s",
|
||||
crowdSecURL: "",
|
||||
crowdSecKey: "",
|
||||
banResponse: "403",
|
||||
limitBanDuration: "1h",
|
||||
limitBanRepeatWindow: "24h",
|
||||
maxBanDuration: "7d",
|
||||
attackBanDuration: "7d",
|
||||
maxBans: "5000",
|
||||
banScopeV4Prefix: "32",
|
||||
stateDir: "/var/lib/smallwebwaf",
|
||||
stateWriteDelay: "10s",
|
||||
stateCounterInterval: "15m",
|
||||
adminToken: "",
|
||||
metricsToken: "",
|
||||
metricsTopN: "50",
|
||||
instanceName: hostname,
|
||||
logRequestHeaders: defaultLogRequestHeaders,
|
||||
logLevel: "info",
|
||||
rulesDir: "/etc/smallwebwaf/rules.d",
|
||||
rulesEnabled: "true",
|
||||
wafMode: config.WAFModeBlock,
|
||||
wafParanoiaLevel: "1",
|
||||
wafAnomalyThreshold: "5",
|
||||
wafDisabledRules: defaultWAFDisabledRules,
|
||||
wafExemptPaths: "",
|
||||
wafBodyLimit: off,
|
||||
trapPaths: "",
|
||||
errorBurstThreshold: "30",
|
||||
logRemoteURL: "",
|
||||
logRemoteTLSCAFile: "",
|
||||
logRemoteBuffer: "10000",
|
||||
logRemoteFacility: "local0",
|
||||
logRemoteAppName: hostname,
|
||||
alertWebhookURL: "",
|
||||
alertWebhookHeaders: "",
|
||||
alertSlackWebhookURL: "",
|
||||
alertNtfyURL: "",
|
||||
alertNtfyToken: "",
|
||||
alertEvents: defaultAlertEvents,
|
||||
alertCooldown: defaultAlertCooldown,
|
||||
alertMaxPerHour: "60",
|
||||
}
|
||||
maps.Copy(want, loggedAnomalyDefaults())
|
||||
|
||||
@@ -2481,14 +2376,6 @@ func wantSettings(t *testing.T, got *config.Config, want config.Config) {
|
||||
|
||||
if got.ListenAddr != want.ListenAddr ||
|
||||
got.Observe != want.Observe ||
|
||||
got.ClientRequestTimeout != want.ClientRequestTimeout ||
|
||||
got.ClientRequestHeaderMaxBytes != want.ClientRequestHeaderMaxBytes ||
|
||||
got.ClientIdleTimeout != want.ClientIdleTimeout ||
|
||||
got.ClientResponseTimeout != want.ClientResponseTimeout ||
|
||||
got.UpstreamRequestTimeout != want.UpstreamRequestTimeout ||
|
||||
got.UpstreamResponseTimeout != want.UpstreamResponseTimeout ||
|
||||
got.RequestMaxBytes != want.RequestMaxBytes ||
|
||||
got.ResponseMaxBytes != want.ResponseMaxBytes ||
|
||||
got.RateLimitPerMinute != want.RateLimitPerMinute ||
|
||||
got.RateLimitPerHour != want.RateLimitPerHour ||
|
||||
got.RateLimitPerDay != want.RateLimitPerDay {
|
||||
|
||||
@@ -64,15 +64,9 @@ var (
|
||||
// invalid is an error that names it. Since rproxy never touches disk, its
|
||||
// settings have no _FILE form.
|
||||
func RProxyFromEnvironment(lookupEnv func(string) (string, bool)) (*RProxy, error) {
|
||||
env := &environment{lookupEnv: func(name string) (string, bool) {
|
||||
if strings.HasSuffix(name, "_FILE") {
|
||||
return "", false
|
||||
}
|
||||
|
||||
return lookupEnv(name)
|
||||
}}
|
||||
env := &environment{lookupEnv: withoutFileForms(lookupEnv)}
|
||||
cfg := &RProxy{
|
||||
ListenAddr: env.address("SWRP_LISTEN_ADDR", defaultListenAddr),
|
||||
ListenAddr: env.address("SWRP_LISTEN_ADDR"),
|
||||
UpstreamURL: env.appURL("SWRP_UPSTREAM_URL", defaultUpstreamURL),
|
||||
TrustedProxies: env.netblocks("SWRP_TRUSTED_PROXIES", privateRanges),
|
||||
WAFBackend: env.wafBackend("SWRP_WAF_BACKEND"),
|
||||
@@ -93,6 +87,37 @@ func RProxyFromEnvironment(lookupEnv func(string) (string, bool)) (*RProxy, erro
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
// RProxyListenAddrAndUpstreamURL reads only SWRP_LISTEN_ADDR and
|
||||
// SWRP_UPSTREAM_URL. The health check in rproxy's container needs no other
|
||||
// setting, so it reads no other.
|
||||
func RProxyListenAddrAndUpstreamURL(
|
||||
lookupEnv func(string) (string, bool),
|
||||
) (string, *url.URL, error) {
|
||||
env := &environment{lookupEnv: withoutFileForms(lookupEnv)}
|
||||
listenAddr := env.address("SWRP_LISTEN_ADDR")
|
||||
upstreamURL := env.appURL("SWRP_UPSTREAM_URL", defaultUpstreamURL)
|
||||
|
||||
if env.err != nil {
|
||||
return "", nil, env.err
|
||||
}
|
||||
|
||||
return listenAddr, upstreamURL, nil
|
||||
}
|
||||
|
||||
// withoutFileForms returns lookupEnv finding no setting named with _FILE, since
|
||||
// rproxy never touches disk.
|
||||
func withoutFileForms(
|
||||
lookupEnv func(string) (string, bool),
|
||||
) func(string) (string, bool) {
|
||||
return func(name string) (string, bool) {
|
||||
if strings.HasSuffix(name, "_FILE") {
|
||||
return "", false
|
||||
}
|
||||
|
||||
return lookupEnv(name)
|
||||
}
|
||||
}
|
||||
|
||||
// LogValue makes an RProxy log as each setting's name with the value it was
|
||||
// given, or its default.
|
||||
func (c *RProxy) LogValue() slog.Value {
|
||||
|
||||
@@ -125,7 +125,19 @@ func TestRProxyInvalidSettingsAreRefused(t *testing.T) {
|
||||
{rproxyDefault, "maybe"},
|
||||
{rproxyListen, "8080"},
|
||||
{rproxyUpstream, "app"},
|
||||
{rproxyUpstream, "127.0.0.1:8081"},
|
||||
{rproxyUpstream, "ftp://127.0.0.1:8081"},
|
||||
{rproxyUpstream, "http://"},
|
||||
{rproxyUpstream, "http://:8081"},
|
||||
{rproxyUpstream, "http://127.0.0.1:0"},
|
||||
{rproxyUpstream, "http://127.0.0.1:99999"},
|
||||
{rproxyUpstream, "http://127.0.0.1:8081/app"},
|
||||
{rproxyUpstream, "http://127.0.0.1:8081/?a=1"},
|
||||
{rproxyUpstream, "http://user:secret@127.0.0.1:8081"},
|
||||
{rproxyTrusted, "not a netblock"},
|
||||
{rproxyTrusted, "10.0.0.0/33"},
|
||||
{rproxyTrusted, "10.0.0.0/8,,192.168.0.0/16"},
|
||||
{rproxyTrusted, "fe80::1%eth0"},
|
||||
{rproxyMaxRequest, notASize},
|
||||
{rproxyMaxResponse, notASize},
|
||||
{rproxyRequestTime, "0"},
|
||||
@@ -143,6 +155,19 @@ func TestRProxyInvalidSettingsAreRefused(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRProxyTrustedProxiesSetButEmptyTrustNothing(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cfg, err := rproxyConfig(map[string]string{wafBackend: backendURL, rproxyTrusted: ""})
|
||||
if err != nil {
|
||||
t.Fatalf("settings: %v", err)
|
||||
}
|
||||
|
||||
if len(cfg.TrustedProxies) != 0 {
|
||||
t.Errorf("trusted proxies %v, want none", cfg.TrustedProxies)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRProxyNeedsItsBackend(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
|
||||
+25
-36
@@ -28,16 +28,15 @@ type Metrics struct {
|
||||
registry prometheus.Registerer
|
||||
handler http.Handler
|
||||
|
||||
inFlight prometheus.Gauge
|
||||
requests *prometheus.CounterVec
|
||||
requestBytes *prometheus.CounterVec
|
||||
responseBytes *prometheus.CounterVec
|
||||
requestDuration prometheus.Histogram
|
||||
upstreamDuration prometheus.Histogram
|
||||
rateLimitHits *prometheus.CounterVec
|
||||
sizeAndTimeLimitHits *prometheus.CounterVec
|
||||
offences *prometheus.CounterVec
|
||||
wafMatches *prometheus.CounterVec
|
||||
inFlight prometheus.Gauge
|
||||
requests *prometheus.CounterVec
|
||||
requestBytes *prometheus.CounterVec
|
||||
responseBytes *prometheus.CounterVec
|
||||
requestDuration prometheus.Histogram
|
||||
upstreamDuration prometheus.Histogram
|
||||
rateLimitHits *prometheus.CounterVec
|
||||
offences *prometheus.CounterVec
|
||||
wafMatches *prometheus.CounterVec
|
||||
// ruleMatches are made by AddRules, and reputationHits by
|
||||
// AddReputation.
|
||||
ruleMatches *prometheus.CounterVec
|
||||
@@ -100,9 +99,6 @@ func New(topN int, instanceName string) *Metrics {
|
||||
"Requests that broke a rate limit, a byte limit or the error burst, by "+
|
||||
"its window and its kind, requests, bytes or refusals.",
|
||||
[]string{"window", "kind"}),
|
||||
sizeAndTimeLimitHits: counterVec("smallwebwaf_size_and_time_limit_hits_total",
|
||||
"Requests that passed a size or time limit, by its setting.",
|
||||
[]string{"limit"}),
|
||||
offences: counterVec("smallwebwaf_offences_total",
|
||||
"Offences, by kind.", []string{"kind"}),
|
||||
wafMatches: counterVec("smallwebwaf_waf_matches_total",
|
||||
@@ -143,7 +139,7 @@ func New(topN int, instanceName string) *Metrics {
|
||||
collectors.NewProcessCollector(collectors.ProcessCollectorOpts{}),
|
||||
m.inFlight, m.requests, m.requestBytes, m.responseBytes,
|
||||
m.requestDuration, m.upstreamDuration,
|
||||
m.rateLimitHits, m.sizeAndTimeLimitHits, m.offences, m.wafMatches,
|
||||
m.rateLimitHits, m.offences, m.wafMatches,
|
||||
m.countries, m.asns,
|
||||
m.GeoJSRequests, m.GeoJSFailures, m.GeoJSUnanswered,
|
||||
m.stateFileWrites, m.stateFileWriteFailures,
|
||||
@@ -395,15 +391,11 @@ func (m *Metrics) RequestStarted() {
|
||||
m.inFlight.Inc()
|
||||
}
|
||||
|
||||
// RequestEnded counts a request that has ended, from its log line. limit
|
||||
// is the setting whose size or time limit the request passed, "" if none.
|
||||
// duration is how long the request took, and upstreamDuration how long it
|
||||
// took from when it was passed to the app, zero if it was not.
|
||||
func (m *Metrics) RequestEnded(
|
||||
line *requestlog.Line, limit string, duration, upstreamDuration time.Duration,
|
||||
) {
|
||||
// RequestEnded counts a request that has ended without being passed to the app,
|
||||
// from its log line. duration is how long the request took.
|
||||
func (m *Metrics) RequestEnded(line *requestlog.Line, duration time.Duration) {
|
||||
m.inFlight.Dec()
|
||||
m.count(line, limit, duration, upstreamDuration)
|
||||
m.count(line, duration, 0)
|
||||
}
|
||||
|
||||
// RequestAllowed counts a question of rproxy's, answered allow, as no longer
|
||||
@@ -412,12 +404,13 @@ func (m *Metrics) RequestAllowed() {
|
||||
m.inFlight.Dec()
|
||||
}
|
||||
|
||||
// RequestReported counts a request rproxy passed to the app, as RequestEnded
|
||||
// does, from what its report says, which line holds.
|
||||
// RequestReported counts a request rproxy passed to the app, from what its
|
||||
// report says, which line holds: duration is how long the request took, and
|
||||
// upstreamDuration how long it took from when it was passed to the app.
|
||||
func (m *Metrics) RequestReported(
|
||||
line *requestlog.Line, duration, upstreamDuration time.Duration,
|
||||
) {
|
||||
m.count(line, "", duration, upstreamDuration)
|
||||
m.count(line, duration, upstreamDuration)
|
||||
}
|
||||
|
||||
// LimitHit counts a request that broke a rate limit, a byte limit or the
|
||||
@@ -431,11 +424,10 @@ func (m *Metrics) LimitHit(hit ratelimit.Hit) {
|
||||
// them.
|
||||
func (m *Metrics) Offences(r ratelimit.Request) {
|
||||
for kind, committed := range map[string]bool{
|
||||
"limit": r.BrokeLimit,
|
||||
"attack": r.Attack,
|
||||
"rule_blocked": r.RuleBlocked,
|
||||
"waf_blocked": r.WAFBlocked,
|
||||
"token_refused": r.TokenRefused,
|
||||
"limit": r.BrokeLimit,
|
||||
"attack": r.Attack,
|
||||
"rule_blocked": r.RuleBlocked,
|
||||
"waf_blocked": r.WAFBlocked,
|
||||
} {
|
||||
if committed {
|
||||
m.offences.WithLabelValues(kind).Inc()
|
||||
@@ -486,9 +478,10 @@ func (m *Metrics) StateFileEditSetAside(name string) {
|
||||
m.stateFileEditsSetAside.WithLabelValues(name).Inc()
|
||||
}
|
||||
|
||||
// count counts a request that has ended, as RequestEnded describes.
|
||||
// count counts a request that has ended, as RequestEnded and RequestReported
|
||||
// describe.
|
||||
func (m *Metrics) count(
|
||||
line *requestlog.Line, limit string, duration, upstreamDuration time.Duration,
|
||||
line *requestlog.Line, duration, upstreamDuration time.Duration,
|
||||
) {
|
||||
class := statusClass(line.Status)
|
||||
m.requests.WithLabelValues(class, line.Action).Inc()
|
||||
@@ -500,10 +493,6 @@ func (m *Metrics) count(
|
||||
m.upstreamDuration.Observe(upstreamDuration.Seconds())
|
||||
}
|
||||
|
||||
if limit != "" {
|
||||
m.sizeAndTimeLimitHits.WithLabelValues(limit).Inc()
|
||||
}
|
||||
|
||||
if line.Country != "" {
|
||||
m.countries.add(line.Country, line)
|
||||
}
|
||||
|
||||
+11
-41
@@ -9,7 +9,6 @@ import (
|
||||
"io"
|
||||
"net/http"
|
||||
"net/netip"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -50,16 +49,13 @@ func (rq *request) answerHealth() {
|
||||
_, _ = io.WriteString(rq.out, "ok\n")
|
||||
}
|
||||
|
||||
// answerAdmin answers a request for smallwebwaf itself, under
|
||||
// /_smallwebwaf/, once it has passed the checks. Each endpoint needs a
|
||||
// token, sent as Authorization: Bearer <token>: the metrics
|
||||
// SWWAF_METRICS_TOKEN, the others SWWAF_ADMIN_TOKEN. A request without
|
||||
// it is refused with 401, which counts toward the error burst. An
|
||||
// endpoint whose token is unset answers 404, as any other request under
|
||||
// /_smallwebwaf/ does.
|
||||
// answerAdmin answers a request for one of servewaf's own endpoints. Each
|
||||
// needs a token, sent as Authorization: Bearer <token>: the metrics
|
||||
// SWWAF_METRICS_TOKEN, the others SWWAF_ADMIN_TOKEN. A request without it is
|
||||
// refused with 401. An endpoint whose token is unset answers 404, as any other
|
||||
// path does.
|
||||
func (rq *request) answerAdmin() {
|
||||
rq.line.Action = requestlog.ActionAdmin
|
||||
rq.startClientResponseTimeout()
|
||||
|
||||
token, answer := rq.endpoint()
|
||||
|
||||
@@ -67,12 +63,8 @@ func (rq *request) answerAdmin() {
|
||||
case token == "":
|
||||
http.Error(rq.out, http.StatusText(http.StatusNotFound), http.StatusNotFound)
|
||||
case !hasToken(rq.in, token):
|
||||
rq.tokenRefused = true
|
||||
rq.out.Header().Set("WWW-Authenticate", "Bearer")
|
||||
rq.answer(refusal{
|
||||
status: http.StatusUnauthorized,
|
||||
action: requestlog.ActionAdmin,
|
||||
})
|
||||
http.Error(rq.out, http.StatusText(http.StatusUnauthorized), http.StatusUnauthorized)
|
||||
default:
|
||||
answer()
|
||||
}
|
||||
@@ -129,26 +121,7 @@ type banToAdd struct {
|
||||
// an admin, from now for the duration the body gives, with its reason,
|
||||
// and answers with that ban.
|
||||
func (rq *request) addBan() {
|
||||
// The body must arrive within SWWAF_CLIENT_REQUEST_TIMEOUT, as any
|
||||
// other request's must.
|
||||
rq.stopReadingBody(rq.clientRequestDeadline())
|
||||
|
||||
toAdd, err := rq.readBanToAdd()
|
||||
if refused := rq.refused.Load(); refused != nil {
|
||||
rq.answer(*refused) // the body is over SWWAF_REQUEST_MAX_BYTES
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if errors.Is(err, os.ErrDeadlineExceeded) {
|
||||
rq.answer(refusal{
|
||||
status: http.StatusRequestTimeout,
|
||||
action: requestlog.ActionTimedOut,
|
||||
limit: "SWWAF_CLIENT_REQUEST_TIMEOUT",
|
||||
})
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
var (
|
||||
netblock netip.Prefix
|
||||
@@ -177,12 +150,9 @@ func (rq *request) addBan() {
|
||||
// readBanToAdd reads the body of POST BansPath: a JSON object with
|
||||
// nothing but whitespace after it, in at most banBodyMaxBytes.
|
||||
func (rq *request) readBanToAdd() (banToAdd, error) {
|
||||
var body io.ReadCloser = http.NoBody
|
||||
if rq.body != nil {
|
||||
body = rq.body
|
||||
}
|
||||
data, err := io.ReadAll(http.MaxBytesReader(nil, rq.in.Body, banBodyMaxBytes))
|
||||
rq.requestBytes = int64(len(data))
|
||||
|
||||
data, err := io.ReadAll(http.MaxBytesReader(nil, body, banBodyMaxBytes))
|
||||
if err != nil {
|
||||
return banToAdd{}, fmt.Errorf("%w: %w", errNotBanToAdd, err)
|
||||
}
|
||||
@@ -280,9 +250,9 @@ type clientAnswer struct {
|
||||
Bans []state.BanEntry `json:"bans"`
|
||||
}
|
||||
|
||||
// showClient answers GET ClientsPath<ip> with what smallwebwaf knows of
|
||||
// the client: its counters, its history, which holds its country as last
|
||||
// looked up and its offences, and its bans with their notes.
|
||||
// showClient answers GET ClientsPath<ip> with what servewaf knows of the client:
|
||||
// its counters, its history, which holds its country as last looked up and its
|
||||
// offences, and its bans with their notes.
|
||||
func (rq *request) showClient() {
|
||||
addr, err := pathAddress(rq.in.URL.Path, ClientsPath)
|
||||
if err != nil {
|
||||
|
||||
+20
-138
@@ -5,7 +5,6 @@ import (
|
||||
"net/http"
|
||||
"net/netip"
|
||||
"reflect"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
@@ -23,8 +22,6 @@ const (
|
||||
// how a request carries it.
|
||||
adminSecret = "fedcba9876543210fedcba9876543210"
|
||||
adminBearer = "Bearer " + adminSecret
|
||||
// adminClient is the client the tests' admin sends its requests from.
|
||||
adminClient = "192.0.2.10"
|
||||
// banOtherClient is the body of a request to ban otherClient for an
|
||||
// hour.
|
||||
banOtherClient = `{"netblock": "` + otherClient + `", "duration": "1h", ` +
|
||||
@@ -43,8 +40,7 @@ func TestAdminEndpointsAreOffWhileTheTokenIsUnset(t *testing.T) {
|
||||
// An empty token does not match the unset one either.
|
||||
for _, authorization := range []string{adminBearer, bearer, "Bearer ", ""} {
|
||||
for _, e := range adminEndpoints() {
|
||||
s.adminRequest(adminClient, authorization, e.method, e.path, e.body,
|
||||
http.StatusNotFound, requestlog.ActionAdmin)
|
||||
s.adminRequest(authorization, e.method, e.path, e.body, http.StatusNotFound)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -71,8 +67,8 @@ func TestAdminEndpointsNeedTheAdminToken(t *testing.T) {
|
||||
for _, authorization := range []string{
|
||||
"", bearer, "Bearer " + strings.ToUpper(adminSecret), "Basic " + adminSecret,
|
||||
} {
|
||||
got := s.adminRequest(adminClient, authorization, e.method, e.path, e.body,
|
||||
http.StatusUnauthorized, requestlog.ActionAdmin)
|
||||
got := s.adminRequest(authorization, e.method, e.path, e.body,
|
||||
http.StatusUnauthorized)
|
||||
if got.header.Get("WWW-Authenticate") != "Bearer" {
|
||||
t.Errorf("%s %s with %q was answered without WWW-Authenticate: Bearer",
|
||||
e.method, e.path, authorization)
|
||||
@@ -265,64 +261,12 @@ func TestBanToAddThatCannotBeReadIsRefused(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestBanToAddOverTheRequestSizeLimitIsRefused(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
s, _, server := startWithClock(t, "", map[string]string{
|
||||
adminToken: adminSecret,
|
||||
requestMaxBytes: "16",
|
||||
})
|
||||
|
||||
// Sent in a chunk, its length is not announced, so that it is found
|
||||
// over SWWAF_REQUEST_MAX_BYTES only as it is read.
|
||||
chunk := `{"netblock": "203.0.113.9", "duration": "1h"}`
|
||||
s.adminRequest(adminClient, adminBearer+"\r\nTransfer-Encoding: chunked",
|
||||
http.MethodPost, proxy.BansPath,
|
||||
strconv.FormatInt(int64(len(chunk)), 16)+"\r\n"+chunk+"\r\n0\r\n\r\n",
|
||||
http.StatusRequestEntityTooLarge, requestlog.ActionTooLarge)
|
||||
|
||||
if held := server.Ledger.Snapshot(); len(held) != 0 {
|
||||
t.Errorf("the ledger holds %+v, want no ban", held)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBanToAddSlowerThanTheClientRequestTimeoutIsRefused(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
s, _, server := startWithClock(t, "", map[string]string{
|
||||
adminToken: adminSecret,
|
||||
metricsToken: token,
|
||||
clientRequestTimeout: shortTimeoutSetting,
|
||||
})
|
||||
|
||||
// The chunk announces 256 bytes and the rest of it never comes, so only
|
||||
// the timeout ends the wait. A hold-up of the test process can only
|
||||
// make the answer later, so the time is checked only for not being
|
||||
// shorter than the timeout.
|
||||
start := time.Now()
|
||||
|
||||
s.adminRequest(adminClient, adminBearer+"\r\nTransfer-Encoding: chunked",
|
||||
http.MethodPost, proxy.BansPath, "100\r\n"+`{"netblock": "203.0.113.9", `,
|
||||
http.StatusRequestTimeout, requestlog.ActionTimedOut)
|
||||
|
||||
if took := time.Since(start); took < shortTimeout {
|
||||
t.Errorf("answered after %s, before the timeout of %s ran out", took, shortTimeout)
|
||||
}
|
||||
|
||||
wantLimitHits(t, s.addr, clientRequestTimeout, 1)
|
||||
|
||||
if held := server.Ledger.Snapshot(); len(held) != 0 {
|
||||
t.Errorf("the ledger holds %+v, want no ban", held)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClientEndpointShowsTheClientAndItsBans(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
s, clk, _ := startWithClock(t, "", map[string]string{
|
||||
adminToken: adminSecret,
|
||||
rateLimitPerMinute: "2",
|
||||
rateLimitExemptNets: adminClient,
|
||||
adminToken: adminSecret,
|
||||
rateLimitPerMinute: "2",
|
||||
})
|
||||
start := clk.Now()
|
||||
|
||||
@@ -371,61 +315,6 @@ func TestClientEndpointShowsTheClientAndItsBans(t *testing.T) {
|
||||
http.StatusBadRequest)
|
||||
}
|
||||
|
||||
func TestBannedClientIsRefusedAtTheEndpointsEvenWithTheToken(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
s, _, _ := startWithClock(t, "", map[string]string{adminToken: adminSecret})
|
||||
|
||||
s.admin(http.MethodPost, proxy.BansPath, banOtherClient, http.StatusOK)
|
||||
|
||||
// otherClient cannot lift its own ban either.
|
||||
for _, e := range adminEndpoints() {
|
||||
s.adminRequest(otherClient, adminBearer, e.method, e.path, e.body,
|
||||
http.StatusForbidden, requestlog.ActionBanned)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminRequestsCountTowardTheLimits(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
s, _, _ := startWithClock(t, "", map[string]string{
|
||||
adminToken: adminSecret,
|
||||
rateLimitPerMinute: "2",
|
||||
})
|
||||
|
||||
// A request refused for a missing token and one answered count toward
|
||||
// the limit of two a minute, so the next breaks it.
|
||||
s.adminRequest(client, "", http.MethodGet, proxy.BansPath, "",
|
||||
http.StatusUnauthorized, requestlog.ActionAdmin)
|
||||
s.adminRequest(client, adminBearer, http.MethodGet, proxy.BansPath, "",
|
||||
http.StatusOK, requestlog.ActionAdmin)
|
||||
s.adminRequest(client, adminBearer, http.MethodGet, proxy.BansPath, "",
|
||||
http.StatusForbidden, requestlog.ActionRateLimited)
|
||||
}
|
||||
|
||||
func TestClientInAllowNetsSkipsTheChecksButNeedsTheToken(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
const allowed = "192.0.2.60" // in SWWAF_ALLOW_NETS
|
||||
|
||||
s, clk, server := startWithClock(t, "", map[string]string{
|
||||
adminToken: adminSecret,
|
||||
allowNets: allowed,
|
||||
rateLimitPerMinute: "1",
|
||||
})
|
||||
|
||||
// A ban on it refuses nothing, and its requests are not counted.
|
||||
server.Ledger.BanForAdmin(netip.MustParsePrefix(allowed+"/32"), clk.Now(),
|
||||
time.Time{}, "")
|
||||
|
||||
for range 2 {
|
||||
s.adminRequest(allowed, "", http.MethodGet, proxy.BansPath, "",
|
||||
http.StatusUnauthorized, requestlog.ActionAdmin)
|
||||
s.adminRequest(allowed, adminBearer, http.MethodGet, proxy.BansPath, "",
|
||||
http.StatusOK, requestlog.ActionAdmin)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminEndpointsNeedTheTokenInObserveMode(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
@@ -435,8 +324,7 @@ func TestAdminEndpointsNeedTheTokenInObserveMode(t *testing.T) {
|
||||
})
|
||||
|
||||
for _, e := range adminEndpoints() {
|
||||
s.adminRequest(adminClient, "", e.method, e.path, e.body,
|
||||
http.StatusUnauthorized, requestlog.ActionAdmin)
|
||||
s.adminRequest("", e.method, e.path, e.body, http.StatusUnauthorized)
|
||||
}
|
||||
|
||||
if held := server.Ledger.Snapshot(); len(held) != 0 {
|
||||
@@ -461,39 +349,33 @@ func adminEndpoints() []adminEndpoint {
|
||||
}
|
||||
}
|
||||
|
||||
// admin sends a request with method for path, with body, from
|
||||
// adminClient, with the admin token, and checks that it is answered with
|
||||
// status, its log line's action admin. It returns the answer.
|
||||
// admin sends a request with method for path, with body, to servewaf's own
|
||||
// address, with the admin token, and checks that it is answered with status, its
|
||||
// log line's action admin. It returns the answer.
|
||||
func (s *sender) admin(method, path, body string, status int) answer {
|
||||
s.t.Helper()
|
||||
|
||||
return s.adminRequest(adminClient, adminBearer, method, path, body, status,
|
||||
requestlog.ActionAdmin)
|
||||
return s.adminRequest(adminBearer, method, path, body, status)
|
||||
}
|
||||
|
||||
// adminRequest sends a request with method for path, with body, from the
|
||||
// client at from, with authorization as its Authorization header unless
|
||||
// it is "", and checks its answer's status and its log line's action, as
|
||||
// request does. authorization may end in more header lines. A body that
|
||||
// is not "" has its length announced, unless authorization names
|
||||
// Transfer-Encoding. It returns the answer.
|
||||
// adminRequest is admin with authorization as the request's Authorization header
|
||||
// unless it is "".
|
||||
func (s *sender) adminRequest(
|
||||
from, authorization, method, path, body string, status int, action string,
|
||||
authorization, method, path, body string, status int,
|
||||
) answer {
|
||||
s.t.Helper()
|
||||
|
||||
var header []string
|
||||
|
||||
req := newRequest(s.t, method, s.serveWAFAddr, path, strings.NewReader(body))
|
||||
if authorization != "" {
|
||||
header = append(header, "Authorization: "+authorization)
|
||||
req.Header.Set("Authorization", authorization)
|
||||
}
|
||||
|
||||
if body != "" && !strings.Contains(authorization, "Transfer-Encoding") {
|
||||
header = append(header, "Content-Length: "+strconv.Itoa(len(body)))
|
||||
}
|
||||
got := do(s.t, req)
|
||||
wantStatus(s.t, got, status)
|
||||
|
||||
_, got := s.requestWithBody(method, from, path, strings.Join(header, "\r\n"),
|
||||
body, status, action)
|
||||
line := s.out.endedLines(s.t, s.sent+1)[s.sent]
|
||||
s.sent++
|
||||
wantLine(s.t, line, status, requestlog.ActionAdmin)
|
||||
|
||||
return got
|
||||
}
|
||||
|
||||
@@ -224,7 +224,9 @@ func startAppWithAlerts(
|
||||
|
||||
addr, out, server, queue := startProxyWithAlerts(t, app.URL, "", clk.Now, settings)
|
||||
|
||||
return &sender{t: t, addr: addr, out: out}, clk, server, queue
|
||||
s := &sender{t: t, addr: addr, serveWAFAddr: server.Addr, out: out}
|
||||
|
||||
return s, clk, server, queue
|
||||
}
|
||||
|
||||
// banAlert returns the alert for event, raised by a request from client at
|
||||
@@ -237,6 +239,7 @@ func banAlert(
|
||||
Instance: alertInstance,
|
||||
Time: raised,
|
||||
Event: event,
|
||||
Host: appHost,
|
||||
Client: netip.MustParseAddr(client),
|
||||
Netblock: ban.Netblock,
|
||||
Reason: ban.Reason,
|
||||
|
||||
@@ -117,6 +117,7 @@ func TestEachScopeAndWindowOverItsThresholdAlertsOncePerCooldown(t *testing.T) {
|
||||
Instance: alertInstance,
|
||||
Time: start,
|
||||
Event: alerts.EventAnomaly,
|
||||
Host: appHost,
|
||||
Client: netip.MustParseAddr(client),
|
||||
Netblock: scope.netblock,
|
||||
ASN: asnDE,
|
||||
|
||||
@@ -315,20 +315,6 @@ func TestObserveModeWaitsForGeoJSForARequestOverTheHeaderLimits(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestWithoutACommandTheHeaderLimitsDoNotApply(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
app := startApp(t, func(http.ResponseWriter, *http.Request) {})
|
||||
addr, out := startProxy(t, app.URL, nil)
|
||||
|
||||
req := newRequest(t, http.MethodGet, addr, "/", http.NoBody)
|
||||
req.Header.Set(cookieHeader, cookies(cookieLimit+1))
|
||||
fillHeaders(req, headerFieldLimit+1)
|
||||
|
||||
wantStatus(t, do(t, req), http.StatusOK)
|
||||
wantLine(t, out.requestLine(t), http.StatusOK, requestlog.ActionForward)
|
||||
}
|
||||
|
||||
// newServeWAFInBubble makes a servewaf for a synctest bubble: GeoJS never
|
||||
// answers, a setting needs its answer, and everything is at its default but
|
||||
// for env. No request to it may use the network.
|
||||
@@ -338,8 +324,7 @@ func newServeWAFInBubble(t *testing.T, env map[string]string) (*proxy.Server, *o
|
||||
settings := map[string]string{deniedCountries: "KP"}
|
||||
maps.Copy(settings, env)
|
||||
|
||||
server, out, _ := newServerWith(t, proxy.NewServeWAF, "http://app.invalid",
|
||||
unansweredGeoJSURL, time.Now, settings)
|
||||
server, out, _ := newServeWAF(t, unansweredGeoJSURL, time.Now, settings)
|
||||
|
||||
return server, out
|
||||
}
|
||||
@@ -521,27 +506,6 @@ func TestAQuestionWaitsForASlowGeoJSOnlyAsLongAsTheDefaultSays(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestWithoutACommandARefusedRequestStillWaitsForGeoJS(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
synctest.Test(t, func(t *testing.T) {
|
||||
server, out, _ := newProxy(t, "http://app.invalid", unansweredGeoJSURL,
|
||||
time.Now, map[string]string{deniedCountries: "KP", blocklistURLs: dropURL})
|
||||
listClient(t, server, time.Now())
|
||||
|
||||
status, took := askDirectly(t, server, func(req *http.Request) {
|
||||
req.RemoteAddr = net.JoinHostPort(client, "1234")
|
||||
})
|
||||
|
||||
if status != http.StatusForbidden || took != defaultLookupWait {
|
||||
t.Errorf("answered %d after %s, want %d after %s; output:\n%s",
|
||||
status, took, http.StatusForbidden, defaultLookupWait, out.text())
|
||||
}
|
||||
|
||||
wantLine(t, out.requestLine(t), http.StatusForbidden, requestlog.ActionDenied)
|
||||
})
|
||||
}
|
||||
|
||||
func TestServeWAFAnswersWithinTheTimeLimitWhileEveryOutsideSourceHangs(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
@@ -555,11 +519,11 @@ func TestServeWAFAnswersWithinTheTimeLimitWhileEveryOutsideSourceHangs(t *testin
|
||||
|
||||
t.Cleanup(func() { _ = resolver.Close() })
|
||||
|
||||
server, out, _ := newServerWith(t, func(params proxy.Params) *proxy.Server {
|
||||
server, out, _ := newServeWAFWith(t, func(params proxy.Params) *proxy.Server {
|
||||
params.AbuseIPDBURL = silentAbuseIPDBURL
|
||||
|
||||
return proxy.NewServeWAF(params)
|
||||
}, "http://app.invalid", held, time.Now, map[string]string{
|
||||
}, held, time.Now, map[string]string{
|
||||
deniedCountries: "KP", wafMode: block,
|
||||
dnsblZones: dnsblZone, dnsblResolver: resolver.LocalAddr().String(),
|
||||
abuseIPDBKey: accountKey,
|
||||
|
||||
+12
-25
@@ -58,13 +58,12 @@ func (rq *request) limitBroken(now time.Time) bool {
|
||||
}
|
||||
|
||||
// countBytes counts the request's bytes, as countedBytes gives them, for
|
||||
// the byte limits, once its response has ended, and notes the client's
|
||||
// byte totals for the log line; its requests stay there as the rate limits
|
||||
// counted them. Only a request passed to the app has them counted, and
|
||||
// only one the rate limits counted; in observe mode, not one that enforce
|
||||
// mode would have refused. Bytes that take the client over a byte limit,
|
||||
// as its limit percentage for the byte limits lowers it, break it; the
|
||||
// response was passed on whole.
|
||||
// the byte limits, once its report has come, and notes the client's byte totals
|
||||
// for the log line; its requests stay there as the rate limits counted them.
|
||||
// Only an allowed request has them counted, and only one the rate limits
|
||||
// counted; in observe mode, not one that enforce mode would have refused. Bytes
|
||||
// that take the client over a byte limit, as its limit percentage for the byte
|
||||
// limits lowers it, break it; the response was passed on whole.
|
||||
func (rq *request) countBytes() {
|
||||
if !rq.counted || rq.line.WouldAction != "" {
|
||||
return
|
||||
@@ -84,9 +83,8 @@ func (rq *request) countBytes() {
|
||||
}
|
||||
|
||||
// countRefusal counts the request for the error burst once it has been
|
||||
// answered, if smallwebwaf refused it after a rule file match, a trap path
|
||||
// or a Core Rule Set match, or for a missing or wrong token, and in
|
||||
// observe mode if enforce mode would have: more than
|
||||
// answered, if servewaf refused it after a rule file match, a trap path or a
|
||||
// Core Rule Set match, and in observe mode if enforce mode would have: more than
|
||||
// SWWAF_ERROR_BURST_THRESHOLD such refusals of the client within a minute
|
||||
// break a limit. A client in SWWAF_ALLOW_NETS, which the checks skip, is
|
||||
// not counted, and nothing is while the threshold is off.
|
||||
@@ -96,11 +94,7 @@ func (rq *request) countRefusal() {
|
||||
return
|
||||
}
|
||||
|
||||
// In observe mode, a request that enforce mode would have refused
|
||||
// before it reached the endpoint has had no token refused there.
|
||||
tokenRefused := rq.tokenRefused && rq.line.WouldAction == "" &&
|
||||
!isInside(rq.client, cfg.AllowNets)
|
||||
if !rq.attack && !rq.ruleBlocked && !rq.wafBlocked && !tokenRefused {
|
||||
if !rq.attack && !rq.ruleBlocked && !rq.wafBlocked {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -127,16 +121,9 @@ func (rq *request) countRefusal() {
|
||||
|
||||
// countedBytes returns the request's bytes, once it has ended, as the
|
||||
// byte limits and the anomaly thresholds count them: the response's body
|
||||
// bytes, the request's, or both, as SWWAF_BYTES_COUNT says. For an
|
||||
// upgraded connection, such as a WebSocket, which has closed by then, what
|
||||
// it carried from the app counts with the response's and what it carried
|
||||
// from the client with the request's.
|
||||
// bytes, the request's, or both, as SWWAF_BYTES_COUNT says.
|
||||
func (rq *request) countedBytes() int64 {
|
||||
response, request := rq.out.bytes, rq.requestBytes()
|
||||
if rq.upgraded != nil {
|
||||
response += rq.upgraded.fromApp.Load()
|
||||
request += rq.upgraded.toApp.Load()
|
||||
}
|
||||
response, request := rq.out.bytes, rq.requestBytes
|
||||
|
||||
switch rq.h.config.BytesCount {
|
||||
case "response":
|
||||
@@ -151,7 +138,7 @@ func (rq *request) countedBytes() int64 {
|
||||
// banForLimit bans the client's netblock at now for a broken limit, the
|
||||
// one hit names, notes the offence for the log line and counts the hit in
|
||||
// the metrics. status is what the client was sent, or is sent:
|
||||
// SWWAF_BAN_RESPONSE for a request over a rate limit, the app's answer for
|
||||
// SWWAF_BAN_RESPONSE for a request over a rate limit, what the report says for
|
||||
// one whose bytes broke a byte limit, the refusal for one that broke the
|
||||
// error burst. The ban's notes give the client's limit percentage for a
|
||||
// rate limit or a byte limit; the error burst is not lowered. The ban sets
|
||||
|
||||
+25
-22
@@ -9,6 +9,7 @@ import (
|
||||
"net/netip"
|
||||
"reflect"
|
||||
"slices"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
@@ -374,7 +375,7 @@ func (c *clock) advance(d time.Duration) {
|
||||
c.now = c.now.Add(d)
|
||||
}
|
||||
|
||||
// startWithClock starts smallwebwaf in front of an app that answers 200,
|
||||
// startWithClock starts servewaf, and rproxy in front of an app that answers 200,
|
||||
// with the settings in env on top of trusting localhost's
|
||||
// X-Forwarded-For, clients' AS numbers and countries looked up at
|
||||
// geojsURL, and a clock set to midnight, the start of a bucket in every
|
||||
@@ -391,18 +392,19 @@ func startWithClock(
|
||||
|
||||
addr, out, server := startProxyWithClock(t, app.URL, geojsURL, clk.Now, settings)
|
||||
|
||||
return &sender{t: t, addr: addr, out: out}, clk, server
|
||||
return &sender{t: t, addr: addr, serveWAFAddr: server.Addr, out: out}, clk, server
|
||||
}
|
||||
|
||||
// sender sends requests to smallwebwaf one after another, each on a
|
||||
// connection of its own, and checks each one's answer and log line. They
|
||||
// must be the only requests smallwebwaf is sent, since the log lines are
|
||||
// matched to them in order.
|
||||
// sender sends requests to rproxy one after another, each on a connection of its
|
||||
// own, and checks each one's answer and log line. serveWAFAddr is where servewaf's
|
||||
// own endpoints are, which adminRequest asks. Every request servewaf gets must
|
||||
// come through the sender, since the log lines are matched to them in order.
|
||||
type sender struct {
|
||||
t *testing.T
|
||||
addr string
|
||||
out *output
|
||||
sent int
|
||||
t *testing.T
|
||||
addr string
|
||||
serveWAFAddr string
|
||||
out *output
|
||||
sent int
|
||||
}
|
||||
|
||||
// get sends a GET request for / from the client at from.
|
||||
@@ -419,22 +421,19 @@ func (s *sender) get(from string, status int, action string) logLine {
|
||||
func (s *sender) request(from, path string, status int, action string) logLine {
|
||||
s.t.Helper()
|
||||
|
||||
line, _ := s.requestWithHeader(from, path, "", status, action)
|
||||
|
||||
return line
|
||||
return s.requestWithHeader(from, path, "", status, action)
|
||||
}
|
||||
|
||||
// requestWithHeader is request with header, such as "Authorization:
|
||||
// Bearer x", added to the request unless it is "". It returns the body of
|
||||
// the answer too.
|
||||
// Bearer x", added to the request unless it is "".
|
||||
func (s *sender) requestWithHeader(
|
||||
from, path, header string, status int, action string,
|
||||
) (logLine, string) {
|
||||
) logLine {
|
||||
s.t.Helper()
|
||||
|
||||
line, got := s.requestWithBody(http.MethodGet, from, path, header, "", status, action)
|
||||
line, _ := s.requestWithBody(http.MethodGet, from, path, header, "", status, action)
|
||||
|
||||
return line, string(got.body)
|
||||
return line
|
||||
}
|
||||
|
||||
// requestWithBody is requestWithHeader for a request with method, whose
|
||||
@@ -450,10 +449,14 @@ func (s *sender) requestWithBody(
|
||||
header += "\r\n"
|
||||
}
|
||||
|
||||
// rproxy refuses a request with more than one.
|
||||
if !strings.Contains(header, "User-Agent:") {
|
||||
header += "User-Agent: " + userAgent + "\r\n"
|
||||
}
|
||||
|
||||
conn := dial(s.t, s.addr)
|
||||
send(s.t, conn, method+" "+path+" HTTP/1.1\r\nHost: "+appHost+
|
||||
"\r\nUser-Agent: "+userAgent+"\r\n"+forwardedFor+": "+from+"\r\n"+
|
||||
header+"\r\n"+body)
|
||||
send(s.t, conn, method+" "+path+" HTTP/1.1\r\nHost: "+appHost+"\r\n"+
|
||||
forwardedFor+": "+from+"\r\n"+header+"\r\n"+body)
|
||||
|
||||
err := conn.SetReadDeadline(time.Now().Add(waitLimit))
|
||||
if err != nil {
|
||||
@@ -478,7 +481,7 @@ func (s *sender) requestWithBody(
|
||||
got.status, status)
|
||||
}
|
||||
|
||||
line := s.out.requestLines(s.t, s.sent+1)[s.sent]
|
||||
line := s.out.endedLines(s.t, s.sent+1)[s.sent]
|
||||
s.sent++
|
||||
wantLine(s.t, line, status, action)
|
||||
|
||||
|
||||
@@ -4,7 +4,6 @@ import (
|
||||
"fmt"
|
||||
"io"
|
||||
"maps"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/netip"
|
||||
@@ -281,12 +280,12 @@ func TestClientWithoutAnAnswerInTimeHasTheUnknownLimitPercent(t *testing.T) {
|
||||
// In a synctest bubble, as TestRequestWaitsAsLongAsTheLookupTimeoutSays
|
||||
// says, with a GeoJS that never answers.
|
||||
synctest.Test(t, func(t *testing.T) {
|
||||
server, out, _ := newProxy(t, "http://app.invalid", unansweredGeoJSURL,
|
||||
time.Now, map[string]string{unknownLimitPercent: "0"})
|
||||
server, out, _ := newServeWAF(t, unansweredGeoJSURL, time.Now,
|
||||
map[string]string{unknownLimitPercent: "0"})
|
||||
|
||||
// Once the time the request waits for its answer is up, the client
|
||||
// counts as without a country, and its zero allowance refuses the
|
||||
// request before it reaches the app.
|
||||
// request.
|
||||
serveFromDE(t, server, http.MethodGet, http.NoBody)
|
||||
|
||||
line := out.requestLine(t)
|
||||
@@ -320,13 +319,8 @@ func TestRequestWaitsForItsLookupWhileABiasedThresholdIsSet(t *testing.T) {
|
||||
// In a synctest bubble, as TestRequestWaitsAsLongAsTheLookupTimeoutSays
|
||||
// says, with a GeoJS that never answers.
|
||||
synctest.Test(t, func(t *testing.T) {
|
||||
// The request's body is over SWWAF_REQUEST_MAX_BYTES, so that it
|
||||
// is refused after the checks, and never reaches the app.
|
||||
server, out, _ := newProxy(t, "http://app.invalid", unansweredGeoJSURL,
|
||||
time.Now, map[string]string{
|
||||
lookupTimeout: timeout.String(), requestMaxBytes: "1",
|
||||
tc.setting: tc.value,
|
||||
})
|
||||
server, out, _ := newServeWAF(t, unansweredGeoJSURL, time.Now,
|
||||
map[string]string{lookupTimeout: timeout.String(), tc.setting: tc.value})
|
||||
began := time.Now()
|
||||
|
||||
serveFromDE(t, server, http.MethodPost, strings.NewReader("ab"))
|
||||
@@ -340,8 +334,7 @@ func TestRequestWaitsForItsLookupWhileABiasedThresholdIsSet(t *testing.T) {
|
||||
t.Errorf("the request waited %s for its answer, want %s", waited, want)
|
||||
}
|
||||
|
||||
wantLine(t, out.requestLine(t), http.StatusRequestEntityTooLarge,
|
||||
requestlog.ActionTooLarge)
|
||||
wantLine(t, out.requestLine(t), http.StatusOK, requestlog.ActionForward)
|
||||
|
||||
// The bubble's clock stops once this function returns, so the
|
||||
// request to GeoJS, which a request that did not wait leaves
|
||||
@@ -467,13 +460,15 @@ func (s *sender) uploadFrom(from string) logLine {
|
||||
return line
|
||||
}
|
||||
|
||||
// serveFromDE hands a request from fromDE with method and body straight to
|
||||
// server's handler, without the network, and returns once it is answered.
|
||||
// serveFromDE hands a question about a request from fromDE with method and body
|
||||
// straight to server's handler, without the network, and returns once it is
|
||||
// answered.
|
||||
func serveFromDE(t *testing.T, server *proxy.Server, method string, body io.Reader) {
|
||||
t.Helper()
|
||||
|
||||
req := httptest.NewRequestWithContext(t.Context(), method, "/", body)
|
||||
req.RemoteAddr = net.JoinHostPort(fromDE, "1234")
|
||||
req.Header.Set(proxy.ClientHeader, fromDE)
|
||||
req.Header.Set(requestIDHeader, "the-id")
|
||||
|
||||
server.Handler.ServeHTTP(httptest.NewRecorder(), req)
|
||||
}
|
||||
|
||||
@@ -1,239 +0,0 @@
|
||||
package proxy
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"errors"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"sync/atomic"
|
||||
|
||||
"sneak.berlin/go/smallwebwaf/internal/requestlog"
|
||||
)
|
||||
|
||||
// errResponseTooLarge ends an app's response body that is longer than
|
||||
// SWWAF_RESPONSE_MAX_BYTES.
|
||||
var errResponseTooLarge = errors.New(
|
||||
"the response body is over SWWAF_RESPONSE_MAX_BYTES")
|
||||
|
||||
// requestBody is the client's request body on its way to the app. The
|
||||
// transport reads it on a goroutine of its own.
|
||||
type requestBody struct {
|
||||
// body is the client's body, ending in an *http.MaxBytesError past
|
||||
// SWWAF_REQUEST_MAX_BYTES.
|
||||
body io.ReadCloser
|
||||
rq *request
|
||||
// readByCoreRuleSet is what the Core Rule Set read of the body before
|
||||
// the request went to the app, and Read gives first.
|
||||
readByCoreRuleSet []byte
|
||||
// waiting is true while a Read waits for the client to send more.
|
||||
waiting atomic.Bool
|
||||
// received is true once the client has sent the whole body.
|
||||
received atomic.Bool
|
||||
// bytes is how much of the body has been read.
|
||||
bytes atomic.Int64
|
||||
}
|
||||
|
||||
// Read reads from the client's body, after what the Core Rule Set read of
|
||||
// it, which has been counted already.
|
||||
func (b *requestBody) Read(p []byte) (int, error) {
|
||||
if len(b.readByCoreRuleSet) > 0 {
|
||||
n := copy(p, b.readByCoreRuleSet)
|
||||
b.readByCoreRuleSet = b.readByCoreRuleSet[n:]
|
||||
|
||||
return n, nil
|
||||
}
|
||||
|
||||
b.waiting.Store(true)
|
||||
n, err := b.body.Read(p)
|
||||
b.waiting.Store(false)
|
||||
b.bytes.Add(int64(n))
|
||||
|
||||
var tooLarge *http.MaxBytesError
|
||||
|
||||
switch {
|
||||
case errors.Is(err, io.EOF):
|
||||
b.received.Store(true)
|
||||
b.rq.bodyReceived()
|
||||
case errors.As(err, &tooLarge):
|
||||
b.rq.refuse(refusal{
|
||||
status: http.StatusRequestEntityTooLarge,
|
||||
action: requestlog.ActionTooLarge,
|
||||
limit: "SWWAF_REQUEST_MAX_BYTES",
|
||||
})
|
||||
}
|
||||
|
||||
return n, err
|
||||
}
|
||||
|
||||
// Close closes the client's body.
|
||||
func (b *requestBody) Close() error {
|
||||
return b.body.Close()
|
||||
}
|
||||
|
||||
// responseBody is the app's response body on its way to the client.
|
||||
type responseBody struct {
|
||||
// body is the app's body, ending in an *http.MaxBytesError past
|
||||
// SWWAF_RESPONSE_MAX_BYTES.
|
||||
body io.ReadCloser
|
||||
rq *request
|
||||
}
|
||||
|
||||
// Read reads from the app's body.
|
||||
func (b *responseBody) Read(p []byte) (int, error) {
|
||||
n, err := b.body.Read(p)
|
||||
if err == nil {
|
||||
return n, nil
|
||||
}
|
||||
|
||||
var tooLarge *http.MaxBytesError
|
||||
|
||||
switch {
|
||||
case errors.Is(err, io.EOF):
|
||||
b.rq.responseReceived()
|
||||
case errors.As(err, &tooLarge):
|
||||
b.rq.refuse(refusal{
|
||||
status: http.StatusBadGateway,
|
||||
action: requestlog.ActionTooLarge,
|
||||
limit: "SWWAF_RESPONSE_MAX_BYTES",
|
||||
})
|
||||
|
||||
return n, errResponseTooLarge
|
||||
case b.rq.in.Context().Err() == nil:
|
||||
// The answer broke off, not because the client went away. If a
|
||||
// timeout cut it, that refusal came first and is the one kept.
|
||||
b.rq.refuse(refusal{
|
||||
status: http.StatusBadGateway,
|
||||
action: requestlog.ActionUpstreamError,
|
||||
})
|
||||
}
|
||||
|
||||
return n, err
|
||||
}
|
||||
|
||||
// Close closes the app's body.
|
||||
func (b *responseBody) Close() error {
|
||||
return b.body.Close()
|
||||
}
|
||||
|
||||
// upgradedConn is the connection to the app once the app has switched
|
||||
// protocols, as for a WebSocket. ReverseProxy writes to it what the client
|
||||
// sends and reads from it what the app sends, on goroutines of its own,
|
||||
// until the connection closes; it counts the bytes each way, for the byte
|
||||
// limits.
|
||||
type upgradedConn struct {
|
||||
io.ReadWriteCloser
|
||||
|
||||
// fromApp is how many bytes the app has sent, and toApp how many the
|
||||
// client has.
|
||||
fromApp atomic.Int64
|
||||
toApp atomic.Int64
|
||||
}
|
||||
|
||||
// Read reads what the app sends.
|
||||
func (c *upgradedConn) Read(p []byte) (int, error) {
|
||||
n, err := c.ReadWriteCloser.Read(p)
|
||||
c.fromApp.Add(int64(n))
|
||||
|
||||
return n, err
|
||||
}
|
||||
|
||||
// Write sends the app what the client sent.
|
||||
func (c *upgradedConn) Write(p []byte) (int, error) {
|
||||
n, err := c.ReadWriteCloser.Write(p)
|
||||
c.toApp.Add(int64(n))
|
||||
|
||||
return n, err
|
||||
}
|
||||
|
||||
// CloseWrite tells the app that the client sends no more, while what the
|
||||
// app sends still passes. ReverseProxy calls it once the client has
|
||||
// stopped sending, and closes the connection there if it is not supported.
|
||||
func (c *upgradedConn) CloseWrite() error {
|
||||
conn, ok := c.ReadWriteCloser.(interface{ CloseWrite() error })
|
||||
if !ok {
|
||||
return http.ErrNotSupported
|
||||
}
|
||||
|
||||
return conn.CloseWrite()
|
||||
}
|
||||
|
||||
// limitBody returns body, cut off with an *http.MaxBytesError after
|
||||
// maxBytes, or unchanged if maxBytes is zero, which is off.
|
||||
func limitBody(body io.ReadCloser, maxBytes int64) io.ReadCloser {
|
||||
if maxBytes == 0 {
|
||||
return body
|
||||
}
|
||||
|
||||
// Without a ResponseWriter, MaxBytesReader only counts and cuts off.
|
||||
return http.MaxBytesReader(nil, body, maxBytes)
|
||||
}
|
||||
|
||||
// responseWriter is the response to the client. It notes the status and
|
||||
// size for the log line, and the first error writing to the client.
|
||||
type responseWriter struct {
|
||||
http.ResponseWriter
|
||||
|
||||
// status is the final status sent, or zero before one is.
|
||||
status int
|
||||
bytes int64
|
||||
err error
|
||||
}
|
||||
|
||||
// WriteHeader sends the status and headers. An informational 1xx status
|
||||
// is passed on and the final status still comes later.
|
||||
func (w *responseWriter) WriteHeader(status int) {
|
||||
if status >= http.StatusOK && w.status == 0 {
|
||||
w.status = status
|
||||
}
|
||||
|
||||
w.ResponseWriter.WriteHeader(status)
|
||||
}
|
||||
|
||||
// Write sends part of the body.
|
||||
func (w *responseWriter) Write(p []byte) (int, error) {
|
||||
if w.status == 0 {
|
||||
w.status = http.StatusOK
|
||||
}
|
||||
|
||||
n, err := w.ResponseWriter.Write(p)
|
||||
w.bytes += int64(n)
|
||||
w.noteError(err)
|
||||
|
||||
return n, err
|
||||
}
|
||||
|
||||
// FlushError sends what has been written so far.
|
||||
// http.ResponseController calls it, as ReverseProxy does after each
|
||||
// write.
|
||||
func (w *responseWriter) FlushError() error {
|
||||
err := http.NewResponseController(w.ResponseWriter).Flush()
|
||||
w.noteError(err)
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
// Hijack takes over the connection to the client, which ReverseProxy does
|
||||
// only to pass on the app's 101 Switching Protocols: that is then the
|
||||
// status sent.
|
||||
func (w *responseWriter) Hijack() (net.Conn, *bufio.ReadWriter, error) {
|
||||
conn, buffered, err := http.NewResponseController(w.ResponseWriter).Hijack()
|
||||
if err == nil {
|
||||
w.status = http.StatusSwitchingProtocols
|
||||
}
|
||||
|
||||
return conn, buffered, err
|
||||
}
|
||||
|
||||
// Unwrap lets http.ResponseController reach net/http's own
|
||||
// ResponseWriter for what responseWriter does not do itself.
|
||||
func (w *responseWriter) Unwrap() http.ResponseWriter {
|
||||
return w.ResponseWriter
|
||||
}
|
||||
|
||||
// noteError keeps the first error writing to the client.
|
||||
func (w *responseWriter) noteError(err error) {
|
||||
if w.err == nil {
|
||||
w.err = err
|
||||
}
|
||||
}
|
||||
@@ -47,8 +47,6 @@ const (
|
||||
func TestEachByteLimitBansOnceTheResponseHasEnded(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
const scraper = "192.0.2.200"
|
||||
|
||||
for _, tc := range []struct {
|
||||
setting, window string
|
||||
// apart is the time between the two requests, which the window
|
||||
@@ -94,7 +92,7 @@ func TestEachByteLimitBansOnceTheResponseHasEnded(t *testing.T) {
|
||||
|
||||
s.get(client, http.StatusForbidden, requestlog.ActionBanned)
|
||||
|
||||
wantMetric(t, s.scrape(scraper), `smallwebwaf_rate_limit_hits_total{`+
|
||||
wantMetric(t, s.scrape(), `smallwebwaf_rate_limit_hits_total{`+
|
||||
`instance="`+alertInstance+`",kind="bytes",window="`+tc.window+`"}`, 1)
|
||||
})
|
||||
}
|
||||
@@ -126,7 +124,7 @@ func TestBytesOfAnAnswerThatBreaksOffAreCounted(t *testing.T) {
|
||||
// The 70 bytes passed on before the app broke off are over the limit of
|
||||
// 50, and ban the client for an hour.
|
||||
line, got := s.requestWithBody(http.MethodGet, client, "/", "", "", http.StatusOK,
|
||||
requestlog.ActionUpstreamError)
|
||||
requestlog.ActionForward)
|
||||
if len(got.body) != answerBytes || line.LimitHit != minuteBytes ||
|
||||
line.BanExpires != expires {
|
||||
t.Errorf("got %d bytes, and the log line has limit_hit %q and ban_expires %q, "+
|
||||
@@ -168,39 +166,6 @@ func TestWebSocketBytesAreCountedOnceItCloses(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestWebSocketPassesTheAnswerAfterTheClientStopsSending(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
app := startApp(t, echoOnceTheClientStops)
|
||||
addr, out := startProxy(t, app.URL,
|
||||
map[string]string{trustedProxies: trustLocalhost})
|
||||
s := &sender{t: t, addr: addr, out: out}
|
||||
|
||||
conn, reader := s.openWebSocket()
|
||||
send(t, conn, uploadBody)
|
||||
|
||||
// The client closes its sending side and waits for the answer, which the
|
||||
// app sends only once it has seen the client stop. smallwebwaf passes the
|
||||
// close on to the app through CloseWrite on upgradedConn; without that,
|
||||
// it closes both connections, and the answer is lost.
|
||||
tcp, ok := conn.(*net.TCPConn)
|
||||
if !ok {
|
||||
t.Fatalf("connection is a %T, want a *net.TCPConn", conn)
|
||||
}
|
||||
|
||||
err := tcp.CloseWrite()
|
||||
if err != nil {
|
||||
t.Fatalf("close the sending side: %v", err)
|
||||
}
|
||||
|
||||
got, err := io.ReadAll(reader)
|
||||
if err != nil || string(got) != uploadBody {
|
||||
t.Errorf("got %q (%v), want %q", got, err, uploadBody)
|
||||
}
|
||||
|
||||
s.closeWebSocket(conn)
|
||||
}
|
||||
|
||||
func TestBytesCountSaysWhichBytesCount(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
@@ -465,28 +430,6 @@ func answerAfterUpgrade(w http.ResponseWriter, _ *http.Request) {
|
||||
}
|
||||
}
|
||||
|
||||
// echoOnceTheClientStops is an app that switches protocols, as for a
|
||||
// WebSocket, reads what the client sends until the client stops sending,
|
||||
// and then sends it all back.
|
||||
func echoOnceTheClientStops(w http.ResponseWriter, _ *http.Request) {
|
||||
conn, buffered, err := http.NewResponseController(w).Hijack()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
defer func() {
|
||||
_ = conn.Close()
|
||||
}()
|
||||
|
||||
_, _ = buffered.WriteString("HTTP/1.1 101 Switching Protocols\r\n" +
|
||||
"Connection: Upgrade\r\nUpgrade: websocket\r\n\r\n")
|
||||
_ = buffered.Flush()
|
||||
|
||||
received, _ := io.ReadAll(buffered)
|
||||
_, _ = buffered.Write(received)
|
||||
_ = buffered.Flush()
|
||||
}
|
||||
|
||||
// webSocket opens a WebSocket from client to answerAfterUpgrade, sends a
|
||||
// line of bodyBytes on it, reads the answer, and closes it. It checks the
|
||||
// answer, and the log line as request does, and returns the log line.
|
||||
@@ -543,7 +486,7 @@ func (s *sender) closeWebSocket(conn net.Conn) logLine {
|
||||
|
||||
_ = conn.Close()
|
||||
|
||||
line := s.out.requestLines(s.t, s.sent+1)[s.sent]
|
||||
line := s.out.endedLines(s.t, s.sent+1)[s.sent]
|
||||
s.sent++
|
||||
wantLine(s.t, line, http.StatusSwitchingProtocols, requestlog.ActionForward)
|
||||
|
||||
|
||||
+12
-92
@@ -5,10 +5,9 @@ import (
|
||||
"net/http"
|
||||
"net/netip"
|
||||
"slices"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// peerAddress is the address of the request's TCP peer, normally traefik.
|
||||
// peerAddress is the address of the request's TCP peer.
|
||||
func peerAddress(r *http.Request) netip.Addr {
|
||||
addrPort, err := netip.ParseAddrPort(r.RemoteAddr)
|
||||
if err != nil {
|
||||
@@ -18,58 +17,24 @@ func peerAddress(r *http.Request) netip.Addr {
|
||||
return addrPort.Addr().Unmap()
|
||||
}
|
||||
|
||||
// clientAddress works out who the client is. A peer outside the trusted
|
||||
// proxies is the client, and what it says in X-Forwarded-For is ignored.
|
||||
// For a peer inside them, X-Forwarded-For is read from the right, and the
|
||||
// first address outside them is the client; if every address in it is
|
||||
// inside, the leftmost is, and with no header, the peer. An entry that is
|
||||
// not an address ends the reading, since nothing to its left can be
|
||||
// believed.
|
||||
func clientAddress(
|
||||
peer netip.Addr, forwardedFor []string, trusted []netip.Prefix,
|
||||
) netip.Addr {
|
||||
client := peer
|
||||
if !isInside(peer, trusted) {
|
||||
return client
|
||||
}
|
||||
|
||||
entries := strings.Split(strings.Join(forwardedFor, ","), ",")
|
||||
for _, entry := range slices.Backward(entries) {
|
||||
addr, err := netip.ParseAddr(strings.TrimSpace(entry))
|
||||
if err != nil {
|
||||
break
|
||||
}
|
||||
|
||||
client = addr.Unmap()
|
||||
if !isInside(client, trusted) {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return client
|
||||
}
|
||||
|
||||
// requestIDHeader carries the request's id, from traefik and to the app.
|
||||
// requestIDHeader carries the request's id, from rproxy.
|
||||
const requestIDHeader = "X-Request-ID"
|
||||
|
||||
// requestID is the request's id: the one a trusted proxy sent, or a new
|
||||
// random one. A peer outside the trusted proxies did not come through
|
||||
// traefik, so the id it sends is its own claim, and is replaced.
|
||||
func requestID(r *http.Request, peerTrusted bool) string {
|
||||
id := r.Header.Get(requestIDHeader)
|
||||
if !peerTrusted || id == "" {
|
||||
id = rand.Text()
|
||||
// requestID is the request's id: the one a question gives, or for any other
|
||||
// request a new random one.
|
||||
func requestID(r *http.Request, isQuestion bool) string {
|
||||
if isQuestion {
|
||||
return r.Header.Get(requestIDHeader)
|
||||
}
|
||||
|
||||
return id
|
||||
return rand.Text()
|
||||
}
|
||||
|
||||
// scheme is how the client reached traefik, as a trusted proxy says in
|
||||
// X-Forwarded-Proto, or otherwise http, the only scheme smallwebwaf
|
||||
// serves.
|
||||
func scheme(r *http.Request, peerTrusted bool) string {
|
||||
// scheme is how the visitor reached rproxy, or the trusted proxy in front of
|
||||
// it, as a question says in X-Forwarded-Proto, or otherwise http.
|
||||
func scheme(r *http.Request, isQuestion bool) string {
|
||||
proto := r.Header.Get("X-Forwarded-Proto")
|
||||
if !peerTrusted || proto == "" {
|
||||
if !isQuestion || proto == "" {
|
||||
return "http"
|
||||
}
|
||||
|
||||
@@ -95,48 +60,3 @@ func isInside(addr netip.Addr, netblocks []netip.Prefix) bool {
|
||||
return netblock.Contains(addr)
|
||||
})
|
||||
}
|
||||
|
||||
// setForwardedHeaders sets the headers in which the app learns about the
|
||||
// client, so that it sees what it would see from traefik directly. A
|
||||
// trusted proxy's forwarded headers pass on, with the proxy's own address
|
||||
// added to X-Forwarded-For. Those of any other peer are its own claims and
|
||||
// are replaced: X-Forwarded-For names the peer, X-Forwarded-Host the host
|
||||
// it asked for, and X-Forwarded-Proto plain http, which is how it reached
|
||||
// smallwebwaf.
|
||||
func setForwardedHeaders(in, out *http.Request, peer netip.Addr, trusted bool) {
|
||||
forwardedFor := peer.String()
|
||||
|
||||
if trusted {
|
||||
// ReverseProxy removes these from out before Rewrite.
|
||||
for _, name := range []string{"Forwarded", "X-Forwarded-Host", "X-Forwarded-Proto"} {
|
||||
values, ok := in.Header[name]
|
||||
if ok {
|
||||
out.Header[name] = values
|
||||
}
|
||||
}
|
||||
|
||||
prior := in.Header.Values("X-Forwarded-For")
|
||||
if len(prior) > 0 {
|
||||
forwardedFor = strings.Join(prior, ", ") + ", " + forwardedFor
|
||||
}
|
||||
|
||||
out.Header.Set("X-Forwarded-For", forwardedFor)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// ReverseProxy has removed Forwarded and the three set below; these
|
||||
// are the other headers in which traefik tells the app about the
|
||||
// client and its request.
|
||||
for _, name := range []string{
|
||||
"X-Forwarded-Port", "X-Forwarded-Server", "X-Forwarded-Uri",
|
||||
"X-Forwarded-Method", "X-Forwarded-Prefix", "X-Forwarded-Tls-Client-Cert",
|
||||
"X-Forwarded-Tls-Client-Cert-Info", "X-Real-Ip",
|
||||
} {
|
||||
out.Header.Del(name)
|
||||
}
|
||||
|
||||
out.Header.Set("X-Forwarded-For", forwardedFor)
|
||||
out.Header.Set("X-Forwarded-Host", in.Host)
|
||||
out.Header.Set("X-Forwarded-Proto", "http")
|
||||
}
|
||||
|
||||
@@ -1,165 +0,0 @@
|
||||
package proxy_test
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"testing"
|
||||
)
|
||||
|
||||
const (
|
||||
// trustLocalhost trusts the address every test connects from, and a
|
||||
// network for proxies in front of it.
|
||||
trustLocalhost = localhost + "/32,10.0.0.0/8"
|
||||
// appHost is the host every test asks for.
|
||||
appHost = "app.example"
|
||||
// client is the client's address, as a proxy names it.
|
||||
client = "203.0.113.9"
|
||||
// forwardedFor is the header that lists the client and its proxies,
|
||||
// and forwardedProto the one that gives the scheme the client used.
|
||||
forwardedFor = "X-Forwarded-For"
|
||||
forwardedProto = "X-Forwarded-Proto"
|
||||
// secure is the scheme a client reached traefik with, and plain the
|
||||
// one smallwebwaf serves.
|
||||
secure = "https"
|
||||
plain = "http"
|
||||
)
|
||||
|
||||
// appHeaders is what the app tells about the headers it received.
|
||||
type appHeaders struct {
|
||||
Host string `json:"host"`
|
||||
ForwardedFor string `json:"forwardedFor"`
|
||||
ForwardedHost string `json:"forwardedHost"`
|
||||
ForwardedProto string `json:"forwardedProto"`
|
||||
RealIP string `json:"realIp"`
|
||||
}
|
||||
|
||||
// clientAddressCase is a request and what smallwebwaf makes of it.
|
||||
type clientAddressCase struct {
|
||||
name string
|
||||
env map[string]string
|
||||
header http.Header
|
||||
wantClient string
|
||||
wantApp appHeaders
|
||||
}
|
||||
|
||||
func TestClientAddressAndForwardedHeaders(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
for _, tc := range clientAddressCases() {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
got, line := requestWithHeaders(t, tc.env, tc.header)
|
||||
|
||||
tc.wantApp.Host = appHost
|
||||
if got != tc.wantApp {
|
||||
t.Errorf("app received %+v, want %+v", got, tc.wantApp)
|
||||
}
|
||||
|
||||
if line.ClientIP != tc.wantClient || line.PeerIP != localhost {
|
||||
t.Errorf("log line has client_ip %q and peer_ip %q, want %q and %q",
|
||||
line.ClientIP, line.PeerIP, tc.wantClient, localhost)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// clientAddressCases are the requests TestClientAddressAndForwardedHeaders
|
||||
// sends, from 127.0.0.1, which the default trusted proxies leave out.
|
||||
func clientAddressCases() []clientAddressCase {
|
||||
trusted := map[string]string{trustedProxies: trustLocalhost}
|
||||
forged := http.Header{
|
||||
forwardedFor: {client},
|
||||
"X-Forwarded-Host": {"forged.example"},
|
||||
forwardedProto: {secure},
|
||||
"X-Real-Ip": {client},
|
||||
}
|
||||
replaced := appHeaders{
|
||||
ForwardedFor: localhost, ForwardedHost: appHost, ForwardedProto: plain,
|
||||
}
|
||||
|
||||
return []clientAddressCase{{
|
||||
name: "a peer outside the trusted proxies is the client, " +
|
||||
"and its forwarded headers are replaced",
|
||||
header: forged, wantClient: localhost, wantApp: replaced,
|
||||
}, {
|
||||
name: "set but empty, the trusted proxies trust nothing",
|
||||
env: map[string]string{trustedProxies: ""},
|
||||
header: forged, wantClient: localhost, wantApp: replaced,
|
||||
}, {
|
||||
name: "behind a trusted peer, the client is the first address " +
|
||||
"outside the trusted proxies from the right",
|
||||
env: trusted,
|
||||
header: http.Header{
|
||||
forwardedFor: {"198.51.100.7, " + client + ", 10.0.0.2"},
|
||||
"X-Forwarded-Host": {appHost},
|
||||
forwardedProto: {secure},
|
||||
"X-Real-Ip": {client},
|
||||
},
|
||||
wantClient: client,
|
||||
wantApp: appHeaders{
|
||||
ForwardedFor: "198.51.100.7, " + client + ", 10.0.0.2, " + localhost,
|
||||
ForwardedHost: appHost, ForwardedProto: secure, RealIP: client,
|
||||
},
|
||||
}, {
|
||||
name: "when every address is a trusted proxy, the leftmost is the client",
|
||||
env: trusted,
|
||||
header: http.Header{forwardedFor: {"10.0.0.5, 10.0.0.2"}},
|
||||
wantClient: "10.0.0.5",
|
||||
wantApp: appHeaders{ForwardedFor: "10.0.0.5, 10.0.0.2, " + localhost},
|
||||
}, {
|
||||
name: "with no header, a trusted peer is the client",
|
||||
env: trusted,
|
||||
wantClient: localhost,
|
||||
wantApp: appHeaders{ForwardedFor: localhost},
|
||||
}, {
|
||||
name: "an entry that is not an address ends the reading",
|
||||
env: trusted,
|
||||
header: http.Header{forwardedFor: {client + ", unknown, 10.0.0.2"}},
|
||||
wantClient: "10.0.0.2",
|
||||
wantApp: appHeaders{
|
||||
ForwardedFor: client + ", unknown, 10.0.0.2, " + localhost,
|
||||
},
|
||||
}, {
|
||||
name: "several header lines are read as one list",
|
||||
env: trusted,
|
||||
header: http.Header{forwardedFor: {"2001:db8::7", "10.0.0.2"}},
|
||||
wantClient: "2001:db8::7",
|
||||
wantApp: appHeaders{ForwardedFor: "2001:db8::7, 10.0.0.2, " + localhost},
|
||||
}}
|
||||
}
|
||||
|
||||
// requestWithHeaders sends a request for appHost with header through
|
||||
// smallwebwaf, with the settings in env, and returns the headers the app
|
||||
// received and the request's log line.
|
||||
func requestWithHeaders(
|
||||
t *testing.T, env map[string]string, header http.Header,
|
||||
) (appHeaders, logLine) {
|
||||
t.Helper()
|
||||
|
||||
app := startApp(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
_ = json.NewEncoder(w).Encode(appHeaders{
|
||||
Host: r.Host,
|
||||
ForwardedFor: r.Header.Get(forwardedFor),
|
||||
ForwardedHost: r.Header.Get("X-Forwarded-Host"),
|
||||
ForwardedProto: r.Header.Get(forwardedProto),
|
||||
RealIP: r.Header.Get("X-Real-IP"),
|
||||
})
|
||||
})
|
||||
addr, out := startProxy(t, app.URL, env)
|
||||
|
||||
req := newRequest(t, http.MethodGet, addr, "/", http.NoBody)
|
||||
req.Host = appHost
|
||||
req.Header = header.Clone()
|
||||
|
||||
answered := do(t, req)
|
||||
|
||||
var got appHeaders
|
||||
|
||||
err := json.Unmarshal(answered.body, &got)
|
||||
if err != nil {
|
||||
t.Fatalf("decode the app's answer %q: %v", answered.body, err)
|
||||
}
|
||||
|
||||
return got, out.requestLine(t)
|
||||
}
|
||||
@@ -1,9 +1,6 @@
|
||||
package proxy
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"sneak.berlin/go/smallwebwaf/internal/alerts"
|
||||
@@ -19,8 +16,7 @@ import (
|
||||
// SWWAF_WAF_ANOMALY_THRESHOLD is a match: it raises the waf_block alert,
|
||||
// and in block mode refuses the request, which is an offence its client's
|
||||
// history counts, and so returns ActionWAFBlocked. It returns "" for a
|
||||
// request it does not refuse, and for one whose body meets a size or time
|
||||
// limit while the Core Rule Set reads it, which it notes nothing of.
|
||||
// request it does not refuse.
|
||||
func (rq *request) checkCoreRuleSet() string {
|
||||
cfg := rq.h.config
|
||||
if cfg.WAFMode == config.WAFModeOff || pathExempt(rq.in, cfg.WAFExemptPaths) {
|
||||
@@ -29,10 +25,10 @@ func (rq *request) checkCoreRuleSet() string {
|
||||
|
||||
start := time.Now()
|
||||
|
||||
result := rq.inspect()
|
||||
if rq.refused.Load() != nil {
|
||||
return "" // the refusal for that limit, which check returns
|
||||
}
|
||||
// A question's body has been read into memory, so that reading it here
|
||||
// cannot fail.
|
||||
result, read, _ := rq.h.coreRuleSet.Inspect(rq.in, rq.client, rq.in.Body)
|
||||
rq.requestBytes = int64(len(read))
|
||||
|
||||
rq.line.DurationWAF = new(requestlog.Milliseconds(time.Since(start)))
|
||||
rq.line.WAFRuleIDs = result.RuleIDs
|
||||
@@ -58,39 +54,6 @@ func (rq *request) checkCoreRuleSet() string {
|
||||
return requestlog.ActionWAFBlocked
|
||||
}
|
||||
|
||||
// inspect runs the Core Rule Set on the request, which reads the part of
|
||||
// its body it inspects within SWWAF_CLIENT_REQUEST_TIMEOUT, and keeps that
|
||||
// part for the app. A client that runs out of time is refused with 408
|
||||
// here, and a body over SWWAF_REQUEST_MAX_BYTES with 413 as it is read;
|
||||
// check returns the refusal. A body that breaks off for any other reason
|
||||
// is passed on as far as it came, and the request to the app fails there,
|
||||
// as it would have without the Core Rule Set.
|
||||
func (rq *request) inspect() waf.Result {
|
||||
if rq.body == nil {
|
||||
// Nothing is read of no body, so nothing can go wrong reading it.
|
||||
result, _, _ := rq.h.coreRuleSet.Inspect(rq.in, rq.client, http.NoBody)
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
_ = rq.rc.SetReadDeadline(rq.clientRequestDeadline())
|
||||
result, read, err := rq.h.coreRuleSet.Inspect(rq.in, rq.client, rq.body)
|
||||
// The timeouts that run while the request goes to the app take over.
|
||||
_ = rq.rc.SetReadDeadline(time.Time{})
|
||||
|
||||
rq.body.readByCoreRuleSet = read
|
||||
|
||||
if errors.Is(err, os.ErrDeadlineExceeded) {
|
||||
rq.refuse(refusal{
|
||||
status: http.StatusRequestTimeout,
|
||||
action: requestlog.ActionTimedOut,
|
||||
limit: "SWWAF_CLIENT_REQUEST_TIMEOUT",
|
||||
})
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// alertWAFBlock raises the waf_block alert for the request, which the Core
|
||||
// Rule Set scored at result, at or over SWWAF_WAF_ANOMALY_THRESHOLD. Its
|
||||
// detail gives the rule ids, the score, the method and the path with the
|
||||
|
||||
@@ -8,7 +8,6 @@ import (
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"sneak.berlin/go/smallwebwaf/internal/alerts"
|
||||
"sneak.berlin/go/smallwebwaf/internal/ratelimit"
|
||||
@@ -29,6 +28,13 @@ const (
|
||||
// formData is the type of a form's body.
|
||||
const formData = "application/x-www-form-urlencoded"
|
||||
|
||||
// sizeLimit is SWWAF_WAF_BODY_LIMIT in the tests that read bodies, 1K as a
|
||||
// setting.
|
||||
const (
|
||||
sizeLimit = 1 << 10
|
||||
sizeLimitSetting = "1K"
|
||||
)
|
||||
|
||||
// sqlInjection asks for / with an SQL injection in its query, which only
|
||||
// the Core Rule Set's rule 942100 matches, with a score of 5, the default
|
||||
// SWWAF_WAF_ANOMALY_THRESHOLD.
|
||||
@@ -96,7 +102,7 @@ func TestCoreRuleSetRefusesAttacksInBlockModeAndOnlyLogsThemInDetectMode(t *test
|
||||
} {
|
||||
s, _, _ := startWithClock(t, "", map[string]string{wafMode: tc.mode})
|
||||
|
||||
line, _ := s.requestWithHeader(client, attack.path, attack.header,
|
||||
line := s.requestWithHeader(client, attack.path, attack.header,
|
||||
tc.status, tc.action)
|
||||
wantWAF(t, line, &attack.score, attack.ruleIDs...)
|
||||
}
|
||||
@@ -299,7 +305,7 @@ func TestBodiesReachTheAppAsSentWhileBodiesAreRead(t *testing.T) {
|
||||
tc.name, len(got.body), got.status, len(tc.body))
|
||||
}
|
||||
|
||||
line := out.requestLines(t, i+1)[i]
|
||||
line := out.endedLines(t, i+1)[i]
|
||||
wantLine(t, line, http.StatusOK, requestlog.ActionForward)
|
||||
|
||||
if line.RequestBytes != int64(len(tc.body)) {
|
||||
@@ -309,119 +315,6 @@ func TestBodiesReachTheAppAsSentWhileBodiesAreRead(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormBodyLongerThanTheLimitStreamsOnToTheApp(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
const (
|
||||
first = "a=" // and twice the limit of b's, then the rest
|
||||
rest = 64 * sizeLimit
|
||||
)
|
||||
|
||||
// past is closed once the app has received twice what the Core Rule
|
||||
// Set reads, and got is the length of the whole body it received.
|
||||
past := make(chan struct{})
|
||||
got := make(chan int64, 1)
|
||||
app := startApp(t, func(_ http.ResponseWriter, r *http.Request) {
|
||||
n, _ := io.CopyN(io.Discard, r.Body, 2*sizeLimit)
|
||||
|
||||
close(past)
|
||||
|
||||
m, _ := io.Copy(io.Discard, r.Body)
|
||||
got <- n + m
|
||||
})
|
||||
addr, out := startProxy(t, app.URL, map[string]string{
|
||||
wafMode: block, wafBodyLimit: sizeLimitSetting,
|
||||
})
|
||||
|
||||
// The client sends the rest only once the app has received the first
|
||||
// part: were smallwebwaf to hold the body until the end, it would
|
||||
// never come.
|
||||
body, sender := io.Pipe()
|
||||
|
||||
go func() {
|
||||
_, _ = io.WriteString(sender, first+strings.Repeat("b", 2*sizeLimit))
|
||||
|
||||
select {
|
||||
case <-past:
|
||||
case <-time.After(waitLimit):
|
||||
t.Error("the app got no more than the Core Rule Set reads " +
|
||||
"before the whole body was sent")
|
||||
|
||||
_ = sender.CloseWithError(io.ErrUnexpectedEOF)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
_, _ = io.WriteString(sender, strings.Repeat("b", rest))
|
||||
_ = sender.Close()
|
||||
}()
|
||||
|
||||
req := newRequest(t, http.MethodPost, addr, "/", body)
|
||||
req.Header.Set("Content-Type", formData)
|
||||
wantStatus(t, do(t, req), http.StatusOK)
|
||||
|
||||
want := int64(len(first) + 2*sizeLimit + rest)
|
||||
if n := <-got; n != want {
|
||||
t.Errorf("the app got %d bytes, want %d", n, want)
|
||||
}
|
||||
|
||||
wantLine(t, out.requestLine(t), http.StatusOK, requestlog.ActionForward)
|
||||
}
|
||||
|
||||
func TestClientTooSlowToSendWhatTheCoreRuleSetReads(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
app := startApp(t, func(http.ResponseWriter, *http.Request) {})
|
||||
addr, out := startProxy(t, app.URL, map[string]string{
|
||||
wafMode: block, wafBodyLimit: sizeLimitSetting,
|
||||
clientRequestTimeout: shortTimeoutSetting, metricsToken: token,
|
||||
})
|
||||
|
||||
conn := dial(t, addr)
|
||||
send(t, conn, "POST /comment HTTP/1.1\r\nHost: app\r\nContent-Type: "+formData+
|
||||
"\r\nContent-Length: 100\r\n\r\ncontent=the first bytes")
|
||||
|
||||
wantStatus(t, readResponse(t, conn), http.StatusRequestTimeout)
|
||||
|
||||
line := out.requestLine(t)
|
||||
wantLine(t, line, http.StatusRequestTimeout, requestlog.ActionTimedOut)
|
||||
wantNotSentToTheApp(t, line)
|
||||
wantLimitHits(t, addr, clientRequestTimeout, 1)
|
||||
}
|
||||
|
||||
func TestBodyOverTheSizeLimitWhileTheCoreRuleSetReadsIt(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
app := startApp(t, func(http.ResponseWriter, *http.Request) {})
|
||||
addr, out := startProxy(t, app.URL, map[string]string{
|
||||
wafMode: block, wafBodyLimit: "4K",
|
||||
requestMaxBytes: sizeLimitSetting, metricsToken: token,
|
||||
})
|
||||
|
||||
// Sent in chunks, its length is not announced, and is found to be over
|
||||
// the limit as the Core Rule Set reads it.
|
||||
body := io.MultiReader(strings.NewReader("a=" + strings.Repeat("b", 2*sizeLimit)))
|
||||
req := newRequest(t, http.MethodPost, addr, "/", body)
|
||||
req.Header.Set("Content-Type", formData)
|
||||
wantStatus(t, do(t, req), http.StatusRequestEntityTooLarge)
|
||||
|
||||
line := out.requestLine(t)
|
||||
wantLine(t, line, http.StatusRequestEntityTooLarge, requestlog.ActionTooLarge)
|
||||
wantNotSentToTheApp(t, line)
|
||||
wantLimitHits(t, addr, requestMaxBytes, 1)
|
||||
}
|
||||
|
||||
// wantNotSentToTheApp checks that the request of line was not sent to the
|
||||
// app at all.
|
||||
func wantNotSentToTheApp(t *testing.T, line logLine) {
|
||||
t.Helper()
|
||||
|
||||
_, sent := line.fields["duration_upstream_total"]
|
||||
if sent {
|
||||
t.Error("log line has duration_upstream_total, for a request sent to the app")
|
||||
}
|
||||
}
|
||||
|
||||
func TestResponsesAreNotInspected(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
@@ -441,14 +334,12 @@ func TestResponsesAreNotInspected(t *testing.T) {
|
||||
t.Errorf("answered %d with %q, want 200 with the app's page", got.status, got.body)
|
||||
}
|
||||
|
||||
wantLine(t, out.requestLine(t), http.StatusOK, requestlog.ActionForward)
|
||||
wantLine(t, out.endedLine(t), http.StatusOK, requestlog.ActionForward)
|
||||
}
|
||||
|
||||
func TestCoreRuleSetRefusalIsAnOffenceAndCountsTowardTheErrorBurst(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
const scraper = "192.0.2.200"
|
||||
|
||||
s, _, server := startWithClock(t, "", map[string]string{
|
||||
wafMode: block, errorBurstThreshold: "2", metricsToken: token,
|
||||
})
|
||||
@@ -474,7 +365,7 @@ func TestCoreRuleSetRefusalIsAnOffenceAndCountsTowardTheErrorBurst(t *testing.T)
|
||||
t.Errorf("history counts the offences %+v, want %+v", offences, want)
|
||||
}
|
||||
|
||||
metrics := s.scrape(scraper)
|
||||
metrics := s.scrape()
|
||||
wantMetric(t, metrics,
|
||||
`smallwebwaf_waf_matches_total{instance="app",mode="block",rule_id="942100"}`, 3)
|
||||
wantMetric(t, metrics,
|
||||
@@ -486,8 +377,6 @@ func TestCoreRuleSetRefusalIsAnOffenceAndCountsTowardTheErrorBurst(t *testing.T)
|
||||
func TestDetectModeMatchIsNoOffenceAndNotCountedTowardTheErrorBurst(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
const scraper = "192.0.2.200"
|
||||
|
||||
s, _, server := startWithClock(t, "", map[string]string{
|
||||
wafMode: detect, errorBurstThreshold: "2", metricsToken: token,
|
||||
})
|
||||
@@ -503,7 +392,7 @@ func TestDetectModeMatchIsNoOffenceAndNotCountedTowardTheErrorBurst(t *testing.T
|
||||
t.Errorf("history counts the offences %+v, want none", offences)
|
||||
}
|
||||
|
||||
metrics := s.scrape(scraper)
|
||||
metrics := s.scrape()
|
||||
wantMetric(t, metrics,
|
||||
`smallwebwaf_waf_matches_total{instance="app",mode="detect",rule_id="942100"}`, 3)
|
||||
wantNoSeries(t, metrics,
|
||||
@@ -576,6 +465,7 @@ func TestCoreRuleSetMatchRaisesTheWAFBlockAlert(t *testing.T) {
|
||||
Instance: alertInstance,
|
||||
Time: clk.Now(),
|
||||
Event: alerts.EventWAFBlock,
|
||||
Host: appHost,
|
||||
Client: netip.MustParseAddr(client),
|
||||
Netblock: netip.MustParsePrefix(client + "/32"),
|
||||
Reason: "scored by the Core Rule Set at or over SWWAF_WAF_ANOMALY_THRESHOLD",
|
||||
|
||||
@@ -65,7 +65,7 @@ func TestCountryLists(t *testing.T) {
|
||||
req.Header.Set(forwardedFor, sent.client)
|
||||
got := do(t, req)
|
||||
|
||||
line := out.requestLines(t, i+1)[i]
|
||||
line := out.endedLines(t, i+1)[i]
|
||||
if line.ASN != sent.asn || line.ASName != sent.asName ||
|
||||
line.Country != sent.country {
|
||||
t.Errorf("log line has %q, %q and %q, want %q, %q and %q",
|
||||
@@ -90,7 +90,7 @@ func TestCountryLists(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCountryRefusalComesBeforeTheBody(t *testing.T) {
|
||||
func TestCountryRefusalKeepsARequestFromTheApp(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var calls atomic.Int32
|
||||
@@ -109,13 +109,9 @@ func TestCountryRefusalComesBeforeTheBody(t *testing.T) {
|
||||
req.Header.Set(forwardedFor, fromKP)
|
||||
wantStatus(t, do(t, req), http.StatusForbidden)
|
||||
|
||||
line := out.requestLine(t)
|
||||
line := out.endedLine(t)
|
||||
wantLine(t, line, http.StatusForbidden, requestlog.ActionCountryDenied)
|
||||
|
||||
if line.RequestBytes != 0 {
|
||||
t.Errorf("log line has request_bytes %d, want 0", line.RequestBytes)
|
||||
}
|
||||
|
||||
if calls.Load() != 0 {
|
||||
t.Errorf("the app was called %d times, want none", calls.Load())
|
||||
}
|
||||
@@ -211,7 +207,7 @@ func TestPrivateAddressIsNeverLookedUp(t *testing.T) {
|
||||
|
||||
wantStatus(t, do(t, req), http.StatusOK)
|
||||
|
||||
line := out.requestLines(t, i+1)[i]
|
||||
line := out.endedLines(t, i+1)[i]
|
||||
wantLine(t, line, http.StatusOK, requestlog.ActionForward)
|
||||
|
||||
for _, field := range []string{"asn", "as_name", "country"} {
|
||||
|
||||
@@ -84,7 +84,7 @@ func TestClientTheCrowdSecDecisionListListsIsBannedUntilTheDecisionEnds(t *testi
|
||||
|
||||
// The ban and the hit are counted, and the list has the metrics of any
|
||||
// list fetched from a URL.
|
||||
metrics := s.scrape(unplaced)
|
||||
metrics := s.scrape()
|
||||
labels := `{instance="` + alertInstance + `",source="` + decisionsURL + `"}`
|
||||
|
||||
wantMetric(t, metrics, `smallwebwaf_bans_made_total{cause="crowdsec",`+
|
||||
|
||||
@@ -17,8 +17,8 @@ import (
|
||||
|
||||
const errorBurstThreshold = "SWWAF_ERROR_BURST_THRESHOLD"
|
||||
|
||||
// refused is a request the tests here send, which smallwebwaf refuses
|
||||
// after a rule file match, or for a missing or wrong token.
|
||||
// refused is a request the tests here send, which servewaf refuses after a rule
|
||||
// file match.
|
||||
type refused int
|
||||
|
||||
const (
|
||||
@@ -27,11 +27,6 @@ const (
|
||||
// banRule is one its ban rule refuses with 403, and bans the client
|
||||
// for.
|
||||
banRule
|
||||
// noMetricsToken is one for the metrics without a token, and
|
||||
// wrongAdminToken one for the bans with the metrics token, each
|
||||
// refused with 401.
|
||||
noMetricsToken
|
||||
wrongAdminToken
|
||||
)
|
||||
|
||||
// send sends r from the client at from, checks its answer and log line as
|
||||
@@ -45,14 +40,6 @@ func (r refused) send(s *sender, from string) logLine {
|
||||
requestlog.ActionRuleBlocked)
|
||||
case banRule:
|
||||
return s.request(from, probePath, http.StatusForbidden, requestlog.ActionBanned)
|
||||
case noMetricsToken:
|
||||
return s.request(from, proxy.MetricsPath, http.StatusUnauthorized,
|
||||
requestlog.ActionAdmin)
|
||||
case wrongAdminToken:
|
||||
line, _ := s.requestWithHeader(from, proxy.BansPath, "Authorization: "+bearer,
|
||||
http.StatusUnauthorized, requestlog.ActionAdmin)
|
||||
|
||||
return line
|
||||
}
|
||||
|
||||
s.t.Fatalf("no request for the refusal %d", r)
|
||||
@@ -60,7 +47,7 @@ func (r refused) send(s *sender, from string) logLine {
|
||||
return logLine{}
|
||||
}
|
||||
|
||||
// startForErrorBurst is startWithClock with testRules, both tokens and
|
||||
// startForErrorBurst is startWithClock with testRules, the metrics token and
|
||||
// SWWAF_ERROR_BURST_THRESHOLD at threshold, and the settings in env.
|
||||
func startForErrorBurst(
|
||||
t *testing.T, threshold string, env map[string]string,
|
||||
@@ -70,7 +57,6 @@ func startForErrorBurst(
|
||||
settings := map[string]string{
|
||||
errorBurstThreshold: threshold,
|
||||
rulesDir: writeRules(t, testRules),
|
||||
adminToken: adminSecret,
|
||||
metricsToken: token,
|
||||
}
|
||||
maps.Copy(settings, env)
|
||||
@@ -87,14 +73,7 @@ func TestErrorBurstBreaksAtOneOverTheThreshold(t *testing.T) {
|
||||
refusals []refused
|
||||
}{
|
||||
{"block rule", []refused{blockRule, blockRule, blockRule, blockRule}},
|
||||
{
|
||||
"missing or wrong token",
|
||||
[]refused{noMetricsToken, wrongAdminToken, noMetricsToken, wrongAdminToken},
|
||||
},
|
||||
{
|
||||
"a mix ending in a ban rule",
|
||||
[]refused{blockRule, noMetricsToken, blockRule, banRule},
|
||||
},
|
||||
{"a ban rule last", []refused{blockRule, blockRule, blockRule, banRule}},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
@@ -130,13 +109,11 @@ func TestErrorBurstBreaksAtOneOverTheThreshold(t *testing.T) {
|
||||
func TestErrorBurstBanNotesHistoryAndMetrics(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
const scraper = "192.0.2.200"
|
||||
|
||||
s, clk, server := startForErrorBurst(t, "2", nil)
|
||||
start := clk.Now()
|
||||
|
||||
blockRule.send(s, client)
|
||||
wrongAdminToken.send(s, client)
|
||||
blockRule.send(s, client)
|
||||
line := blockRule.send(s, client)
|
||||
|
||||
expires := start.Add(time.Hour)
|
||||
@@ -173,19 +150,17 @@ func TestErrorBurstBanNotesHistoryAndMetrics(t *testing.T) {
|
||||
t.Fatalf("bans\n%+v\nwant\n%+v", got, want)
|
||||
}
|
||||
|
||||
wantOffences := ratelimit.Offences{Limit: 1, RuleBlocked: 2, TokenRefused: 1}
|
||||
wantOffences := ratelimit.Offences{Limit: 1, RuleBlocked: 3}
|
||||
if offences := historyOf(t, server, client).Offences; offences != wantOffences {
|
||||
t.Errorf("history counts the offences %+v, want %+v", offences, wantOffences)
|
||||
}
|
||||
|
||||
metrics := s.scrape(scraper)
|
||||
metrics := s.scrape()
|
||||
wantMetric(t, metrics, `smallwebwaf_rate_limit_hits_total{instance="app",`+
|
||||
`kind="refusals",window="minute"}`, 1)
|
||||
wantMetric(t, metrics, `smallwebwaf_offences_total{instance="app",kind="limit"}`, 1)
|
||||
wantMetric(t, metrics,
|
||||
`smallwebwaf_offences_total{instance="app",kind="rule_blocked"}`, 2)
|
||||
wantMetric(t, metrics,
|
||||
`smallwebwaf_offences_total{instance="app",kind="token_refused"}`, 1)
|
||||
`smallwebwaf_offences_total{instance="app",kind="rule_blocked"}`, 3)
|
||||
wantMetric(t, metrics, `smallwebwaf_bans_made_total{cause="limit",instance="app"}`, 1)
|
||||
}
|
||||
|
||||
@@ -291,19 +266,15 @@ func TestErrorBurstCountsEachClientTheChecksApplyTo(t *testing.T) {
|
||||
allowNets: allowed, rateLimitExemptNets: exempt,
|
||||
})
|
||||
|
||||
// A client in SWWAF_ALLOW_NETS still needs the token, but is not
|
||||
// counted.
|
||||
// A client in SWWAF_ALLOW_NETS is not refused, and so not counted.
|
||||
for range 3 {
|
||||
line := noMetricsToken.send(s, allowed)
|
||||
if line.LimitHit != "" {
|
||||
t.Errorf("log line has limit_hit %q, want none", line.LimitHit)
|
||||
}
|
||||
s.request(allowed, blockedPath, http.StatusOK, requestlog.ActionForward)
|
||||
}
|
||||
|
||||
// One the rate limits do not apply to is.
|
||||
noMetricsToken.send(s, exempt)
|
||||
blockRule.send(s, exempt)
|
||||
|
||||
line := wrongAdminToken.send(s, exempt)
|
||||
line := blockRule.send(s, exempt)
|
||||
if line.LimitHit != requestlog.LimitHitErrorBurst {
|
||||
t.Errorf("log line has limit_hit %q, want error_burst", line.LimitHit)
|
||||
}
|
||||
@@ -336,7 +307,6 @@ func TestObserveModeLogsAndAlertsTheErrorBurst(t *testing.T) {
|
||||
mode: observe,
|
||||
errorBurstThreshold: "1",
|
||||
rulesDir: writeRules(t, testRules),
|
||||
adminToken: adminSecret,
|
||||
})
|
||||
start := clk.Now()
|
||||
held := bans.Ban{
|
||||
@@ -347,10 +317,11 @@ func TestObserveModeLogsAndAlertsTheErrorBurst(t *testing.T) {
|
||||
}
|
||||
server.Ledger.Load([]bans.Ban{held})
|
||||
|
||||
// Under a ban, enforce mode would have refused these before the
|
||||
// endpoint, so their tokens are not counted.
|
||||
// Under a ban, enforce mode would have refused these before the rule, so
|
||||
// they are not counted.
|
||||
for range 2 {
|
||||
line := wrongAdminToken.send(s, otherClient)
|
||||
line := s.request(otherClient, blockedPath, http.StatusOK,
|
||||
requestlog.ActionForward)
|
||||
wantWouldAction(t, line, requestlog.ActionBanned)
|
||||
|
||||
if line.LimitHit != "" {
|
||||
@@ -360,7 +331,7 @@ func TestObserveModeLogsAndAlertsTheErrorBurst(t *testing.T) {
|
||||
|
||||
// The block rule's refusal, which enforce mode would have answered 403,
|
||||
// is the second of the client's, and would have banned it.
|
||||
wrongAdminToken.send(s, client)
|
||||
s.request(client, blockedPath, http.StatusOK, requestlog.ActionForward)
|
||||
|
||||
line := s.request(client, blockedPath, http.StatusOK, requestlog.ActionForward)
|
||||
wantWouldAction(t, line, requestlog.ActionRuleBlocked)
|
||||
|
||||
@@ -2,24 +2,18 @@ package proxy_test
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
|
||||
"sneak.berlin/go/smallwebwaf/internal/proxy"
|
||||
"sneak.berlin/go/smallwebwaf/internal/requestlog"
|
||||
)
|
||||
|
||||
func TestHealthEndpointIsAnsweredBeforeAnyCheck(t *testing.T) {
|
||||
func TestHealthEndpointCountsTowardNoLimit(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var calls atomic.Int32
|
||||
|
||||
app := startApp(t, func(http.ResponseWriter, *http.Request) {
|
||||
calls.Add(1)
|
||||
})
|
||||
// With a limit of one request a minute, any request counted before
|
||||
// the last one would have it refused.
|
||||
addr, out := startProxy(t, app.URL, map[string]string{rateLimitPerMinute: "1"})
|
||||
s, _, _ := startWithClock(t, "", map[string]string{rateLimitPerMinute: "1"})
|
||||
|
||||
const (
|
||||
healthChecks = 3
|
||||
@@ -27,8 +21,7 @@ func TestHealthEndpointIsAnsweredBeforeAnyCheck(t *testing.T) {
|
||||
)
|
||||
|
||||
for range healthChecks {
|
||||
got := get(t, addr, proxy.HealthPath)
|
||||
wantStatus(t, got, http.StatusOK)
|
||||
got := s.adminRequest("", http.MethodGet, proxy.HealthPath, "", http.StatusOK)
|
||||
|
||||
if string(got.body) != "ok\n" || got.header.Get("Content-Type") != contentType {
|
||||
t.Errorf("health endpoint answered %q with Content-Type %q, want ok "+
|
||||
@@ -36,21 +29,12 @@ func TestHealthEndpointIsAnsweredBeforeAnyCheck(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
wantStatus(t, get(t, addr, "/"), http.StatusOK)
|
||||
|
||||
lines := out.requestLines(t, healthChecks+1)
|
||||
for _, line := range lines[:healthChecks] {
|
||||
wantLine(t, line, http.StatusOK, requestlog.ActionAdmin)
|
||||
s.get(client, http.StatusOK, requestlog.ActionForward)
|
||||
|
||||
for _, line := range s.out.endedLines(t, healthChecks)[:healthChecks] {
|
||||
if line.ResponseContentType != contentType {
|
||||
t.Errorf("health check's log line has response_content_type %q, "+
|
||||
"want %q", line.ResponseContentType, contentType)
|
||||
}
|
||||
}
|
||||
|
||||
wantLine(t, lines[healthChecks], http.StatusOK, requestlog.ActionForward)
|
||||
|
||||
if calls.Load() != 1 {
|
||||
t.Errorf("the app was called %d times, want once", calls.Load())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -86,7 +86,7 @@ func TestHistoryCountsTheBodiesEachWay(t *testing.T) {
|
||||
|
||||
got := do(t, newRequest(t, http.MethodPost, addr, "/", strings.NewReader("abc")))
|
||||
wantStatus(t, got, http.StatusOK)
|
||||
out.requestLine(t)
|
||||
out.endedLine(t)
|
||||
|
||||
history := historyOf(t, server, localhost)
|
||||
if history.RequestBytes != 3 || history.ResponseBytes != 5 {
|
||||
@@ -95,38 +95,33 @@ func TestHistoryCountsTheBodiesEachWay(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestHealthEndpointIsNotInTheHistory(t *testing.T) {
|
||||
func TestOwnEndpointsAreNotInTheHistory(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
app := startApp(t, func(http.ResponseWriter, *http.Request) {})
|
||||
addr, out, server := startProxyWithClock(t, app.URL, "", time.Now, nil)
|
||||
s, _, server := startWithClock(t, "", map[string]string{metricsToken: token})
|
||||
|
||||
wantStatus(t, get(t, addr, proxy.HealthPath), http.StatusOK)
|
||||
out.requestLine(t)
|
||||
s.adminRequest("", http.MethodGet, proxy.HealthPath, "", http.StatusOK)
|
||||
s.adminRequest("", http.MethodGet, proxy.MetricsPath, "", http.StatusUnauthorized)
|
||||
s.scrape()
|
||||
|
||||
if clients := server.Limiter.Snapshot(); len(clients) != 0 {
|
||||
t.Errorf("the table holds %+v, want no client", clients)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRequestForSmallwebwafIsRefusedOnlyWithoutTheToken(t *testing.T) {
|
||||
func TestPathUnderTheOwnPrefixIsNeitherForwardedNorRefused(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
app := startApp(t, func(http.ResponseWriter, *http.Request) {})
|
||||
addr, out, server := startProxyWithClock(t, app.URL, "", time.Now,
|
||||
map[string]string{metricsToken: token})
|
||||
addr, out, server := startProxyWithClock(t, app.URL, "", time.Now, nil)
|
||||
|
||||
// The metrics and the 404 are neither forwarded nor refused; the 401
|
||||
// is refused.
|
||||
scrape(t, addr)
|
||||
wantStatus(t, get(t, addr, "/_smallwebwaf/nothing"), http.StatusNotFound)
|
||||
wantStatus(t, get(t, addr, proxy.MetricsPath), http.StatusUnauthorized)
|
||||
out.requestLines(t, 3)
|
||||
out.endedLine(t)
|
||||
|
||||
history := historyOf(t, server, localhost)
|
||||
if history.Requests != 3 || history.Forwarded != 0 || history.Refused != 1 {
|
||||
if history.Requests != 1 || history.Forwarded != 0 || history.Refused != 0 {
|
||||
t.Errorf("history counts %d requests, %d forwarded and %d refused, "+
|
||||
"want 3, 0 and 1", history.Requests, history.Forwarded, history.Refused)
|
||||
"want 1, 0 and 0", history.Requests, history.Forwarded, history.Refused)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,162 +0,0 @@
|
||||
package proxy_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
|
||||
"sneak.berlin/go/smallwebwaf/internal/requestlog"
|
||||
)
|
||||
|
||||
// sizeLimit is the size limit the tests set, 1K as a setting.
|
||||
const (
|
||||
sizeLimit = 1 << 10
|
||||
sizeLimitSetting = "1K"
|
||||
)
|
||||
|
||||
func TestRequestBodyLimit(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
size int
|
||||
// announced sends the size in Content-Length; otherwise the body
|
||||
// is sent in chunks with no length given.
|
||||
announced bool
|
||||
want int
|
||||
action string
|
||||
// refusedBeforeApp is a refusal before anything reaches the app.
|
||||
// A body over the limit with no length given has already partly
|
||||
// reached the app when it is refused.
|
||||
refusedBeforeApp bool
|
||||
}{
|
||||
{"announced, over the limit", 2 * sizeLimit, true,
|
||||
http.StatusRequestEntityTooLarge, requestlog.ActionTooLarge, true},
|
||||
{"announced, at the limit", sizeLimit, true,
|
||||
http.StatusOK, requestlog.ActionForward, false},
|
||||
{"not announced, over the limit", 4 * sizeLimit, false,
|
||||
http.StatusRequestEntityTooLarge, requestlog.ActionTooLarge, false},
|
||||
{"not announced, at the limit", sizeLimit, false,
|
||||
http.StatusOK, requestlog.ActionForward, false},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var calls atomic.Int32
|
||||
|
||||
app := startApp(t, func(_ http.ResponseWriter, r *http.Request) {
|
||||
calls.Add(1)
|
||||
|
||||
_, _ = io.Copy(io.Discard, r.Body)
|
||||
})
|
||||
addr, out := startProxy(t, app.URL, map[string]string{
|
||||
requestMaxBytes: sizeLimitSetting,
|
||||
metricsToken: token,
|
||||
})
|
||||
|
||||
var body io.Reader = bytes.NewReader(make([]byte, tc.size))
|
||||
if !tc.announced {
|
||||
body = io.MultiReader(body) // hides the length
|
||||
}
|
||||
|
||||
wantStatus(t, do(t, newRequest(t, http.MethodPost, addr, "/upload", body)),
|
||||
tc.want)
|
||||
wantLine(t, out.requestLine(t), tc.want, tc.action)
|
||||
|
||||
hits := 0
|
||||
if tc.action == requestlog.ActionTooLarge {
|
||||
hits = 1
|
||||
}
|
||||
|
||||
wantLimitHits(t, addr, requestMaxBytes, hits)
|
||||
|
||||
if tc.refusedBeforeApp && calls.Load() != 0 {
|
||||
t.Errorf("the app was called %d times, want never", calls.Load())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestResponseBodyLimit(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
size int
|
||||
// announced sends the size in Content-Length; otherwise the body
|
||||
// is sent in chunks with no length given.
|
||||
announced bool
|
||||
want int
|
||||
action string
|
||||
// received is how much of a body the client gets, and cutOff
|
||||
// whether the connection is then cut.
|
||||
received int
|
||||
cutOff bool
|
||||
}{
|
||||
{"announced, over the limit", 2 * sizeLimit, true, http.StatusBadGateway,
|
||||
requestlog.ActionTooLarge, len("Bad Gateway\n"), false},
|
||||
{"announced, at the limit", sizeLimit, true, http.StatusOK,
|
||||
requestlog.ActionForward, sizeLimit, false},
|
||||
{"not announced, over the limit", 4 * sizeLimit, false, http.StatusOK,
|
||||
requestlog.ActionTooLarge, sizeLimit, true},
|
||||
{"not announced, at the limit", sizeLimit, false, http.StatusOK,
|
||||
requestlog.ActionForward, sizeLimit, false},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
app := startApp(t, func(w http.ResponseWriter, _ *http.Request) {
|
||||
answerWithSize(w, tc.size, tc.announced)
|
||||
})
|
||||
addr, out := startProxy(t, app.URL, map[string]string{
|
||||
responseMaxBytes: sizeLimitSetting,
|
||||
metricsToken: token,
|
||||
})
|
||||
|
||||
got := get(t, addr, "/download")
|
||||
wantStatus(t, got, tc.want)
|
||||
|
||||
if len(got.body) != tc.received ||
|
||||
errors.Is(got.err, io.ErrUnexpectedEOF) != tc.cutOff {
|
||||
t.Errorf("client got %d bytes (%v), want %d",
|
||||
len(got.body), got.err, tc.received)
|
||||
}
|
||||
|
||||
line := out.requestLine(t)
|
||||
wantLine(t, line, tc.want, tc.action)
|
||||
|
||||
if line.UpstreamStatus != http.StatusOK {
|
||||
t.Errorf("log line has upstream_status %d", line.UpstreamStatus)
|
||||
}
|
||||
|
||||
hits := 0
|
||||
if tc.action == requestlog.ActionTooLarge {
|
||||
hits = 1
|
||||
}
|
||||
|
||||
wantLimitHits(t, addr, responseMaxBytes, hits)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// answerWithSize answers with a body of size bytes, announced in
|
||||
// Content-Length or sent in chunks with no length given.
|
||||
func answerWithSize(w http.ResponseWriter, size int, announced bool) {
|
||||
body := make([]byte, size)
|
||||
if announced {
|
||||
w.Header().Set("Content-Length", strconv.Itoa(size))
|
||||
_, _ = w.Write(body)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// Sending part of it before the end keeps Go's server from working
|
||||
// out the length.
|
||||
_, _ = w.Write(body[:size/2])
|
||||
_ = http.NewResponseController(w).Flush()
|
||||
_, _ = w.Write(body[size/2:])
|
||||
}
|
||||
@@ -9,11 +9,10 @@ import (
|
||||
"sneak.berlin/go/smallwebwaf/internal/lookup"
|
||||
)
|
||||
|
||||
// The headers in which the app is passed the client's AS number and
|
||||
// country while SWWAF_ADD_LOOKUP_HEADERS is set. Go writes every header
|
||||
// name in this form, as it sends it and as it receives it, so X-Client-ASN
|
||||
// arrives as X-Client-Asn, and Del removes a client's own whatever their
|
||||
// case; header names are not case-sensitive.
|
||||
// The headers in which an answer of allow gives rproxy the client's AS number
|
||||
// and country, for the app, while SWWAF_ADD_LOOKUP_HEADERS is set. Go writes
|
||||
// every header name in this form, as it sends it and as it receives it, so
|
||||
// X-Client-ASN arrives as X-Client-Asn; header names are not case-sensitive.
|
||||
const (
|
||||
asnHeader = "X-Client-Asn"
|
||||
countryHeader = "X-Client-Country"
|
||||
@@ -37,7 +36,7 @@ func (rq *request) lookUp(ctx context.Context, now time.Time) {
|
||||
switch {
|
||||
case rq.h.config.LookupSource == "file":
|
||||
rq.lookupAnswer = rq.h.lookupFile.LookUp(client)
|
||||
case rq.h.answersQuestions() && rq.refusedWhateverTheAnswer(now):
|
||||
case rq.refusedWhateverTheAnswer(now):
|
||||
rq.lookupAnswer = rq.h.geojs.LookUpWithoutWaiting(ctx, client)
|
||||
rq.skippedLookupWait = rq.lookupAnswer.Answered.IsZero()
|
||||
default:
|
||||
@@ -60,8 +59,8 @@ func (h *handler) addLookup(answer lookup.Answer) {
|
||||
answer.ASN, answer.ASName, answer.Country)
|
||||
}
|
||||
|
||||
// setLookupHeaders sets the headers in which the app is passed the
|
||||
// client's AS number and country, leaving out one that is unknown.
|
||||
// setLookupHeaders sets the headers that give the client's AS number and
|
||||
// country, leaving out one that is unknown.
|
||||
func setLookupHeaders(header http.Header, asn, country string) {
|
||||
if asn != "" {
|
||||
header.Set(asnHeader, asn)
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
package proxy_test
|
||||
|
||||
import (
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/netip"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
@@ -142,7 +140,7 @@ func TestRequestThatWaitedForItsLookupIsCountedWhenItGoesOn(t *testing.T) {
|
||||
t.Fatalf("first request: %v", err)
|
||||
}
|
||||
|
||||
wantLine(t, out.requestLines(t, 2)[1], http.StatusOK, requestlog.ActionForward)
|
||||
wantLine(t, out.endedLines(t, 2)[1], http.StatusOK, requestlog.ActionForward)
|
||||
|
||||
// The third request's count holds all three: the first was counted
|
||||
// when it went on, in the minute under way, and the second's count
|
||||
@@ -150,7 +148,7 @@ func TestRequestThatWaitedForItsLookupIsCountedWhenItGoesOn(t *testing.T) {
|
||||
clk.advance(500 * time.Millisecond)
|
||||
wantStatus(t, do(t, requestFromDE()), http.StatusOK)
|
||||
|
||||
if got := out.requestLines(t, 3)[2].Counts.Minute; got != 3 {
|
||||
if got := out.endedLines(t, 3)[2].Counts.Minute; got != 3 {
|
||||
t.Errorf("the third request's count in the minute is %v, want 3", got)
|
||||
}
|
||||
}
|
||||
@@ -387,18 +385,15 @@ func TestRequestWaitsAsLongAsTheLookupTimeoutSays(t *testing.T) {
|
||||
// is never reached.
|
||||
const timeout = 3 * time.Second
|
||||
|
||||
server, out, _ := newProxy(t, "http://app.invalid", unansweredGeoJSURL,
|
||||
time.Now, map[string]string{
|
||||
server, out, _ := newServeWAF(t, unansweredGeoJSURL, time.Now,
|
||||
map[string]string{
|
||||
lookupTimeout: timeout.String(),
|
||||
allowedCountries: "DE",
|
||||
})
|
||||
|
||||
req := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/",
|
||||
http.NoBody)
|
||||
req.RemoteAddr = net.JoinHostPort(fromDE, "1234")
|
||||
began := time.Now()
|
||||
|
||||
server.Handler.ServeHTTP(httptest.NewRecorder(), req)
|
||||
serveFromDE(t, server, http.MethodGet, http.NoBody)
|
||||
|
||||
if waited := time.Since(began); waited != timeout {
|
||||
t.Errorf("the request waited %s for its answer, want %s", waited, timeout)
|
||||
|
||||
+47
-161
@@ -7,8 +7,6 @@ import (
|
||||
"net/netip"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -30,41 +28,21 @@ const (
|
||||
func TestMetricsAreOffWhileTheTokenIsUnset(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var calls atomic.Int32
|
||||
|
||||
app := startApp(t, func(http.ResponseWriter, *http.Request) {
|
||||
calls.Add(1)
|
||||
})
|
||||
addr, out := startProxy(t, app.URL, nil)
|
||||
s, _, _ := startWithClock(t, "", nil)
|
||||
|
||||
// An empty token does not match the unset one either.
|
||||
for i, authorization := range []string{bearer, "Bearer ", ""} {
|
||||
req := newRequest(t, http.MethodGet, addr, proxy.MetricsPath, http.NoBody)
|
||||
if authorization != "" {
|
||||
req.Header.Set("Authorization", authorization)
|
||||
}
|
||||
|
||||
wantStatus(t, do(t, req), http.StatusNotFound)
|
||||
wantLine(t, out.requestLines(t, i+1)[i], http.StatusNotFound,
|
||||
requestlog.ActionAdmin)
|
||||
}
|
||||
|
||||
if calls.Load() != 0 {
|
||||
t.Errorf("the app was called %d times, want never", calls.Load())
|
||||
for _, authorization := range []string{bearer, "Bearer ", ""} {
|
||||
s.adminRequest(authorization, http.MethodGet, proxy.MetricsPath, "",
|
||||
http.StatusNotFound)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMetricsNeedTheTokenAndOtherPathsAreNotFound(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var calls atomic.Int32
|
||||
s, _, _ := startWithClock(t, "", map[string]string{metricsToken: token})
|
||||
|
||||
app := startApp(t, func(http.ResponseWriter, *http.Request) {
|
||||
calls.Add(1)
|
||||
})
|
||||
addr, out := startProxy(t, app.URL, map[string]string{metricsToken: token})
|
||||
|
||||
for i, tc := range []struct {
|
||||
for _, tc := range []struct {
|
||||
method, path, authorization string
|
||||
status int
|
||||
}{
|
||||
@@ -81,14 +59,7 @@ func TestMetricsNeedTheTokenAndOtherPathsAreNotFound(t *testing.T) {
|
||||
{http.MethodGet, "/_smallwebwaf/bans", bearer, http.StatusNotFound},
|
||||
{http.MethodPost, proxy.HealthPath, "", http.StatusNotFound},
|
||||
} {
|
||||
req := newRequest(t, tc.method, addr, tc.path, http.NoBody)
|
||||
if tc.authorization != "" {
|
||||
req.Header.Set("Authorization", tc.authorization)
|
||||
}
|
||||
|
||||
got := do(t, req)
|
||||
wantStatus(t, got, tc.status)
|
||||
wantLine(t, out.requestLines(t, i+1)[i], tc.status, requestlog.ActionAdmin)
|
||||
got := s.adminRequest(tc.authorization, tc.method, tc.path, "", tc.status)
|
||||
|
||||
if tc.status == http.StatusUnauthorized &&
|
||||
got.header.Get("WWW-Authenticate") != "Bearer" {
|
||||
@@ -101,58 +72,28 @@ func TestMetricsNeedTheTokenAndOtherPathsAreNotFound(t *testing.T) {
|
||||
t.Errorf("the metrics are\n%s", got.body)
|
||||
}
|
||||
}
|
||||
|
||||
if calls.Load() != 0 {
|
||||
t.Errorf("the app was called %d times, want never", calls.Load())
|
||||
}
|
||||
}
|
||||
|
||||
func TestMetricsAreAskedForThroughTheChecks(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
s, _, _ := startWithClock(t, "", map[string]string{
|
||||
metricsToken: token,
|
||||
rateLimitPerMinute: "1",
|
||||
})
|
||||
|
||||
// Asking for the metrics counts toward the client's limit of one
|
||||
// request a minute, so its next request breaks it, and bans it. A
|
||||
// banned client is refused the metrics too.
|
||||
s.scrape(client)
|
||||
s.get(client, http.StatusForbidden, requestlog.ActionRateLimited)
|
||||
s.requestWithHeader(client, proxy.MetricsPath, "Authorization: "+bearer,
|
||||
http.StatusForbidden, requestlog.ActionBanned)
|
||||
}
|
||||
|
||||
func TestMetricsCountTheTraffic(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
arrived, release := make(chan struct{}), make(chan struct{})
|
||||
app := startApp(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
_, _ = io.Copy(io.Discard, r.Body)
|
||||
|
||||
if r.URL.Path == "/held" {
|
||||
close(arrived)
|
||||
<-release
|
||||
}
|
||||
|
||||
_, _ = io.WriteString(w, "hello")
|
||||
})
|
||||
releaseApp := sync.OnceFunc(func() { close(release) })
|
||||
t.Cleanup(releaseApp)
|
||||
|
||||
addr, out := startProxy(t, app.URL, map[string]string{metricsToken: token})
|
||||
addr, out, server := startProxyWithClock(t, app.URL, "", time.Now,
|
||||
map[string]string{metricsToken: token})
|
||||
|
||||
got := do(t, newRequest(t, http.MethodPost, addr, "/", strings.NewReader("abc")))
|
||||
wantStatus(t, got, http.StatusOK)
|
||||
wantStatus(t, get(t, addr, "/_smallwebwaf/nothing"), http.StatusNotFound)
|
||||
out.requestLines(t, 2)
|
||||
out.endedLines(t, 2)
|
||||
|
||||
forward := `{action="forward",instance="app",status_class="2xx"}`
|
||||
notFound := `{action="admin",instance="app",status_class="4xx"}`
|
||||
|
||||
// The request for the metrics is itself under way.
|
||||
metrics := scrape(t, addr)
|
||||
metrics := scrape(t, server.Addr)
|
||||
wantMetric(t, metrics, "smallwebwaf_requests_total"+forward, 1)
|
||||
wantMetric(t, metrics, "smallwebwaf_requests_total"+notFound, 1)
|
||||
wantMetric(t, metrics, "smallwebwaf_request_bytes_total"+forward, 3)
|
||||
@@ -166,50 +107,20 @@ func TestMetricsCountTheTraffic(t *testing.T) {
|
||||
wantMetric(t, metrics, `smallwebwaf_requests_in_flight{instance="app"}`, 1)
|
||||
metric(t, metrics, `go_goroutines{instance="app"}`)
|
||||
metric(t, metrics, `process_start_time_seconds{instance="app"}`)
|
||||
|
||||
// A request the app holds is under way until it ends.
|
||||
httpClient := newClient(t)
|
||||
held := newRequest(t, http.MethodGet, addr, "/held", http.NoBody)
|
||||
ended := make(chan error, 1)
|
||||
|
||||
go func() {
|
||||
res, err := httpClient.Do(held)
|
||||
if err == nil {
|
||||
err = readAnswer(res).err
|
||||
}
|
||||
|
||||
ended <- err
|
||||
}()
|
||||
|
||||
<-arrived
|
||||
wantMetric(t, scrape(t, addr), `smallwebwaf_requests_in_flight{instance="app"}`, 2)
|
||||
releaseApp()
|
||||
|
||||
err := <-ended
|
||||
if err != nil {
|
||||
t.Fatalf("held request: %v", err)
|
||||
}
|
||||
|
||||
out.requestLines(t, 5)
|
||||
wantMetric(t, scrape(t, addr), `smallwebwaf_requests_in_flight{instance="app"}`, 1)
|
||||
}
|
||||
|
||||
func TestMetricsCountLimitsAndBans(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
const (
|
||||
scraper = "192.0.2.200" // in SWWAF_RATE_LIMIT_EXEMPT_NETS
|
||||
denied = "192.0.2.50" // in SWWAF_DENY_NETS
|
||||
)
|
||||
const denied = "192.0.2.50" // in SWWAF_DENY_NETS
|
||||
|
||||
s, clk, _ := startWithClock(t, "", map[string]string{
|
||||
metricsToken: token,
|
||||
rateLimitPerMinute: "1",
|
||||
rateLimitExemptNets: scraper,
|
||||
denyNets: denied,
|
||||
banResponse: banResponseClose,
|
||||
limitBanDuration: "1h",
|
||||
maxBanDuration: "2h",
|
||||
metricsToken: token,
|
||||
rateLimitPerMinute: "1",
|
||||
denyNets: denied,
|
||||
banResponse: banResponseClose,
|
||||
limitBanDuration: "1h",
|
||||
maxBanDuration: "2h",
|
||||
})
|
||||
|
||||
// SWWAF_BAN_RESPONSE=close sends no status at all.
|
||||
@@ -219,7 +130,7 @@ func TestMetricsCountLimitsAndBans(t *testing.T) {
|
||||
s.get(client, http.StatusOK, requestlog.ActionForward)
|
||||
s.get(client, 0, requestlog.ActionRateLimited)
|
||||
|
||||
metrics := s.scrape(scraper)
|
||||
metrics := s.scrape()
|
||||
wantMetric(t, metrics,
|
||||
`smallwebwaf_requests_total{action="denied",instance="app",status_class="none"}`, 1)
|
||||
wantMetric(t, metrics, `smallwebwaf_rate_limit_hits_total{instance="app",`+
|
||||
@@ -230,38 +141,35 @@ func TestMetricsCountLimitsAndBans(t *testing.T) {
|
||||
wantMetric(t, metrics, `smallwebwaf_permanent_bans{instance="app"}`, 0)
|
||||
|
||||
clk.advance(time.Hour)
|
||||
wantMetric(t, s.scrape(scraper), `smallwebwaf_active_bans{instance="app"}`, 0)
|
||||
wantMetric(t, s.scrape(), `smallwebwaf_active_bans{instance="app"}`, 0)
|
||||
|
||||
// A limit broken again right after would ban for three hours, longer
|
||||
// than SWWAF_MAX_BAN_DURATION, so the ban is permanent.
|
||||
s.get(client, http.StatusOK, requestlog.ActionForward)
|
||||
s.get(client, 0, requestlog.ActionRateLimited)
|
||||
|
||||
metrics = s.scrape(scraper)
|
||||
metrics = s.scrape()
|
||||
wantMetric(t, metrics, `smallwebwaf_rate_limit_hits_total{instance="app",`+
|
||||
`kind="requests",window="minute"}`, 2)
|
||||
wantMetric(t, metrics, `smallwebwaf_offences_total{instance="app",kind="limit"}`, 2)
|
||||
wantMetric(t, metrics, `smallwebwaf_bans_made_total{cause="limit",instance="app"}`, 2)
|
||||
wantMetric(t, metrics, `smallwebwaf_active_bans{instance="app"}`, 1)
|
||||
wantMetric(t, metrics, `smallwebwaf_permanent_bans{instance="app"}`, 1)
|
||||
// denied, client, and the scraper as of its earlier requests.
|
||||
wantMetric(t, metrics, `smallwebwaf_tracked_clients{instance="app"}`, 3)
|
||||
// denied and client.
|
||||
wantMetric(t, metrics, `smallwebwaf_tracked_clients{instance="app"}`, 2)
|
||||
}
|
||||
|
||||
func TestMetricsCountTheBansAnAdminMakes(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
const scraper = "192.0.2.200" // in SWWAF_RATE_LIMIT_EXEMPT_NETS
|
||||
|
||||
s, clk, server := startWithClock(t, "", map[string]string{
|
||||
metricsToken: token,
|
||||
adminToken: adminSecret,
|
||||
rateLimitExemptNets: scraper,
|
||||
metricsToken: token,
|
||||
adminToken: adminSecret,
|
||||
})
|
||||
|
||||
const admins = `smallwebwaf_bans_made_total{cause="admin",instance="app"}`
|
||||
|
||||
wantMetric(t, s.scrape(scraper), admins, 0)
|
||||
wantMetric(t, s.scrape(), admins, 0)
|
||||
|
||||
// As an admin's edit of bans.json that adds a ban is taken in.
|
||||
server.Ledger.LoadEdit([]bans.Ban{{
|
||||
@@ -269,11 +177,11 @@ func TestMetricsCountTheBansAnAdminMakes(t *testing.T) {
|
||||
Start: clk.Now(),
|
||||
}})
|
||||
|
||||
wantMetric(t, s.scrape(scraper), admins, 1)
|
||||
wantMetric(t, s.scrape(), admins, 1)
|
||||
|
||||
// And a ban made through the endpoint.
|
||||
s.admin(http.MethodPost, proxy.BansPath, banOtherClient, http.StatusOK)
|
||||
wantMetric(t, s.scrape(scraper), admins, 2)
|
||||
wantMetric(t, s.scrape(), admins, 2)
|
||||
}
|
||||
|
||||
func TestMetricsByCountryKeepTheBusiestAndCountTheRestAsOther(t *testing.T) {
|
||||
@@ -312,7 +220,7 @@ func TestMetricsByCountryKeepTheBusiestAndCountTheRestAsOther(t *testing.T) {
|
||||
// Each is counted before the next is sent, so that the
|
||||
// countries are ranked in the order sent.
|
||||
lines++
|
||||
out.requestLines(t, lines)
|
||||
out.endedLines(t, lines)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -321,7 +229,7 @@ func TestMetricsByCountryKeepTheBusiestAndCountTheRestAsOther(t *testing.T) {
|
||||
send(fromDE, 2, http.StatusOK)
|
||||
send(fromFR, 1, http.StatusOK)
|
||||
|
||||
metrics := scrape(t, addr)
|
||||
metrics := scrape(t, server.Addr)
|
||||
lines++
|
||||
|
||||
wantMetric(t, metrics,
|
||||
@@ -349,7 +257,7 @@ func TestMetricsByCountryKeepTheBusiestAndCountTheRestAsOther(t *testing.T) {
|
||||
// from then on, and DE's is gone.
|
||||
send(fromFR, 3, http.StatusOK)
|
||||
|
||||
metrics = scrape(t, addr)
|
||||
metrics = scrape(t, server.Addr)
|
||||
wantMetric(t, metrics,
|
||||
`smallwebwaf_country_requests_total{country="KP",instance="app"}`, 3)
|
||||
wantMetric(t, metrics,
|
||||
@@ -383,13 +291,12 @@ func TestMetricsByASNumberKeepTheBusiestAndCountTheRestAsOther(t *testing.T) {
|
||||
answer(fromDE, "AS64501"), answer(fromKP, "AS64502"),
|
||||
})
|
||||
|
||||
// With one AS number of its own, the other is counted as other. The
|
||||
// metrics are asked for from a private address, which has no AS number.
|
||||
// With one AS number of its own, the other is counted as other.
|
||||
s.get(fromDE, http.StatusOK, requestlog.ActionForward)
|
||||
s.get(fromDE, http.StatusOK, requestlog.ActionForward)
|
||||
s.get(fromKP, http.StatusOK, requestlog.ActionForward)
|
||||
|
||||
metrics := s.scrape("10.0.0.9")
|
||||
metrics := s.scrape()
|
||||
wantMetric(t, metrics,
|
||||
`smallwebwaf_asn_requests_total{asn="AS64501",instance="app"}`, 2)
|
||||
wantMetric(t, metrics,
|
||||
@@ -412,11 +319,12 @@ func TestMetricsCountGeoJSRequestsAndFailures(t *testing.T) {
|
||||
t.Cleanup(geojs.Close)
|
||||
|
||||
app := startApp(t, func(http.ResponseWriter, *http.Request) {})
|
||||
addr, _ := startProxyWithGeoJS(t, app.URL, geojs.URL, map[string]string{
|
||||
trustedProxies: trustLocalhost,
|
||||
metricsToken: token,
|
||||
deniedCountries: "kp",
|
||||
})
|
||||
addr, _, server := startProxyWithClock(t, app.URL, geojs.URL, time.Now,
|
||||
map[string]string{
|
||||
trustedProxies: trustLocalhost,
|
||||
metricsToken: token,
|
||||
deniedCountries: "kp",
|
||||
})
|
||||
|
||||
// GeoJS fails, so the client counts as coming from an unknown country,
|
||||
// which SWWAF_DENIED_COUNTRIES does not refuse.
|
||||
@@ -427,13 +335,13 @@ func TestMetricsCountGeoJSRequestsAndFailures(t *testing.T) {
|
||||
// The client stops waiting for GeoJS once SWWAF_LOOKUP_TIMEOUT is up, so
|
||||
// GeoJS's failure can come after its request has ended.
|
||||
deadline := time.Now().Add(waitLimit)
|
||||
metrics := scrape(t, addr)
|
||||
metrics := scrape(t, server.Addr)
|
||||
|
||||
for metric(t, metrics, `smallwebwaf_geojs_failures_total{instance="app"}`) == 0 &&
|
||||
time.Now().Before(deadline) {
|
||||
time.Sleep(pollInterval)
|
||||
|
||||
metrics = scrape(t, addr)
|
||||
metrics = scrape(t, server.Addr)
|
||||
}
|
||||
|
||||
wantMetric(t, metrics, `smallwebwaf_geojs_requests_total{instance="app"}`, 1)
|
||||
@@ -452,8 +360,8 @@ func keptAnswer(addr, country string) lookup.Answer {
|
||||
}
|
||||
}
|
||||
|
||||
// scrape asks smallwebwaf at addr for the metrics, with the token, and
|
||||
// returns them.
|
||||
// scrape asks servewaf at addr for the metrics, with the token, and returns
|
||||
// them.
|
||||
func scrape(t *testing.T, addr string) string {
|
||||
t.Helper()
|
||||
|
||||
@@ -468,15 +376,12 @@ func scrape(t *testing.T, addr string) string {
|
||||
return string(got.body)
|
||||
}
|
||||
|
||||
// scrape asks for the metrics, with the token, from the client at from,
|
||||
// and returns them.
|
||||
func (s *sender) scrape(from string) string {
|
||||
// scrape asks for the metrics, with the token, and returns them.
|
||||
func (s *sender) scrape() string {
|
||||
s.t.Helper()
|
||||
|
||||
_, metrics := s.requestWithHeader(from, proxy.MetricsPath, "Authorization: "+bearer,
|
||||
http.StatusOK, requestlog.ActionAdmin)
|
||||
|
||||
return metrics
|
||||
return string(s.adminRequest(bearer, http.MethodGet, proxy.MetricsPath, "",
|
||||
http.StatusOK).body)
|
||||
}
|
||||
|
||||
// metric returns the value of series in metrics, which are in the
|
||||
@@ -524,22 +429,3 @@ func wantNoSeries(t *testing.T, metrics, series string) {
|
||||
t.Errorf("there is a series %s", series)
|
||||
}
|
||||
}
|
||||
|
||||
// wantLimitHits checks that the metrics of smallwebwaf at addr count hits
|
||||
// requests that passed the size or time limit of the setting limit, with
|
||||
// no series for it when hits is 0.
|
||||
func wantLimitHits(t *testing.T, addr, limit string, hits int) {
|
||||
t.Helper()
|
||||
|
||||
series := `smallwebwaf_size_and_time_limit_hits_total{instance="app",limit="` +
|
||||
limit + `"}`
|
||||
metrics := scrape(t, addr)
|
||||
|
||||
if hits == 0 {
|
||||
wantNoSeries(t, metrics, series)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
wantMetric(t, metrics, series, float64(hits))
|
||||
}
|
||||
|
||||
@@ -1,17 +1,13 @@
|
||||
package proxy_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/netip"
|
||||
"reflect"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"sneak.berlin/go/smallwebwaf/internal/bans"
|
||||
"sneak.berlin/go/smallwebwaf/internal/proxy"
|
||||
"sneak.berlin/go/smallwebwaf/internal/requestlog"
|
||||
)
|
||||
|
||||
@@ -76,11 +72,6 @@ func TestObserveModeForwardsWhatEnforceModeRefuses(t *testing.T) {
|
||||
// Passed to the app, which answered it.
|
||||
line := s.get(sent.from, http.StatusOK, requestlog.ActionForward)
|
||||
wantWouldAction(t, line, sent.refusal)
|
||||
|
||||
if line.UpstreamStatus != http.StatusOK {
|
||||
t.Errorf("log line has upstream_status %d, want 200",
|
||||
line.UpstreamStatus)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -133,62 +124,6 @@ func TestObserveModeMakesNoBanAndKeepsTheBansItHas(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestObserveModeKeepsTheSizeLimitsAndTheToken(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
const denied = "192.0.2.50" // in SWWAF_DENY_NETS
|
||||
|
||||
var calls atomic.Int32
|
||||
|
||||
app := startApp(t, func(w http.ResponseWriter, _ *http.Request) {
|
||||
calls.Add(1)
|
||||
answerWithSize(w, 2*sizeLimit, true)
|
||||
})
|
||||
addr, out := startProxy(t, app.URL, map[string]string{
|
||||
mode: observe,
|
||||
trustedProxies: trustLocalhost,
|
||||
denyNets: denied,
|
||||
requestMaxBytes: sizeLimitSetting,
|
||||
responseMaxBytes: sizeLimitSetting,
|
||||
metricsToken: token,
|
||||
})
|
||||
|
||||
// SWWAF_DENY_NETS would refuse each request; instead a size limit or
|
||||
// the missing token does.
|
||||
for i, tc := range []struct {
|
||||
method, path string
|
||||
body io.Reader
|
||||
status int
|
||||
action string
|
||||
}{
|
||||
{
|
||||
http.MethodPost, "/upload", bytes.NewReader(make([]byte, 2*sizeLimit)),
|
||||
http.StatusRequestEntityTooLarge, requestlog.ActionTooLarge,
|
||||
},
|
||||
{
|
||||
http.MethodGet, "/download", http.NoBody,
|
||||
http.StatusBadGateway, requestlog.ActionTooLarge,
|
||||
},
|
||||
{
|
||||
http.MethodGet, proxy.MetricsPath, http.NoBody,
|
||||
http.StatusUnauthorized, requestlog.ActionAdmin,
|
||||
},
|
||||
} {
|
||||
req := newRequest(t, tc.method, addr, tc.path, tc.body)
|
||||
req.Header.Set(forwardedFor, denied)
|
||||
wantStatus(t, do(t, req), tc.status)
|
||||
|
||||
line := out.requestLines(t, i+1)[i]
|
||||
wantLine(t, line, tc.status, tc.action)
|
||||
wantWouldAction(t, line, requestlog.ActionDenied)
|
||||
}
|
||||
|
||||
// The upload was refused before it reached the app.
|
||||
if calls.Load() != 1 {
|
||||
t.Errorf("the app was called %d times, want once", calls.Load())
|
||||
}
|
||||
}
|
||||
|
||||
// wantWouldAction checks the request log line's would_action, and that a
|
||||
// line that should have none has no such field.
|
||||
func wantWouldAction(t *testing.T, line logLine, want string) {
|
||||
|
||||
@@ -1,556 +0,0 @@
|
||||
package proxy_test
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"reflect"
|
||||
"slices"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"sneak.berlin/go/smallwebwaf/internal/config"
|
||||
"sneak.berlin/go/smallwebwaf/internal/proxy"
|
||||
"sneak.berlin/go/smallwebwaf/internal/ratelimit"
|
||||
"sneak.berlin/go/smallwebwaf/internal/requestlog"
|
||||
)
|
||||
|
||||
// A request target with an escaped slash and space in its path, and a
|
||||
// query with a parameter ReverseProxy cannot parse.
|
||||
const (
|
||||
rawPath = "/some%2Fpath/with%20space"
|
||||
rawQuery = "b=2&a=1&bad=%zz;x"
|
||||
)
|
||||
|
||||
// chunkSize is the size of each part of a body a test sends in parts.
|
||||
const chunkSize = 1 << 10
|
||||
|
||||
var errNotStreamed = errors.New("the first part never reached the app")
|
||||
|
||||
// appSaw is what the app received.
|
||||
type appSaw struct {
|
||||
method string
|
||||
target string
|
||||
header http.Header
|
||||
body []byte
|
||||
}
|
||||
|
||||
func TestPassesRequestAndAnswerUnchanged(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
requestBody := bytes.Repeat([]byte("request body "), 8000)
|
||||
answerBody := bytes.Repeat([]byte("answer body "), 8000)
|
||||
saw := make(chan appSaw, 1)
|
||||
|
||||
app := startApp(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
body, _ := io.ReadAll(r.Body)
|
||||
saw <- appSaw{r.Method, r.RequestURI, r.Header.Clone(), body}
|
||||
|
||||
w.Header().Set("X-App", "yes")
|
||||
w.Header().Add("Set-Cookie", "a=1")
|
||||
w.Header().Add("Set-Cookie", "b=2")
|
||||
w.WriteHeader(http.StatusTeapot)
|
||||
_, _ = w.Write(answerBody)
|
||||
})
|
||||
addr, out := startProxy(t, app.URL, nil)
|
||||
|
||||
req := newRequest(t, http.MethodPatch, addr, rawPath+"?"+rawQuery,
|
||||
bytes.NewReader(requestBody))
|
||||
req.Header.Add("X-Test", "one")
|
||||
req.Header.Add("X-Test", "two")
|
||||
req.Header.Set("User-Agent", "test-agent")
|
||||
|
||||
got := do(t, req)
|
||||
|
||||
wantAppSaw(t, <-saw, requestBody)
|
||||
wantAnswer(t, got, answerBody)
|
||||
|
||||
line := out.requestLine(t)
|
||||
wantLine(t, line, http.StatusTeapot, requestlog.ActionForward)
|
||||
wantRequestFields(t, line, addr, len(requestBody), len(answerBody))
|
||||
}
|
||||
|
||||
func TestPassesThePathAsSent(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
for _, path := range []string{
|
||||
// An encoded slash and a character Go escapes: |, ^, and é sent
|
||||
// as its UTF-8 bytes.
|
||||
"/a%2Fb|c", "/feat%2Fx/file^1", "/x%2Fy/café",
|
||||
// A path starting with //, which Go would send as a scheme and
|
||||
// host if it were put in URL.Opaque.
|
||||
"//a%2Fb",
|
||||
} {
|
||||
t.Run(path, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
saw := make(chan string, 1)
|
||||
app := startApp(t, func(_ http.ResponseWriter, r *http.Request) {
|
||||
saw <- r.RequestURI
|
||||
})
|
||||
addr, out := startProxy(t, app.URL, nil)
|
||||
|
||||
conn := dial(t, addr)
|
||||
send(t, conn, "GET "+path+"?q=1 HTTP/1.1\r\nHost: "+appHost+"\r\n\r\n")
|
||||
|
||||
got := readResponse(t, conn)
|
||||
if got.status != http.StatusOK {
|
||||
t.Fatalf("status %d, want %d", got.status, http.StatusOK)
|
||||
}
|
||||
|
||||
target := <-saw
|
||||
if target != path+"?q=1" {
|
||||
t.Errorf("app saw %s, want %s", target, path+"?q=1")
|
||||
}
|
||||
|
||||
line := out.requestLine(t)
|
||||
if line.Path != path {
|
||||
t.Errorf("log line has path %s, want %s", line.Path, path)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// wantAppSaw checks that the app received the test's request unchanged.
|
||||
func wantAppSaw(t *testing.T, saw appSaw, body []byte) {
|
||||
t.Helper()
|
||||
|
||||
if saw.method != http.MethodPatch || saw.target != rawPath+"?"+rawQuery {
|
||||
t.Errorf("app saw %s %s, want %s %s", saw.method, saw.target,
|
||||
http.MethodPatch, rawPath+"?"+rawQuery)
|
||||
}
|
||||
|
||||
if !slices.Equal(saw.header.Values("X-Test"), []string{"one", "two"}) {
|
||||
t.Errorf("app saw X-Test %q", saw.header.Values("X-Test"))
|
||||
}
|
||||
|
||||
if saw.header.Get("User-Agent") != "test-agent" {
|
||||
t.Errorf("app saw User-Agent %q", saw.header.Get("User-Agent"))
|
||||
}
|
||||
|
||||
if !bytes.Equal(saw.body, body) {
|
||||
t.Errorf("app saw a body of %d bytes, want the %d sent",
|
||||
len(saw.body), len(body))
|
||||
}
|
||||
}
|
||||
|
||||
// wantAnswer checks that the client received the app's answer unchanged.
|
||||
func wantAnswer(t *testing.T, got answer, body []byte) {
|
||||
t.Helper()
|
||||
|
||||
wantStatus(t, got, http.StatusTeapot)
|
||||
|
||||
if got.header.Get("X-App") != "yes" {
|
||||
t.Errorf("client got X-App %q", got.header.Get("X-App"))
|
||||
}
|
||||
|
||||
if !slices.Equal(got.header.Values("Set-Cookie"), []string{"a=1", "b=2"}) {
|
||||
t.Errorf("client got Set-Cookie %q", got.header.Values("Set-Cookie"))
|
||||
}
|
||||
|
||||
if got.err != nil || !bytes.Equal(got.body, body) {
|
||||
t.Errorf("client got %d bytes (%v), want the %d the app sent",
|
||||
len(got.body), got.err, len(body))
|
||||
}
|
||||
}
|
||||
|
||||
// wantRequestFields checks the log line's fields about the request. Its
|
||||
// time, its id and its timings are checked only for being there.
|
||||
func wantRequestFields(t *testing.T, line logLine, host string, sent, received int) {
|
||||
t.Helper()
|
||||
|
||||
bytes := float64(sent + received)
|
||||
|
||||
want := withTimings(line, requestlog.Line{
|
||||
Type: requestType, Time: line.Time, Instance: "app",
|
||||
ClientIP: localhost, Method: http.MethodPatch, Scheme: plain, Host: host,
|
||||
Path: rawPath, Query: rawQuery, Protocol: protocol,
|
||||
Status: http.StatusTeapot, RequestBytes: int64(sent),
|
||||
ResponseBytes: int64(received), UserAgent: "test-agent",
|
||||
RequestID: line.RequestID, PeerIP: localhost, ClientGroup: localhost + "/32",
|
||||
ContentLength: int64(sent), ResponseContentType: "text/plain; charset=utf-8",
|
||||
UpstreamStatus: http.StatusTeapot, Action: requestlog.ActionForward,
|
||||
Counts: ratelimit.Counts{
|
||||
Minute: 1, Hour: 1, Day: 1,
|
||||
MinuteBytes: bytes, HourBytes: bytes, DayBytes: bytes,
|
||||
},
|
||||
})
|
||||
if !reflect.DeepEqual(line.Line, want) {
|
||||
t.Errorf("log line\n%+v\nwant\n%+v", line.Line, want)
|
||||
}
|
||||
|
||||
_, err := time.Parse(time.RFC3339, line.Time)
|
||||
if err != nil || line.RequestID == "" || line.DurationTotal <= 0 ||
|
||||
line.DurationUpstreamTotal == nil || *line.DurationUpstreamTotal <= 0 {
|
||||
t.Errorf("log line has time %q, request_id %q and durations %v and %v",
|
||||
line.Time, line.RequestID, line.DurationTotal,
|
||||
line.fields["duration_upstream_total"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestAddsNoContentTypeTheAppDidNotSend(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
const typed = "text/plain"
|
||||
|
||||
app := startApp(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path == "/typed" {
|
||||
w.Header().Set("Content-Type", typed)
|
||||
} else {
|
||||
// The key with no value keeps Go's server from adding one.
|
||||
w.Header()["Content-Type"] = nil
|
||||
}
|
||||
|
||||
_, _ = io.WriteString(w, "<!doctype html><title>page</title>")
|
||||
})
|
||||
addr, _ := startProxy(t, app.URL, nil)
|
||||
|
||||
got := get(t, addr, "/")
|
||||
if got.header.Values("Content-Type") != nil {
|
||||
t.Errorf("client got Content-Type %q, want none",
|
||||
got.header.Get("Content-Type"))
|
||||
}
|
||||
|
||||
got = get(t, addr, "/typed")
|
||||
if got.header.Get("Content-Type") != typed {
|
||||
t.Errorf("client got Content-Type %q, want %q",
|
||||
got.header.Get("Content-Type"), typed)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStreamsTheRequestBody(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
chunk := bytes.Repeat([]byte("x"), chunkSize)
|
||||
firstArrived := make(chan struct{})
|
||||
|
||||
app := startApp(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
first := make([]byte, len(chunk))
|
||||
|
||||
_, err := io.ReadFull(r.Body, first)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
close(firstArrived)
|
||||
|
||||
rest, _ := io.ReadAll(r.Body)
|
||||
_, _ = w.Write(rest)
|
||||
})
|
||||
addr, _ := startProxy(t, app.URL, nil)
|
||||
|
||||
body, writer := io.Pipe()
|
||||
|
||||
go func() {
|
||||
_, _ = writer.Write(chunk)
|
||||
|
||||
select {
|
||||
case <-firstArrived:
|
||||
_, _ = writer.Write(chunk)
|
||||
_ = writer.Close()
|
||||
case <-time.After(waitLimit):
|
||||
_ = writer.CloseWithError(errNotStreamed)
|
||||
}
|
||||
}()
|
||||
|
||||
got := do(t, newRequest(t, http.MethodPost, addr, "/upload", body))
|
||||
if got.err != nil || !bytes.Equal(got.body, chunk) {
|
||||
t.Errorf("app read %d bytes after the first part (%v), want %d",
|
||||
len(got.body), got.err, len(chunk))
|
||||
}
|
||||
}
|
||||
|
||||
func TestStreamsTheAnswerBody(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
chunk := bytes.Repeat([]byte("y"), chunkSize)
|
||||
firstArrived := make(chan struct{})
|
||||
|
||||
app := startApp(t, func(w http.ResponseWriter, _ *http.Request) {
|
||||
_, _ = w.Write(chunk)
|
||||
_ = http.NewResponseController(w).Flush()
|
||||
|
||||
select {
|
||||
case <-firstArrived:
|
||||
_, _ = w.Write(chunk)
|
||||
case <-time.After(waitLimit):
|
||||
}
|
||||
})
|
||||
addr, _ := startProxy(t, app.URL, nil)
|
||||
|
||||
req := newRequest(t, http.MethodGet, addr, "/download", http.NoBody)
|
||||
|
||||
res, err := newClient(t).Do(req)
|
||||
if err != nil {
|
||||
t.Fatalf("request: %v", err)
|
||||
}
|
||||
|
||||
first := make([]byte, len(chunk))
|
||||
_, err = io.ReadFull(res.Body, first)
|
||||
|
||||
close(firstArrived)
|
||||
|
||||
got := readAnswer(res)
|
||||
if err != nil || got.err != nil || !bytes.Equal(got.body, chunk) {
|
||||
t.Errorf("client read %d bytes after the first part (%v, %v), want %d",
|
||||
len(got.body), err, got.err, len(chunk))
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpgradedConnectionOutlastsTheTimeouts(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
app := startApp(t, echoAfterUpgrade)
|
||||
addr, out := startProxy(t, app.URL, map[string]string{
|
||||
clientRequestTimeout: shortTimeoutSetting,
|
||||
clientResponseTimeout: shortTimeoutSetting,
|
||||
upstreamRequestTimeout: shortTimeoutSetting,
|
||||
upstreamResponseTimeout: shortTimeoutSetting,
|
||||
})
|
||||
|
||||
conn := dial(t, addr)
|
||||
send(t, conn, "GET /socket HTTP/1.1\r\nHost: app\r\n"+
|
||||
"Connection: Upgrade\r\nUpgrade: websocket\r\n\r\n")
|
||||
|
||||
reader := bufio.NewReader(conn)
|
||||
|
||||
res, err := http.ReadResponse(reader, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("read the answer to the upgrade: %v", err)
|
||||
}
|
||||
|
||||
answered := time.Now()
|
||||
_ = res.Body.Close()
|
||||
|
||||
if res.StatusCode != http.StatusSwitchingProtocols {
|
||||
t.Fatalf("status %d, want %d", res.StatusCode, http.StatusSwitchingProtocols)
|
||||
}
|
||||
|
||||
// Every timeout started before the upgrade was answered, the response
|
||||
// timeouts last, at the end of the request: wait until just past
|
||||
// shortTimeout after the answer was read, then use the connection.
|
||||
time.Sleep(time.Until(answered.Add(shortTimeout + 100*time.Millisecond)))
|
||||
send(t, conn, "still here\n")
|
||||
|
||||
echoed, err := reader.ReadString('\n')
|
||||
if err != nil || echoed != "still here\n" {
|
||||
t.Errorf("echo %q (%v), want %q", echoed, err, "still here\n")
|
||||
}
|
||||
|
||||
_ = conn.Close()
|
||||
|
||||
wantLine(t, out.requestLine(t), http.StatusSwitchingProtocols,
|
||||
requestlog.ActionForward)
|
||||
}
|
||||
|
||||
// echoAfterUpgrade is an app that switches protocols on request, and then
|
||||
// sends back each line it receives.
|
||||
func echoAfterUpgrade(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Header.Get("Upgrade") != "websocket" {
|
||||
http.Error(w, "not an upgrade", http.StatusBadRequest)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
conn, buffered, err := http.NewResponseController(w).Hijack()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
defer func() {
|
||||
_ = conn.Close()
|
||||
}()
|
||||
|
||||
_, _ = buffered.WriteString("HTTP/1.1 101 Switching Protocols\r\n" +
|
||||
"Connection: Upgrade\r\nUpgrade: websocket\r\n\r\n")
|
||||
_ = buffered.Flush()
|
||||
|
||||
for {
|
||||
line, err := buffered.ReadString('\n')
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
_, _ = buffered.WriteString(line)
|
||||
_ = buffered.Flush()
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnswers502WhenTheAppSwitchesToAnotherProtocol(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// answerAfterUpgrade switches to websocket whatever the client asked
|
||||
// for, and ReverseProxy passes on a switch only to the protocol the
|
||||
// client asked for, here "websocket, foo".
|
||||
app := startApp(t, answerAfterUpgrade)
|
||||
addr, out := startProxy(t, app.URL, nil)
|
||||
|
||||
conn := dial(t, addr)
|
||||
send(t, conn, "GET /ws HTTP/1.1\r\nHost: app\r\n"+
|
||||
"Connection: Upgrade\r\nUpgrade: websocket, foo\r\n\r\n")
|
||||
|
||||
wantStatus(t, readResponse(t, conn), http.StatusBadGateway)
|
||||
wantLine(t, out.requestLine(t), http.StatusBadGateway, requestlog.ActionUpstreamError)
|
||||
}
|
||||
|
||||
func TestServerHasTheDefaultLimits(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cfg, err := config.FromEnvironment(func(string) (string, bool) { return "", false })
|
||||
if err != nil {
|
||||
t.Fatalf("default settings: %v", err)
|
||||
}
|
||||
|
||||
server := proxy.New(proxy.Params{
|
||||
Config: cfg,
|
||||
RequestLog: io.Discard,
|
||||
ProcessLog: requestlog.NewProcessLogger(io.Discard, cfg.InstanceName, cfg.LogLevel),
|
||||
})
|
||||
|
||||
if server.Addr != ":8080" || server.MaxHeaderBytes != 28<<10 ||
|
||||
server.IdleTimeout != 2*time.Minute || server.ReadHeaderTimeout != time.Minute {
|
||||
t.Errorf("server listens on %q with header limit %d, idle time %s and "+
|
||||
"header timeout %s", server.Addr, server.MaxHeaderBytes,
|
||||
server.IdleTimeout, server.ReadHeaderTimeout)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRefusesHeadersOverTheLimit(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
env map[string]string
|
||||
limit int
|
||||
}{
|
||||
{"by default", nil, 32 << 10},
|
||||
{"as set", map[string]string{clientHeaderMaxBytes: "8K"}, 8 << 10},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var calls atomic.Int32
|
||||
|
||||
app := startApp(t, func(http.ResponseWriter, *http.Request) {
|
||||
calls.Add(1)
|
||||
})
|
||||
addr, _ := startProxy(t, app.URL, tc.env)
|
||||
|
||||
// size counts every byte of the request: the request line,
|
||||
// the headers and the blank line that ends them.
|
||||
const (
|
||||
start = "GET / HTTP/1.1\r\nHost: app\r\nX-Large: "
|
||||
end = "\r\n\r\n"
|
||||
)
|
||||
|
||||
for _, sent := range []struct{ size, want int }{
|
||||
{tc.limit, http.StatusOK},
|
||||
{tc.limit + 1, http.StatusRequestHeaderFieldsTooLarge},
|
||||
} {
|
||||
conn := dial(t, addr)
|
||||
send(t, conn,
|
||||
start+strings.Repeat("a", sent.size-len(start)-len(end))+end)
|
||||
wantStatus(t, readResponse(t, conn), sent.want)
|
||||
}
|
||||
|
||||
if calls.Load() != 1 {
|
||||
t.Errorf("the app was called %d times, want once", calls.Load())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnswers502WhenTheAppCannotBeReached(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// No test can listen on port 1: listening on port 0 gets one from 32768 up.
|
||||
addr, out := startProxy(t, "http://"+localhost+":1", nil)
|
||||
|
||||
wantStatus(t, get(t, addr, "/"), http.StatusBadGateway)
|
||||
|
||||
line := out.requestLine(t)
|
||||
wantLine(t, line, http.StatusBadGateway, requestlog.ActionUpstreamError)
|
||||
|
||||
// There never was a connection to the app, nor an answer from it.
|
||||
wantTimings(t, line, "duration_total", "duration_checks",
|
||||
"duration_upstream_total")
|
||||
|
||||
logged := slices.ContainsFunc(out.lines(t), func(line map[string]any) bool {
|
||||
return line["type"] == "process" && line["msg"] == "request to the app failed"
|
||||
})
|
||||
if !logged {
|
||||
t.Errorf("no process line says the request to the app failed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLogLevelHoldsBackNoRequestLine(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// At error the warning that the request to the app failed is held back,
|
||||
// and is written before the answer is.
|
||||
addr, out := startProxy(t, "http://"+localhost+":1", map[string]string{
|
||||
"SWWAF_LOG_LEVEL": "error",
|
||||
})
|
||||
|
||||
wantStatus(t, get(t, addr, "/"), http.StatusBadGateway)
|
||||
wantLine(t, out.requestLine(t), http.StatusBadGateway, requestlog.ActionUpstreamError)
|
||||
|
||||
for _, line := range out.lines(t) {
|
||||
if line["type"] == "process" {
|
||||
t.Errorf("process line %v, want none at error", line)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestLogsAnAnswerThatBrokeOff(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
app := startApp(t, func(w http.ResponseWriter, _ *http.Request) {
|
||||
_, _ = io.WriteString(w, "the first part")
|
||||
_ = http.NewResponseController(w).Flush()
|
||||
|
||||
panic(http.ErrAbortHandler) // drops the connection mid-answer
|
||||
})
|
||||
addr, out := startProxy(t, app.URL, nil)
|
||||
|
||||
got := get(t, addr, "/")
|
||||
if string(got.body) != "the first part" || !errors.Is(got.err, io.ErrUnexpectedEOF) {
|
||||
t.Errorf("client read %q (%v), want the first part cut off", got.body, got.err)
|
||||
}
|
||||
|
||||
wantLine(t, out.requestLine(t), http.StatusOK, requestlog.ActionUpstreamError)
|
||||
}
|
||||
|
||||
func TestLogsAClientThatWentAway(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
arrived := make(chan struct{})
|
||||
|
||||
app := startApp(t, func(_ http.ResponseWriter, r *http.Request) {
|
||||
close(arrived)
|
||||
<-r.Context().Done()
|
||||
})
|
||||
addr, out := startProxy(t, app.URL, nil)
|
||||
|
||||
conn := dial(t, addr)
|
||||
send(t, conn, "GET /slow HTTP/1.1\r\nHost: app\r\n\r\n")
|
||||
|
||||
select {
|
||||
case <-arrived:
|
||||
case <-time.After(waitLimit):
|
||||
t.Fatal("the request never reached the app")
|
||||
}
|
||||
|
||||
_ = conn.Close()
|
||||
|
||||
line := out.requestLine(t)
|
||||
if !line.Aborted || line.Status != 0 || line.Action != requestlog.ActionForward {
|
||||
t.Errorf("log line has aborted %v, status %d and action %q, "+
|
||||
"want true, 0 and %q",
|
||||
line.Aborted, line.Status, line.Action, requestlog.ActionForward)
|
||||
}
|
||||
}
|
||||
+43
-148
@@ -1,16 +1,15 @@
|
||||
// Package proxy runs the checks on each request and writes its request log
|
||||
// line. New passes each request it lets through to the app and the app's
|
||||
// answer back, unchanged, within the size and time limits. NewServeWAF instead
|
||||
// answers rproxy's question about each request, and takes rproxy's report once
|
||||
// the visitor's answer has ended, which writes the request a second log line.
|
||||
// Package proxy is servewaf's server. It runs the checks on the request each of
|
||||
// rproxy's questions carries, and answers rproxy what to do with that request. It
|
||||
// takes rproxy's report once the visitor's answer has ended, which counts the
|
||||
// request's bytes and writes its last log line. It answers servewaf's own
|
||||
// endpoints: the health check, the metrics, the bans and the clients. It passes
|
||||
// nothing to an app: internal/rproxy does that.
|
||||
package proxy
|
||||
|
||||
import (
|
||||
"io"
|
||||
"log"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
lru "github.com/hashicorp/golang-lru/v2"
|
||||
@@ -27,18 +26,12 @@ import (
|
||||
"sneak.berlin/go/smallwebwaf/internal/waf"
|
||||
)
|
||||
|
||||
// How smallwebwaf keeps connections to the app open between requests.
|
||||
const (
|
||||
appIdleConns = 100
|
||||
appIdleConnTimeout = 90 * time.Second
|
||||
)
|
||||
|
||||
// adminPrefix starts the path of every request for smallwebwaf itself,
|
||||
// which never reaches the app.
|
||||
// adminPrefix starts the path of each of servewaf's own endpoints. The app never
|
||||
// sees a path under it.
|
||||
const adminPrefix = "/_smallwebwaf/"
|
||||
|
||||
// HealthPath is smallwebwaf's health endpoint, which the container's
|
||||
// health check asks.
|
||||
// HealthPath is the health endpoint of servewaf and of rproxy, which the
|
||||
// container's health check asks.
|
||||
const HealthPath = "/_smallwebwaf/healthz"
|
||||
|
||||
// MetricsPath is where the metrics are, for a request that carries
|
||||
@@ -49,15 +42,15 @@ const MetricsPath = "/_smallwebwaf/metrics"
|
||||
// a client's address, lifts them, with SWWAF_ADMIN_TOKEN.
|
||||
const BansPath = "/_smallwebwaf/bans"
|
||||
|
||||
// ClientsPath is where an admin asks what smallwebwaf knows of a client,
|
||||
// by the client's address after it, with SWWAF_ADMIN_TOKEN.
|
||||
// ClientsPath is where an admin asks what servewaf knows of a client, by the
|
||||
// client's address after it, with SWWAF_ADMIN_TOKEN.
|
||||
const ClientsPath = "/_smallwebwaf/clients/"
|
||||
|
||||
// Params are what New needs.
|
||||
// Params are what NewServeWAF needs.
|
||||
type Params struct {
|
||||
Config *config.Config
|
||||
// RequestLog receives one JSON line per request, and from servewaf a second
|
||||
// when the request's report comes.
|
||||
// RequestLog receives two JSON lines per question, one at its decision and
|
||||
// one when its report comes, and one per request to servewaf's own endpoints.
|
||||
RequestLog io.Writer
|
||||
// ProcessLog receives the process's own messages.
|
||||
ProcessLog *slog.Logger
|
||||
@@ -77,7 +70,7 @@ type Params struct {
|
||||
// Rules are the rule files' rules, which each request is checked
|
||||
// against.
|
||||
Rules *rules.Files
|
||||
// Alerts receive the alert for each ban the proxy makes or makes
|
||||
// Alerts receive the alert for each ban servewaf makes or makes
|
||||
// permanent, for each count over an anomaly threshold, for each request
|
||||
// whose client a blocklist, the CrowdSec decision list, a DNSBL zone or
|
||||
// AbuseIPDB lists, for each request the Core Rule Set scores at or over
|
||||
@@ -87,11 +80,10 @@ type Params struct {
|
||||
Alerts *alerts.Queue
|
||||
}
|
||||
|
||||
// Server is the server smallwebwaf runs, with the parts of the proxy
|
||||
// whose state the state files keep, the lookup database, nil unless
|
||||
// SWWAF_LOOKUP_SOURCE is file, the lists fetched from URLs, which its Run
|
||||
// fetches, the DNSBL zones' verdicts, AbuseIPDB's scores and checks
|
||||
// spent, and the metrics.
|
||||
// Server is the server servewaf runs, with the parts whose state the state
|
||||
// files keep, the lookup database, nil unless SWWAF_LOOKUP_SOURCE is file, the
|
||||
// lists fetched from URLs, which its Run fetches, the DNSBL zones' verdicts,
|
||||
// AbuseIPDB's scores and checks spent, and the metrics.
|
||||
type Server struct {
|
||||
*http.Server
|
||||
|
||||
@@ -106,41 +98,24 @@ type Server struct {
|
||||
Metrics *metrics.Metrics
|
||||
}
|
||||
|
||||
// New returns the server smallwebwaf runs: each request it reads passes
|
||||
// through the proxy. Go's server itself refuses a request line and
|
||||
// headers over SWWAF_CLIENT_REQUEST_HEADER_MAX_BYTES, with 431, closes a
|
||||
// connection idle for SWWAF_CLIENT_IDLE_TIMEOUT, and applies
|
||||
// SWWAF_CLIENT_REQUEST_TIMEOUT while the headers arrive; the proxy
|
||||
// applies the timeouts and size limits from then on.
|
||||
func New(params Params) *Server {
|
||||
h := newHandler(params)
|
||||
|
||||
return h.server(&http.Server{
|
||||
Handler: h,
|
||||
ReadHeaderTimeout: params.Config.ClientRequestTimeout,
|
||||
// Off is an IdleTimeout of 0, which Go's server replaces with
|
||||
// ReadTimeout: no limit, as long as ReadTimeout stays unset.
|
||||
IdleTimeout: params.Config.ClientIdleTimeout,
|
||||
// Go's server reads 4 KiB past MaxHeaderBytes before it
|
||||
// refuses, so the limit a client meets is the setting.
|
||||
MaxHeaderBytes: int(params.Config.ClientRequestHeaderMaxBytes - 4<<10),
|
||||
})
|
||||
}
|
||||
|
||||
// newHandler returns the handler with what every request shares, as the
|
||||
// settings in params make it.
|
||||
func newHandler(params Params) *handler {
|
||||
errorLog := slog.NewLogLogger(params.ProcessLog.Handler(), slog.LevelWarn)
|
||||
m := metrics.New(params.Config.MetricsTopN, params.Config.InstanceName)
|
||||
lists, dnsbl, abuseIPDB := newReputation(params, m)
|
||||
|
||||
awaitingReport, err := lru.New[string, *decision](maxAwaitingReport)
|
||||
if err != nil {
|
||||
panic(err) // New fails only for a size below one
|
||||
}
|
||||
|
||||
h := &handler{
|
||||
config: params.Config,
|
||||
requestLog: params.RequestLog,
|
||||
processLog: params.ProcessLog,
|
||||
errorLog: errorLog,
|
||||
transport: newTransport(),
|
||||
now: params.Now,
|
||||
metrics: m,
|
||||
config: params.Config,
|
||||
requestLog: params.RequestLog,
|
||||
processLog: params.ProcessLog,
|
||||
now: params.Now,
|
||||
metrics: m,
|
||||
awaitingReport: awaitingReport,
|
||||
limiter: ratelimit.New(ratelimit.Limits{
|
||||
PerMinute: params.Config.RateLimitPerMinute,
|
||||
PerHour: params.Config.RateLimitPerHour,
|
||||
@@ -232,7 +207,10 @@ func newReputation(
|
||||
|
||||
// newCoreRuleSet returns the Core Rule Set at SWWAF_WAF_PARANOIA_LEVEL,
|
||||
// without the rules SWWAF_WAF_DISABLED_RULES switches off, reading bodies
|
||||
// up to SWWAF_WAF_BODY_LIMIT, or nil while SWWAF_WAF_MODE is off.
|
||||
// up to SWWAF_WAF_BODY_LIMIT, or nil while SWWAF_WAF_MODE is off. It reads at
|
||||
// most QuestionBodyMaxBytes less one byte: a question's body of
|
||||
// QuestionBodyMaxBytes may be cut short, and the Core Rule Set does not read a
|
||||
// JSON or XML body over its limit.
|
||||
func newCoreRuleSet(cfg *config.Config) *waf.CoreRuleSet {
|
||||
if cfg.WAFMode == config.WAFModeOff {
|
||||
return nil
|
||||
@@ -240,26 +218,24 @@ func newCoreRuleSet(cfg *config.Config) *waf.CoreRuleSet {
|
||||
|
||||
coreRuleSet, err := waf.New(waf.Params{
|
||||
ParanoiaLevel: cfg.WAFParanoiaLevel, DisabledRules: cfg.WAFDisabledRules,
|
||||
BodyLimit: cfg.WAFBodyLimit,
|
||||
BodyLimit: min(cfg.WAFBodyLimit, QuestionBodyMaxBytes-1),
|
||||
})
|
||||
if err != nil {
|
||||
// The Core Rule Set is built in, and the settings cannot break it:
|
||||
// the paranoia level is from 1 to 4, the body limit at most 1G, and
|
||||
// the id of no rule switches nothing off.
|
||||
// the paranoia level is from 1 to 4, and the id of no rule switches
|
||||
// nothing off.
|
||||
panic(err)
|
||||
}
|
||||
|
||||
return coreRuleSet
|
||||
}
|
||||
|
||||
// handler is the proxy. It holds what every request shares; what belongs
|
||||
// to one request is in a request.
|
||||
// handler is servewaf's handler. It holds what every request shares; what
|
||||
// belongs to one request is in a request.
|
||||
type handler struct {
|
||||
config *config.Config
|
||||
requestLog io.Writer
|
||||
processLog *slog.Logger
|
||||
errorLog *log.Logger
|
||||
transport http.RoundTripper
|
||||
now func() time.Time
|
||||
metrics *metrics.Metrics
|
||||
limiter *ratelimit.Limiter
|
||||
@@ -273,88 +249,7 @@ type handler struct {
|
||||
rules *rules.Files
|
||||
coreRuleSet *waf.CoreRuleSet
|
||||
alerts *alerts.Queue
|
||||
// awaitingReport holds, in servewaf, the decision on each answered
|
||||
// question by its id until its report comes, and nil for a question still
|
||||
// being handled.
|
||||
// awaitingReport holds the decision on each answered question by its id
|
||||
// until its report comes, and nil for a question still being handled.
|
||||
awaitingReport *lru.Cache[string, *decision]
|
||||
}
|
||||
|
||||
// newTransport returns what carries requests to the app. It never goes
|
||||
// through a proxy named in the environment, and leaves the app's answers
|
||||
// compressed or not as the app sent them.
|
||||
func newTransport() *http.Transport {
|
||||
return &http.Transport{
|
||||
MaxIdleConns: appIdleConns,
|
||||
MaxIdleConnsPerHost: appIdleConns,
|
||||
IdleConnTimeout: appIdleConnTimeout,
|
||||
DisableCompression: true,
|
||||
}
|
||||
}
|
||||
|
||||
// ServeHTTP handles one request: it works out the client, runs the
|
||||
// checks, passes the request to the app and the answer back within the
|
||||
// limits, or answers it itself if it is for smallwebwaf, and writes the
|
||||
// request's log line.
|
||||
func (h *handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
rq := h.newRequest(w, r)
|
||||
defer rq.finish()
|
||||
|
||||
// The health endpoint is answered at once, before any check, so that
|
||||
// a health checker is never refused. It does not ask the app.
|
||||
if r.Method == http.MethodGet && r.URL.Path == HealthPath {
|
||||
rq.answerHealth()
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// Once the request has ended, before its log line is written. The
|
||||
// last deferred runs first: countRefusal before addToHistory, so that
|
||||
// a broken error burst is in the client's history.
|
||||
defer rq.addToHistory()
|
||||
defer rq.countAnomalies()
|
||||
defer rq.countRefusal()
|
||||
|
||||
refused := rq.check(r.Context())
|
||||
rq.checked = time.Now()
|
||||
|
||||
if refused != nil {
|
||||
rq.answer(*refused)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// A request for smallwebwaf itself is answered where another would be
|
||||
// passed to the app, so that it goes through every check first.
|
||||
if strings.HasPrefix(r.URL.Path, adminPrefix) {
|
||||
rq.answerAdmin()
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// Once the response has ended, before the request is added to its
|
||||
// client's history. Deferred, since ReverseProxy panics to end a
|
||||
// response it cannot finish.
|
||||
defer rq.countBytes()
|
||||
|
||||
rq.forward(r.Context())
|
||||
}
|
||||
|
||||
// server returns the Server of h, which listens at SWWAF_LISTEN_ADDR and
|
||||
// logs through h, with what httpServer sets.
|
||||
func (h *handler) server(httpServer *http.Server) *Server {
|
||||
httpServer.Addr = h.config.ListenAddr
|
||||
httpServer.ErrorLog = h.errorLog
|
||||
|
||||
return &Server{
|
||||
Server: httpServer,
|
||||
Ledger: h.ledger,
|
||||
Limiter: h.limiter,
|
||||
GeoJS: h.geojs,
|
||||
Anomalies: h.anomalies,
|
||||
LookupFile: h.lookupFile,
|
||||
Lists: h.lists,
|
||||
DNSBL: h.dnsbl,
|
||||
AbuseIPDB: h.abuseIPDB,
|
||||
Metrics: h.metrics,
|
||||
}
|
||||
}
|
||||
|
||||
+201
-96
@@ -5,6 +5,7 @@ import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"log/slog"
|
||||
"maps"
|
||||
"net"
|
||||
"net/http"
|
||||
@@ -19,73 +20,68 @@ import (
|
||||
"sneak.berlin/go/smallwebwaf/internal/lookup"
|
||||
"sneak.berlin/go/smallwebwaf/internal/proxy"
|
||||
"sneak.berlin/go/smallwebwaf/internal/requestlog"
|
||||
"sneak.berlin/go/smallwebwaf/internal/rproxy"
|
||||
"sneak.berlin/go/smallwebwaf/internal/rules"
|
||||
)
|
||||
|
||||
const (
|
||||
// shortTimeout is what a test sets a timeout to, to see it run out.
|
||||
// It starts before the test has set up its case, such as an upgrade
|
||||
// or the app's buffers filling, so it is as long as the hold-up of the
|
||||
// test process that wantTimedOut allows: a shorter one can run out
|
||||
// first on a busy host.
|
||||
shortTimeout = waitLimit / 2
|
||||
// longTimeoutSetting is a timeout that does not run out in a test.
|
||||
longTimeoutSetting = "1m"
|
||||
// waitLimit bounds how long a test waits for what should happen.
|
||||
waitLimit = 10 * time.Second
|
||||
// pollInterval is how often a test looks for a log line.
|
||||
pollInterval = 10 * time.Millisecond
|
||||
// localhost is where every test server listens, and so the address
|
||||
// smallwebwaf sees each test's requests come from.
|
||||
// localhost is where every test server listens, and so the address rproxy
|
||||
// sees each test's requests come from.
|
||||
localhost = "127.0.0.1"
|
||||
// requestType is the type that marks a request log line.
|
||||
requestType = "request"
|
||||
// protocol is the protocol of every test's requests.
|
||||
// protocol is the protocol of every question.
|
||||
protocol = "HTTP/1.1"
|
||||
// trustLocalhost trusts the address every test connects from, and a
|
||||
// network for proxies in front of it.
|
||||
trustLocalhost = localhost + "/32,10.0.0.0/8"
|
||||
// appHost is the host every test asks for.
|
||||
appHost = "app.example"
|
||||
// client is the client's address, as a proxy names it.
|
||||
client = "203.0.113.9"
|
||||
// forwardedFor is the header that lists the client and its proxies,
|
||||
// and forwardedProto the one that gives the scheme the client used.
|
||||
forwardedFor = "X-Forwarded-For"
|
||||
forwardedProto = "X-Forwarded-Proto"
|
||||
// secure is the scheme a client reached traefik with, and plain the one
|
||||
// rproxy serves.
|
||||
secure = "https"
|
||||
plain = "http"
|
||||
)
|
||||
|
||||
// shortTimeoutSetting is shortTimeout as a setting's value.
|
||||
//
|
||||
//nolint:gochecknoglobals // a constant cannot call String
|
||||
var shortTimeoutSetting = shortTimeout.String()
|
||||
|
||||
// The settings the tests set.
|
||||
const (
|
||||
clientRequestTimeout = "SWWAF_CLIENT_REQUEST_TIMEOUT"
|
||||
clientHeaderMaxBytes = "SWWAF_CLIENT_REQUEST_HEADER_MAX_BYTES"
|
||||
clientIdleTimeout = "SWWAF_CLIENT_IDLE_TIMEOUT"
|
||||
clientResponseTimeout = "SWWAF_CLIENT_RESPONSE_TIMEOUT"
|
||||
upstreamRequestTimeout = "SWWAF_UPSTREAM_REQUEST_TIMEOUT"
|
||||
upstreamResponseTimeout = "SWWAF_UPSTREAM_RESPONSE_TIMEOUT"
|
||||
mode = "SWWAF_MODE"
|
||||
requestMaxBytes = "SWWAF_REQUEST_MAX_BYTES"
|
||||
responseMaxBytes = "SWWAF_RESPONSE_MAX_BYTES"
|
||||
trustedProxies = "SWWAF_TRUSTED_PROXIES"
|
||||
ipv6GroupPrefix = "SWWAF_IPV6_GROUP_PREFIX"
|
||||
maxTrackedClients = "SWWAF_MAX_TRACKED_CLIENTS"
|
||||
allowNets = "SWWAF_ALLOW_NETS"
|
||||
rateLimitExemptNets = "SWWAF_RATE_LIMIT_EXEMPT_NETS"
|
||||
denyNets = "SWWAF_DENY_NETS"
|
||||
rateLimitPerMinute = "SWWAF_RATE_LIMIT_PER_MINUTE"
|
||||
rateLimitPerDay = "SWWAF_RATE_LIMIT_PER_DAY"
|
||||
rateLimitExemptPaths = "SWWAF_RATE_LIMIT_EXEMPT_PATHS"
|
||||
lookupSource = "SWWAF_LOOKUP_SOURCE"
|
||||
lookupDBPath = "SWWAF_LOOKUP_DB_PATH"
|
||||
lookupTimeout = "SWWAF_LOOKUP_TIMEOUT"
|
||||
addLookupHeaders = "SWWAF_ADD_LOOKUP_HEADERS"
|
||||
deniedCountries = "SWWAF_DENIED_COUNTRIES"
|
||||
allowedCountries = "SWWAF_EXCLUSIVELY_ALLOWED_COUNTRIES"
|
||||
banResponse = "SWWAF_BAN_RESPONSE"
|
||||
limitBanDuration = "SWWAF_LIMIT_BAN_DURATION"
|
||||
limitBanRepeatWindow = "SWWAF_LIMIT_BAN_REPEAT_WINDOW"
|
||||
maxBanDuration = "SWWAF_MAX_BAN_DURATION"
|
||||
maxBans = "SWWAF_MAX_BANS"
|
||||
banScopeV4Prefix = "SWWAF_BAN_SCOPE_V4_PREFIX"
|
||||
instanceName = "SWWAF_INSTANCE_NAME"
|
||||
logRequestHeaders = "SWWAF_LOG_REQUEST_HEADERS"
|
||||
attackBanDuration = "SWWAF_ATTACK_BAN_DURATION"
|
||||
rulesDir = "SWWAF_RULES_DIR"
|
||||
wafMode = "SWWAF_WAF_MODE"
|
||||
mode = "SWWAF_MODE"
|
||||
trustedProxies = "SWRP_TRUSTED_PROXIES"
|
||||
ipv6GroupPrefix = "SWWAF_IPV6_GROUP_PREFIX"
|
||||
maxTrackedClients = "SWWAF_MAX_TRACKED_CLIENTS"
|
||||
allowNets = "SWWAF_ALLOW_NETS"
|
||||
rateLimitExemptNets = "SWWAF_RATE_LIMIT_EXEMPT_NETS"
|
||||
denyNets = "SWWAF_DENY_NETS"
|
||||
rateLimitPerMinute = "SWWAF_RATE_LIMIT_PER_MINUTE"
|
||||
rateLimitPerDay = "SWWAF_RATE_LIMIT_PER_DAY"
|
||||
rateLimitExemptPaths = "SWWAF_RATE_LIMIT_EXEMPT_PATHS"
|
||||
lookupSource = "SWWAF_LOOKUP_SOURCE"
|
||||
lookupDBPath = "SWWAF_LOOKUP_DB_PATH"
|
||||
lookupTimeout = "SWWAF_LOOKUP_TIMEOUT"
|
||||
addLookupHeaders = "SWWAF_ADD_LOOKUP_HEADERS"
|
||||
deniedCountries = "SWWAF_DENIED_COUNTRIES"
|
||||
allowedCountries = "SWWAF_EXCLUSIVELY_ALLOWED_COUNTRIES"
|
||||
banResponse = "SWWAF_BAN_RESPONSE"
|
||||
limitBanDuration = "SWWAF_LIMIT_BAN_DURATION"
|
||||
limitBanRepeatWindow = "SWWAF_LIMIT_BAN_REPEAT_WINDOW"
|
||||
maxBanDuration = "SWWAF_MAX_BAN_DURATION"
|
||||
maxBans = "SWWAF_MAX_BANS"
|
||||
banScopeV4Prefix = "SWWAF_BAN_SCOPE_V4_PREFIX"
|
||||
instanceName = "SWWAF_INSTANCE_NAME"
|
||||
logRequestHeaders = "SWWAF_LOG_REQUEST_HEADERS"
|
||||
attackBanDuration = "SWWAF_ATTACK_BAN_DURATION"
|
||||
rulesDir = "SWWAF_RULES_DIR"
|
||||
wafMode = "SWWAF_WAF_MODE"
|
||||
)
|
||||
|
||||
// off is the value that switches a setting off.
|
||||
@@ -179,6 +175,73 @@ func (o *output) requestLine(t *testing.T) logLine {
|
||||
return o.requestLines(t, 1)[0]
|
||||
}
|
||||
|
||||
// endedLines waits for count requests that went through rproxy, or to servewaf's
|
||||
// own endpoints, to have ended, and returns the last log line of each, in the
|
||||
// order servewaf took them. A question's last line is the one its report writes.
|
||||
func (o *output) endedLines(t *testing.T, count int) []logLine {
|
||||
t.Helper()
|
||||
|
||||
deadline := time.Now().Add(waitLimit)
|
||||
for time.Now().Before(deadline) {
|
||||
found := o.lastLines(t)
|
||||
|
||||
ended := len(found) >= count
|
||||
for _, line := range found[:min(count, len(found))] {
|
||||
if line.ServeWAFLine == requestlog.ServeWAFLineDecision {
|
||||
ended = false
|
||||
}
|
||||
}
|
||||
|
||||
if ended {
|
||||
return found
|
||||
}
|
||||
|
||||
time.Sleep(pollInterval)
|
||||
}
|
||||
|
||||
t.Fatalf("fewer than %d requests have ended after %s", count, waitLimit)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// lastLines returns the last request log line written so far of each request,
|
||||
// in the order servewaf took them.
|
||||
func (o *output) lastLines(t *testing.T) []logLine {
|
||||
t.Helper()
|
||||
|
||||
var found []logLine
|
||||
|
||||
places := map[string]int{}
|
||||
|
||||
for _, fields := range o.lines(t) {
|
||||
if fields["type"] != requestType {
|
||||
continue
|
||||
}
|
||||
|
||||
line := decodeLine(t, fields)
|
||||
|
||||
place, seen := places[line.RequestID]
|
||||
if seen {
|
||||
found[place] = line
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
places[line.RequestID] = len(found)
|
||||
found = append(found, line)
|
||||
}
|
||||
|
||||
return found
|
||||
}
|
||||
|
||||
// endedLine waits for a test's one request to have ended, and returns its last
|
||||
// log line.
|
||||
func (o *output) endedLine(t *testing.T) logLine {
|
||||
t.Helper()
|
||||
|
||||
return o.endedLines(t, 1)[0]
|
||||
}
|
||||
|
||||
// decodeLine reads a request log line's fields into a logLine.
|
||||
func decodeLine(t *testing.T, fields map[string]any) logLine {
|
||||
t.Helper()
|
||||
@@ -198,7 +261,7 @@ func decodeLine(t *testing.T, fields map[string]any) logLine {
|
||||
return line
|
||||
}
|
||||
|
||||
// startApp starts app as the app smallwebwaf passes requests to.
|
||||
// startApp starts app as the app rproxy passes requests to.
|
||||
func startApp(t *testing.T, app http.HandlerFunc) *httptest.Server {
|
||||
t.Helper()
|
||||
|
||||
@@ -208,9 +271,9 @@ func startApp(t *testing.T, app http.HandlerFunc) *httptest.Server {
|
||||
return server
|
||||
}
|
||||
|
||||
// startProxy starts smallwebwaf in front of the app at appURL, with the
|
||||
// settings in env on top of the defaults, and returns where it listens and
|
||||
// what it writes.
|
||||
// startProxy starts servewaf, and rproxy in front of the app at appURL, each with
|
||||
// the settings in env on top of the defaults, and returns where rproxy listens
|
||||
// and what servewaf writes.
|
||||
func startProxy(t *testing.T, appURL string, env map[string]string) (string, *output) {
|
||||
t.Helper()
|
||||
|
||||
@@ -230,10 +293,10 @@ func startProxyWithGeoJS(
|
||||
}
|
||||
|
||||
// startProxyWithClock is startProxyWithGeoJS with requests counted and
|
||||
// bans made by the time now tells, and returns the server as well. Unless
|
||||
// env sets SWWAF_RULES_DIR, it is an empty directory, of no rules, and
|
||||
// unless it sets SWWAF_INSTANCE_NAME, that is app, the label instance of
|
||||
// every metric.
|
||||
// bans made by the time now tells, and returns servewaf's server as well, whose
|
||||
// Addr is where servewaf listens. Unless env sets SWWAF_RULES_DIR, it is an empty
|
||||
// directory, of no rules, and unless it sets SWWAF_INSTANCE_NAME, that is app,
|
||||
// the label instance of every metric.
|
||||
func startProxyWithClock(
|
||||
t *testing.T, appURL, geojsURL string, now func() time.Time,
|
||||
env map[string]string,
|
||||
@@ -246,14 +309,70 @@ func startProxyWithClock(
|
||||
}
|
||||
|
||||
// startProxyWithAlerts is startProxyWithClock, and returns the queue of
|
||||
// the alerts the proxy raises as well, as newProxy makes them.
|
||||
// the alerts servewaf raises as well, as newServeWAF makes them.
|
||||
func startProxyWithAlerts(
|
||||
t *testing.T, appURL, geojsURL string, now func() time.Time,
|
||||
env map[string]string,
|
||||
) (string, *output, *proxy.Server, *alerts.Queue) {
|
||||
t.Helper()
|
||||
|
||||
server, out, alertQueue := newProxy(t, appURL, geojsURL, now, env)
|
||||
server, out, alertQueue := newServeWAF(t, geojsURL, now, env)
|
||||
serve(t, server)
|
||||
|
||||
return startRProxy(t, server.Addr, appURL, env), out, server, alertQueue
|
||||
}
|
||||
|
||||
// serve serves server on a port of its own until the test ends, and sets its
|
||||
// Addr to where it listens.
|
||||
func serve(t *testing.T, server *proxy.Server) {
|
||||
t.Helper()
|
||||
|
||||
listener, err := (&net.ListenConfig{}).Listen(t.Context(), "tcp", localhost+":0")
|
||||
if err != nil {
|
||||
t.Fatalf("listen: %v", err)
|
||||
}
|
||||
|
||||
server.Addr = listener.Addr().String()
|
||||
|
||||
go func() {
|
||||
_ = server.Serve(listener)
|
||||
}()
|
||||
|
||||
t.Cleanup(func() {
|
||||
_ = server.Close()
|
||||
})
|
||||
}
|
||||
|
||||
// startRProxy starts rproxy in front of the app at appURL, asking the servewaf
|
||||
// at serveWAFAddr, with the settings in env on top of the defaults, and returns
|
||||
// where it listens. What it writes is dropped. SWRP_TIMEOUT_MS is a minute unless
|
||||
// env sets it, so that on a busy host SWRP_DEFAULT_DISPOSITION does not decide.
|
||||
func startRProxy(
|
||||
t *testing.T, serveWAFAddr, appURL string, env map[string]string,
|
||||
) string {
|
||||
t.Helper()
|
||||
|
||||
settings := map[string]string{
|
||||
"SWRP_WAF_BACKEND": "http://" + serveWAFAddr,
|
||||
"SWRP_UPSTREAM_URL": appURL,
|
||||
"SWRP_TIMEOUT_MS": "60000",
|
||||
}
|
||||
maps.Copy(settings, env)
|
||||
|
||||
cfg, err := config.RProxyFromEnvironment(func(name string) (string, bool) {
|
||||
value, ok := settings[name]
|
||||
|
||||
return value, ok
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("settings %v: %v", settings, err)
|
||||
}
|
||||
|
||||
server := rproxy.New(rproxy.Params{
|
||||
Config: cfg,
|
||||
RequestLog: io.Discard,
|
||||
ProcessLog: requestlog.NewProcessLogger(io.Discard, "rproxy", slog.LevelInfo),
|
||||
})
|
||||
|
||||
listener, err := (&net.ListenConfig{}).Listen(t.Context(), "tcp", localhost+":0")
|
||||
if err != nil {
|
||||
@@ -268,38 +387,35 @@ func startProxyWithAlerts(
|
||||
_ = server.Close()
|
||||
})
|
||||
|
||||
return listener.Addr().String(), out, server, alertQueue
|
||||
return listener.Addr().String()
|
||||
}
|
||||
|
||||
// newProxy makes the server startProxyWithClock starts, without starting
|
||||
// it, and returns it, what it writes, and the queue of the alerts the
|
||||
// proxy raises, as the settings in env make it. No alert is sent from the
|
||||
// queue: they wait in it, for the test to look at. With no geojsURL, there
|
||||
// is no stand-in for GeoJS to look clients up at, and SWWAF_LOOKUP_SOURCE
|
||||
// is off unless env sets it. While it is file, the lookup database
|
||||
// SWWAF_LOOKUP_DB_PATH names is read. Clients are checked with AbuseIPDB
|
||||
// at abuseIPDBURL while env sets SWWAF_ABUSEIPDB_KEY. SWWAF_WAF_MODE is off
|
||||
// unless env sets it, so that only the tests of the Core Rule Set have
|
||||
// their requests inspected by it.
|
||||
func newProxy(
|
||||
t *testing.T, appURL, geojsURL string, now func() time.Time,
|
||||
env map[string]string,
|
||||
// newServeWAF makes servewaf's server, without starting it, and returns it, what
|
||||
// it writes, and the queue of the alerts it raises, as the settings in env make
|
||||
// it. No alert is sent from the queue: they wait in it, for the test to look at.
|
||||
// With no geojsURL, there is no stand-in for GeoJS to look clients up at, and
|
||||
// SWWAF_LOOKUP_SOURCE is off unless env sets it. While it is file, the lookup
|
||||
// database SWWAF_LOOKUP_DB_PATH names is read. Clients are checked with AbuseIPDB
|
||||
// at abuseIPDBURL while env sets SWWAF_ABUSEIPDB_KEY. SWWAF_WAF_MODE is off unless
|
||||
// env sets it, so that only the tests of the Core Rule Set have their requests
|
||||
// inspected by it.
|
||||
func newServeWAF(
|
||||
t *testing.T, geojsURL string, now func() time.Time, env map[string]string,
|
||||
) (*proxy.Server, *output, *alerts.Queue) {
|
||||
t.Helper()
|
||||
|
||||
return newServerWith(t, proxy.New, appURL, geojsURL, now, env)
|
||||
return newServeWAFWith(t, proxy.NewServeWAF, geojsURL, now, env)
|
||||
}
|
||||
|
||||
// newServerWith is newProxy for the server build makes.
|
||||
func newServerWith(
|
||||
// newServeWAFWith is newServeWAF for the server build makes.
|
||||
func newServeWAFWith(
|
||||
t *testing.T, build func(proxy.Params) *proxy.Server,
|
||||
appURL, geojsURL string, now func() time.Time, env map[string]string,
|
||||
geojsURL string, now func() time.Time, env map[string]string,
|
||||
) (*proxy.Server, *output, *alerts.Queue) {
|
||||
t.Helper()
|
||||
|
||||
settings := map[string]string{
|
||||
"SWWAF_UPSTREAM_URL": appURL, rulesDir: t.TempDir(), instanceName: "app",
|
||||
wafMode: off,
|
||||
rulesDir: t.TempDir(), instanceName: "app", wafMode: off,
|
||||
}
|
||||
if geojsURL == "" {
|
||||
settings[lookupSource] = off
|
||||
@@ -391,7 +507,7 @@ func readAnswer(res *http.Response) answer {
|
||||
return answer{status: res.StatusCode, header: res.Header, body: body, err: err}
|
||||
}
|
||||
|
||||
// newRequest makes a request for path to smallwebwaf at addr.
|
||||
// newRequest makes a request for path to addr, where rproxy or servewaf listens.
|
||||
func newRequest(t *testing.T, method, addr, path string, body io.Reader) *http.Request {
|
||||
t.Helper()
|
||||
|
||||
@@ -415,15 +531,15 @@ func do(t *testing.T, req *http.Request) answer {
|
||||
return readAnswer(res)
|
||||
}
|
||||
|
||||
// get sends a GET request for path to smallwebwaf at addr.
|
||||
// get sends a GET request for path to addr.
|
||||
func get(t *testing.T, addr, path string) answer {
|
||||
t.Helper()
|
||||
|
||||
return do(t, newRequest(t, http.MethodGet, addr, path, http.NoBody))
|
||||
}
|
||||
|
||||
// dial opens a connection to smallwebwaf at addr, for requests the HTTP
|
||||
// client cannot make, such as one that stops sending halfway.
|
||||
// dial opens a connection to addr, for requests the HTTP client cannot make,
|
||||
// such as one that stops sending halfway.
|
||||
func dial(t *testing.T, addr string) net.Conn {
|
||||
t.Helper()
|
||||
|
||||
@@ -484,14 +600,3 @@ func wantStatus(t *testing.T, got answer, status int) {
|
||||
t.Errorf("status %d, want %d", got.status, status)
|
||||
}
|
||||
}
|
||||
|
||||
// wantTimedOut checks that what began at start ended once shortTimeout
|
||||
// had run out, and not much later.
|
||||
func wantTimedOut(t *testing.T, start time.Time) {
|
||||
t.Helper()
|
||||
|
||||
took := time.Since(start)
|
||||
if took < shortTimeout || took > shortTimeout+waitLimit/2 {
|
||||
t.Errorf("took %s, want %s", took, shortTimeout)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -51,7 +51,7 @@ func TestRateLimitRefusesBeforeTheApp(t *testing.T) {
|
||||
req.Header.Set(forwardedFor, sent.client)
|
||||
wantStatus(t, do(t, req), sent.want)
|
||||
|
||||
line := out.requestLines(t, i+1)[i]
|
||||
line := out.endedLines(t, i+1)[i]
|
||||
if line.ClientIP != sent.logged {
|
||||
t.Errorf("log line has client_ip %q, want %q", line.ClientIP, sent.logged)
|
||||
}
|
||||
|
||||
@@ -272,6 +272,7 @@ func TestEachBlocklistThatListsAClientRaisesAnAlertOncePerCooldownAndIsCounted(
|
||||
Instance: alertInstance,
|
||||
Time: clk.Now(),
|
||||
Event: alerts.EventReputationHit,
|
||||
Host: appHost,
|
||||
Client: netip.MustParseAddr(fromDE),
|
||||
Netblock: netip.MustParsePrefix(fromDE + "/32"),
|
||||
ASN: asnDE,
|
||||
@@ -289,7 +290,7 @@ func TestEachBlocklistThatListsAClientRaisesAnAlertOncePerCooldownAndIsCounted(
|
||||
|
||||
// Each list's hits, none of its fetches failed, and when its copy was
|
||||
// fetched, 0 for the one without.
|
||||
metrics := s.scrape(unplaced)
|
||||
metrics := s.scrape()
|
||||
fetched := float64(listsFetched().Unix())
|
||||
|
||||
for listURL, want := range map[string]struct{ hits, fetched float64 }{
|
||||
@@ -513,6 +514,7 @@ func TestEachZoneThatListsAClientRaisesAnAlertOncePerCooldownAndIsCounted(
|
||||
Instance: alertInstance,
|
||||
Time: clk.Now(),
|
||||
Event: alerts.EventReputationHit,
|
||||
Host: appHost,
|
||||
Client: netip.MustParseAddr(fromDE),
|
||||
Netblock: netip.MustParsePrefix(fromDE + "/32"),
|
||||
ASN: asnDE,
|
||||
@@ -530,7 +532,7 @@ func TestEachZoneThatListsAClientRaisesAnAlertOncePerCooldownAndIsCounted(
|
||||
|
||||
// Each zone's hits, and its queries and their failures, none, since
|
||||
// every client had its verdicts.
|
||||
metrics := s.scrape(unplaced)
|
||||
metrics := s.scrape()
|
||||
|
||||
for _, zone := range []string{dnsblZone, otherZone} {
|
||||
labels := `{instance="` + alertInstance + `",source="` + zone + `"}`
|
||||
@@ -564,7 +566,7 @@ func TestZoneKeyIsMaskedInTheLogTheAlertAndTheMetrics(t *testing.T) {
|
||||
masked)
|
||||
}
|
||||
|
||||
metrics := s.scrape(unplaced)
|
||||
metrics := s.scrape()
|
||||
wantMetric(t, metrics, `smallwebwaf_reputation_hits_total{instance="`+
|
||||
alertInstance+`",source="`+masked+`"}`, 1)
|
||||
|
||||
@@ -737,10 +739,6 @@ func TestClientRefusedForAnOffenceIsCheckedWithAbuseIPDBAtItsNextRequest(t *test
|
||||
"a trap path", "/xmlrpc.php", http.StatusForbidden, requestlog.ActionBanned,
|
||||
ratelimit.Offences{Attack: 1},
|
||||
},
|
||||
{
|
||||
"a missing token", proxy.MetricsPath, http.StatusUnauthorized,
|
||||
requestlog.ActionAdmin, ratelimit.Offences{TokenRefused: 1},
|
||||
},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
@@ -748,7 +746,7 @@ func TestClientRefusedForAnOffenceIsCheckedWithAbuseIPDBAtItsNextRequest(t *test
|
||||
s, clk, server := startWithClock(t, "", map[string]string{
|
||||
abuseIPDBKey: accountKey, reputationAction: actionLog,
|
||||
rulesDir: writeRules(t, testRules), attackBanDuration: "1h",
|
||||
trapPaths: trapPathList, metricsToken: token, wafMode: block,
|
||||
trapPaths: trapPathList, wafMode: block,
|
||||
})
|
||||
|
||||
s.request(client, tc.path, tc.status, tc.action)
|
||||
@@ -862,7 +860,7 @@ func TestAbuseIPDBHitRaisesAnAlertWithTheScoreOncePerCooldownAndIsCounted(
|
||||
|
||||
// The hits, and the checks, none, since no client committed an
|
||||
// offence, so that the whole budget is left.
|
||||
metrics := s.scrape(unplaced)
|
||||
metrics := s.scrape()
|
||||
labels := `{instance="` + alertInstance + `",source="` + abuseipdb + `"}`
|
||||
|
||||
wantMetric(t, metrics, "smallwebwaf_reputation_hits_total"+labels, 2)
|
||||
@@ -889,7 +887,7 @@ func TestWithoutAnAbuseIPDBKeyNoClientIsCheckedNorAScoreUsed(t *testing.T) {
|
||||
wantReputation(t, s.get(fromDE, http.StatusOK, forward))
|
||||
wantAbuseIPDBChecks(t, server, 0)
|
||||
|
||||
wantNoSeries(t, s.scrape(unplaced), `smallwebwaf_reputation_daily_budget_remaining{`+
|
||||
wantNoSeries(t, s.scrape(), `smallwebwaf_reputation_daily_budget_remaining{`+
|
||||
`instance="`+alertInstance+`",source="`+abuseipdb+`"}`)
|
||||
}
|
||||
|
||||
|
||||
+68
-562
@@ -2,17 +2,10 @@ package proxy
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptrace"
|
||||
"net/http/httputil"
|
||||
"net/netip"
|
||||
"os"
|
||||
"slices"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"sneak.berlin/go/smallwebwaf/internal/anomaly"
|
||||
@@ -24,19 +17,11 @@ import (
|
||||
"sneak.berlin/go/smallwebwaf/internal/rules"
|
||||
)
|
||||
|
||||
// flushAfterEachWrite has ReverseProxy pass on each part of the app's
|
||||
// answer as soon as it arrives.
|
||||
const flushAfterEachWrite time.Duration = -1
|
||||
|
||||
// refusal is smallwebwaf refusing a request, or refusing to go on with it:
|
||||
// the status the client is answered if the response has not started yet,
|
||||
// 0 to close the connection without an answer, the action the log line
|
||||
// names, and the setting whose size or time limit the request passed, if
|
||||
// that is why.
|
||||
// refusal is servewaf refusing a request: the status the visitor is to get, 0
|
||||
// to close its connection without an answer, and the action the log line names.
|
||||
type refusal struct {
|
||||
status int
|
||||
action string
|
||||
limit string
|
||||
}
|
||||
|
||||
// decision is what the checks note of a request: its log line, and what
|
||||
@@ -48,8 +33,8 @@ type decision struct {
|
||||
client netip.Addr
|
||||
// requestURI is the path with the query, as a ban's notes keep it.
|
||||
requestURI string
|
||||
// site is, in servewaf, the host the visitor asked for, which the
|
||||
// alerts name, since one servewaf serves many sites. It is "" otherwise.
|
||||
// site is, for a question, the host the visitor asked for, which the
|
||||
// alerts name, since one servewaf serves many sites.
|
||||
site string
|
||||
// allowed is true for a question answered with DispositionAllow.
|
||||
allowed bool
|
||||
@@ -78,75 +63,34 @@ type decision struct {
|
||||
reputation []bans.ReputationHit
|
||||
}
|
||||
|
||||
// request is one request on its way through smallwebwaf, from the moment
|
||||
// its headers have been read to its log line.
|
||||
// request is one request to servewaf, from the moment its headers have been
|
||||
// read to its log line.
|
||||
type request struct {
|
||||
decision
|
||||
|
||||
h *handler
|
||||
in *http.Request
|
||||
// rc sets the deadlines of the connection to the client.
|
||||
rc *http.ResponseController
|
||||
out *responseWriter
|
||||
body *requestBody // nil for a request without a body
|
||||
h *handler
|
||||
in *http.Request
|
||||
out *responseWriter
|
||||
|
||||
peer netip.Addr
|
||||
peerTrusted bool
|
||||
// tokenRefused is true for a request refused for a missing or wrong
|
||||
// token, an offence its client's history counts.
|
||||
tokenRefused bool
|
||||
// blocklisted is true once a blocklist is found to list the client,
|
||||
// dnsblListed once a DNSBL zone's verdict is, and abuseIPDBHit once
|
||||
// AbuseIPDB's score of it is a hit.
|
||||
blocklisted, dnsblListed, abuseIPDBHit bool
|
||||
start time.Time
|
||||
// checked is when the checks were done, and upstreamStart when the
|
||||
// request was handed to the app.
|
||||
checked time.Time
|
||||
upstreamStart time.Time
|
||||
// cancel ends the request to the app.
|
||||
cancel context.CancelFunc
|
||||
// refused is the first refusal, from whichever goroutine meets it.
|
||||
refused atomic.Pointer[refusal]
|
||||
// complete is true once the app's whole answer has been passed on.
|
||||
complete bool
|
||||
// upgraded is the connection to the app once the app has switched
|
||||
// protocols, as for a WebSocket, and nil otherwise.
|
||||
upgraded *upgradedConn
|
||||
|
||||
// mu guards what follows. The timeouts run on goroutines of their
|
||||
// own, and the transport starts and stops them, and notes the times
|
||||
// below, from its own; once timersStopped is set, none of the timeouts
|
||||
// acts any more.
|
||||
mu sync.Mutex
|
||||
timersStopped bool
|
||||
clientRequestTimer *time.Timer
|
||||
upstreamRequestTimer *time.Timer
|
||||
upstreamResponseTimer *time.Timer
|
||||
// connected is when there was a connection to the app, requestSent
|
||||
// when the app had been sent the whole request, and answerStarted
|
||||
// when the first byte of its answer arrived.
|
||||
connected time.Time
|
||||
requestSent time.Time
|
||||
answerStarted time.Time
|
||||
}
|
||||
|
||||
// newRequest starts handling r: it works out the client from the TCP peer
|
||||
// and X-Forwarded-For, and goes on as startRequest does.
|
||||
func (h *handler) newRequest(w http.ResponseWriter, r *http.Request) *request {
|
||||
peer := peerAddress(r)
|
||||
trusted := h.config.TrustedProxies
|
||||
client := clientAddress(peer, r.Header.Values("X-Forwarded-For"), trusted)
|
||||
|
||||
return h.startRequest(w, r, client, peer, isInside(peer, trusted))
|
||||
// checked is when the checks were done.
|
||||
checked time.Time
|
||||
// refused is what the request was refused with, nil if it was not.
|
||||
refused *refusal
|
||||
// requestBytes is how many bytes of the request's body have been read.
|
||||
requestBytes int64
|
||||
}
|
||||
|
||||
// startRequest starts handling r from client, which came through peer: it
|
||||
// notes the time, counts the request as under way, and starts the log line
|
||||
// with what is known of the request. peerTrusted says whether to believe
|
||||
// the request's id and scheme, which r's headers give.
|
||||
// with what is known of the request. isQuestion says whether r is a question,
|
||||
// whose headers give the request's id and scheme.
|
||||
func (h *handler) startRequest(
|
||||
w http.ResponseWriter, r *http.Request, client, peer netip.Addr, peerTrusted bool,
|
||||
w http.ResponseWriter, r *http.Request, client, peer netip.Addr, isQuestion bool,
|
||||
) *request {
|
||||
h.metrics.RequestStarted()
|
||||
|
||||
@@ -162,14 +106,14 @@ func (h *handler) startRequest(
|
||||
Instance: h.config.InstanceName,
|
||||
ClientIP: client.String(),
|
||||
Method: r.Method,
|
||||
Scheme: scheme(r, peerTrusted),
|
||||
Scheme: scheme(r, isQuestion),
|
||||
Host: r.Host,
|
||||
Path: rules.Path(r),
|
||||
Query: r.URL.RawQuery,
|
||||
Protocol: r.Proto,
|
||||
Referer: r.Referer(),
|
||||
UserAgent: r.UserAgent(),
|
||||
RequestID: requestID(r, peerTrusted),
|
||||
RequestID: requestID(r, isQuestion),
|
||||
PeerIP: peer.String(),
|
||||
ForwardedFor: strings.Join(forwardedFor, ", "),
|
||||
ClientGroup: h.clientGroup(client).String(),
|
||||
@@ -180,13 +124,10 @@ func (h *handler) startRequest(
|
||||
Action: requestlog.ActionForward,
|
||||
},
|
||||
},
|
||||
h: h,
|
||||
in: r,
|
||||
rc: http.NewResponseController(w),
|
||||
out: &responseWriter{ResponseWriter: w},
|
||||
peer: peer,
|
||||
peerTrusted: peerTrusted,
|
||||
start: start,
|
||||
h: h,
|
||||
in: r,
|
||||
out: &responseWriter{ResponseWriter: w},
|
||||
start: start,
|
||||
}
|
||||
|
||||
// A length of -1 is a body whose length was not announced.
|
||||
@@ -194,10 +135,6 @@ func (h *handler) startRequest(
|
||||
rq.line.ContentLength = r.ContentLength
|
||||
}
|
||||
|
||||
if r.Body != http.NoBody {
|
||||
rq.body = &requestBody{body: limitBody(r.Body, h.config.RequestMaxBytes), rq: rq}
|
||||
}
|
||||
|
||||
return rq
|
||||
}
|
||||
|
||||
@@ -222,29 +159,21 @@ func requestHeaders(r *http.Request, names []string) map[string]string {
|
||||
return headers
|
||||
}
|
||||
|
||||
// check is the one place where a request can be refused once its client
|
||||
// is known, before anything reaches the app, and before its body is read,
|
||||
// but for the part the Core Rule Set reads. It returns nil to let the
|
||||
// request through. The checks of checkClient come first, answered with
|
||||
// SWWAF_BAN_RESPONSE, 403 for a block rule or the Core Rule Set, or 431 for
|
||||
// servewaf's header limits, and then the size limit, so that a request the
|
||||
// rate limits count is counted even when it is refused for its size. In
|
||||
// observe mode a request checkClient refuses goes on to the size limit like
|
||||
// any other. A size or time limit the Core Rule Set's reading of the body
|
||||
// meets ends the request in either mode. ctx is the request's own context.
|
||||
// check is the one place where a request can be refused. It returns nil to let
|
||||
// the request through. The checks of checkClient decide: a request they refuse
|
||||
// is answered with SWWAF_BAN_RESPONSE, 403 for a block rule or the Core Rule Set,
|
||||
// or 431 for the header limits. In observe mode nothing is refused, and the log
|
||||
// line names what enforce mode would have done. ctx is the request's own context.
|
||||
func (rq *request) check(ctx context.Context) *refusal {
|
||||
action := rq.checkClient(ctx)
|
||||
|
||||
refused := rq.refused.Load()
|
||||
if refused != nil {
|
||||
return refused
|
||||
}
|
||||
|
||||
switch {
|
||||
case action == "":
|
||||
return nil
|
||||
case rq.h.config.Observe:
|
||||
// The log line names what enforce mode would have done.
|
||||
rq.line.WouldAction = action
|
||||
|
||||
return nil
|
||||
case action == requestlog.ActionRuleBlocked || action == requestlog.ActionWAFBlocked:
|
||||
return &refusal{status: http.StatusForbidden, action: action}
|
||||
case action == requestlog.ActionTooLarge:
|
||||
@@ -252,17 +181,6 @@ func (rq *request) check(ctx context.Context) *refusal {
|
||||
default:
|
||||
return rq.banResponse(action)
|
||||
}
|
||||
|
||||
maxBytes := rq.h.config.RequestMaxBytes
|
||||
if maxBytes > 0 && rq.in.ContentLength > maxBytes {
|
||||
return &refusal{
|
||||
status: http.StatusRequestEntityTooLarge,
|
||||
action: requestlog.ActionTooLarge,
|
||||
limit: "SWWAF_REQUEST_MAX_BYTES",
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// checkClient runs the checks on the request's client, and returns the
|
||||
@@ -277,10 +195,9 @@ func (rq *request) check(ctx context.Context) *refusal {
|
||||
// rate limits, unless the client is in SWWAF_RATE_LIMIT_EXEMPT_NETS or the
|
||||
// request's path is exempt under SWWAF_RATE_LIMIT_EXEMPT_PATHS, so that
|
||||
// every other request is counted, each of them by the client's limit
|
||||
// percentages, then SWWAF_TRAP_PATHS, then the rule files, then, in
|
||||
// servewaf, the header limits, and last the Core Rule Set. A request exempt
|
||||
// from the rate limits is exempt from the byte limits too. ctx is the
|
||||
// request's own context.
|
||||
// percentages, then SWWAF_TRAP_PATHS, then the rule files, then the header
|
||||
// limits, and last the Core Rule Set. A request exempt from the rate limits
|
||||
// is exempt from the byte limits too. ctx is the request's own context.
|
||||
func (rq *request) checkClient(ctx context.Context) string {
|
||||
cfg := rq.h.config
|
||||
if isInside(rq.client, cfg.AllowNets) {
|
||||
@@ -381,251 +298,32 @@ func pathExempt(r *http.Request, prefixes []string) bool {
|
||||
})
|
||||
}
|
||||
|
||||
// forward passes the request to the app and the app's answer back. ctx
|
||||
// is the request's own context.
|
||||
func (rq *request) forward(ctx context.Context) {
|
||||
ctx, cancel := context.WithCancel(ctx)
|
||||
defer cancel()
|
||||
|
||||
rq.cancel = cancel
|
||||
ctx = httptrace.WithClientTrace(ctx, &httptrace.ClientTrace{
|
||||
GotConn: rq.gotConn,
|
||||
WroteRequest: rq.wroteRequest,
|
||||
GotFirstResponseByte: rq.gotFirstResponseByte,
|
||||
})
|
||||
|
||||
out := rq.in.WithContext(ctx)
|
||||
if rq.body != nil {
|
||||
out.Body = rq.body
|
||||
}
|
||||
|
||||
reverseProxy := &httputil.ReverseProxy{
|
||||
Rewrite: rq.rewrite,
|
||||
Transport: rq.h.transport,
|
||||
FlushInterval: flushAfterEachWrite,
|
||||
ErrorLog: rq.h.errorLog,
|
||||
ModifyResponse: rq.modifyResponse,
|
||||
ErrorHandler: rq.answerError,
|
||||
}
|
||||
|
||||
rq.startRequestTimers()
|
||||
rq.upstreamStart = time.Now()
|
||||
reverseProxy.ServeHTTP(rq.out, out)
|
||||
}
|
||||
|
||||
// rewrite makes the request the app receives: the client's request,
|
||||
// unchanged, sent to SWWAF_UPSTREAM_URL, with the forwarded headers and
|
||||
// the request's id set, without any X-Client-ASN or X-Client-Country the
|
||||
// client sent, whatever SWWAF_ADD_LOOKUP_HEADERS says, and, while it is
|
||||
// set, with the client's AS number and country in them.
|
||||
func (rq *request) rewrite(pr *httputil.ProxyRequest) {
|
||||
upstream := rq.h.config.UpstreamURL
|
||||
pr.Out.URL.Scheme = upstream.Scheme
|
||||
pr.Out.URL.Host = upstream.Host
|
||||
// ReverseProxy drops query parameters it cannot parse; the app gets
|
||||
// the query as the client sent it.
|
||||
pr.Out.URL.RawQuery = pr.In.URL.RawQuery
|
||||
// ReverseProxy sends the path URL.EscapedPath gives, which, for a path
|
||||
// holding a character Go escapes, such as |, is the whole path decoded
|
||||
// and escaped again, %2F turned into /. Opaque is sent as it is, so the
|
||||
// app gets the path as the client sent it. Left out are a target that
|
||||
// is no path, such as *, and a path starting with //, which Go would
|
||||
// send in Opaque as a scheme and host.
|
||||
path := rules.Path(pr.In)
|
||||
if strings.HasPrefix(path, "/") && !strings.HasPrefix(path, "//") {
|
||||
pr.Out.URL.Opaque = path
|
||||
}
|
||||
|
||||
setForwardedHeaders(pr.In, pr.Out, rq.peer, rq.peerTrusted)
|
||||
pr.Out.Header.Set(requestIDHeader, rq.line.RequestID)
|
||||
pr.Out.Header.Del(asnHeader)
|
||||
pr.Out.Header.Del(countryHeader)
|
||||
|
||||
if rq.h.config.AddLookupHeaders {
|
||||
setLookupHeaders(pr.Out.Header, rq.line.ASN, rq.line.Country)
|
||||
}
|
||||
}
|
||||
|
||||
// modifyResponse looks at the app's answer before ReverseProxy passes it
|
||||
// on.
|
||||
func (rq *request) modifyResponse(res *http.Response) error {
|
||||
rq.line.UpstreamStatus = res.StatusCode
|
||||
|
||||
if res.StatusCode == http.StatusSwitchingProtocols {
|
||||
// An upgraded connection, such as a WebSocket, is not cut by the
|
||||
// timeouts. ReverseProxy writes this answer straight to the
|
||||
// connection it takes over, not through rq.out, and then copies
|
||||
// what passes each way through res.Body, the connection to the app.
|
||||
// It may still refuse the switch, as when the app's Upgrade is not
|
||||
// the client's, so the status is noted only once it has taken over
|
||||
// the connection, by rq.out's Hijack.
|
||||
rq.stopTimers()
|
||||
rq.line.Websocket = true
|
||||
|
||||
conn, ok := res.Body.(io.ReadWriteCloser)
|
||||
if ok {
|
||||
rq.upgraded = &upgradedConn{ReadWriteCloser: conn}
|
||||
res.Body = rq.upgraded
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
maxBytes := rq.h.config.ResponseMaxBytes
|
||||
if maxBytes > 0 && res.Body != http.NoBody && res.ContentLength > maxBytes {
|
||||
rq.refuse(refusal{
|
||||
status: http.StatusBadGateway,
|
||||
action: requestlog.ActionTooLarge,
|
||||
limit: "SWWAF_RESPONSE_MAX_BYTES",
|
||||
})
|
||||
|
||||
return errResponseTooLarge
|
||||
}
|
||||
|
||||
// Go's server adds a Content-Type it guesses from the body to a
|
||||
// response whose headers have no Content-Type key. The key with no
|
||||
// value sends none, as the app sent none.
|
||||
if res.Header.Get("Content-Type") == "" {
|
||||
rq.out.Header()["Content-Type"] = nil
|
||||
}
|
||||
|
||||
res.Body = &responseBody{body: limitBody(res.Body, maxBytes), rq: rq}
|
||||
rq.startClientResponseTimeout()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// answerError is ReverseProxy's ErrorHandler: the request could not be
|
||||
// passed to the app, or the app's answer cannot be passed on.
|
||||
func (rq *request) answerError(_ http.ResponseWriter, _ *http.Request, err error) {
|
||||
if rq.upgraded != nil {
|
||||
// The app switched protocols, but the switch was not passed on,
|
||||
// and ReverseProxy leaves the app's connection open when it
|
||||
// refuses it.
|
||||
_ = rq.upgraded.Close()
|
||||
}
|
||||
|
||||
refused := rq.refused.Load()
|
||||
if refused == nil {
|
||||
if rq.in.Context().Err() != nil {
|
||||
return // the client has gone, and there is no one to answer
|
||||
}
|
||||
|
||||
rq.h.processLog.Warn("request to the app failed", "error", err.Error())
|
||||
|
||||
refused = &refusal{
|
||||
status: http.StatusBadGateway,
|
||||
action: requestlog.ActionUpstreamError,
|
||||
}
|
||||
}
|
||||
|
||||
rq.answer(*refused)
|
||||
}
|
||||
|
||||
// answer sends smallwebwaf's own answer, unless the response has already
|
||||
// started, and records the refusal for the log line.
|
||||
func (rq *request) answer(r refusal) {
|
||||
rq.refused.CompareAndSwap(nil, &r)
|
||||
|
||||
if rq.out.status != 0 {
|
||||
return // too late to answer: the connection can only be cut
|
||||
}
|
||||
|
||||
if r.status == 0 {
|
||||
// SWWAF_BAN_RESPONSE is close. This panic has Go's server close
|
||||
// the connection without an answer, and log nothing; the log line
|
||||
// is still written as the handler returns.
|
||||
panic(http.ErrAbortHandler)
|
||||
}
|
||||
|
||||
// A client found too slow is read no more; any other may go on
|
||||
// sending until its time is up, so that Go's server can read the
|
||||
// rest of the body and end the request cleanly.
|
||||
deadline := rq.clientRequestDeadline()
|
||||
if r.status == http.StatusRequestTimeout {
|
||||
deadline = time.Now()
|
||||
}
|
||||
|
||||
rq.stopReadingBody(deadline)
|
||||
|
||||
timeout := rq.h.config.ClientResponseTimeout
|
||||
if timeout > 0 {
|
||||
_ = rq.rc.SetWriteDeadline(time.Now().Add(timeout))
|
||||
}
|
||||
|
||||
http.Error(rq.out, http.StatusText(r.status), r.status)
|
||||
}
|
||||
|
||||
// refuse records r, unless an earlier refusal was, and ends the request
|
||||
// to the app, if one was made: smallwebwaf reads the body of a request
|
||||
// it answers itself too.
|
||||
func (rq *request) refuse(r refusal) {
|
||||
rq.refused.CompareAndSwap(nil, &r)
|
||||
|
||||
if rq.cancel != nil {
|
||||
rq.cancel()
|
||||
}
|
||||
}
|
||||
|
||||
// finish ends the request's timeouts, counts it in the metrics and writes
|
||||
// its log line.
|
||||
// finish counts the request in the metrics and writes its log line.
|
||||
func (rq *request) finish() {
|
||||
rq.stopTimers()
|
||||
|
||||
refused := rq.refused.Load()
|
||||
if refused == nil {
|
||||
rq.stopReadingBody(rq.clientRequestDeadline())
|
||||
}
|
||||
|
||||
line := &rq.line
|
||||
line.Status = rq.out.status
|
||||
line.ResponseBytes = rq.out.bytes
|
||||
header := rq.out.Header()
|
||||
line.ResponseContentType = header.Get("Content-Type")
|
||||
line.CacheControl = header.Get("Cache-Control")
|
||||
line.Location = header.Get("Location")
|
||||
line.RequestBytes = rq.requestBytes()
|
||||
|
||||
// limit is the setting whose size or time limit the request passed.
|
||||
var limit string
|
||||
line.ResponseContentType = rq.out.Header().Get("Content-Type")
|
||||
line.RequestBytes = rq.requestBytes
|
||||
|
||||
switch {
|
||||
case refused != nil:
|
||||
line.Action = refused.action
|
||||
limit = refused.limit
|
||||
case errors.Is(rq.out.err, os.ErrDeadlineExceeded):
|
||||
// The client took longer than SWWAF_CLIENT_RESPONSE_TIMEOUT to
|
||||
// take the response.
|
||||
line.Action = requestlog.ActionTimedOut
|
||||
limit = "SWWAF_CLIENT_RESPONSE_TIMEOUT"
|
||||
case !rq.complete && (rq.out.err != nil || rq.in.Context().Err() != nil):
|
||||
case rq.refused != nil:
|
||||
line.Action = rq.refused.action
|
||||
case rq.out.err != nil || rq.in.Context().Err() != nil:
|
||||
line.Aborted = true
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
duration := now.Sub(rq.start)
|
||||
duration := time.Since(rq.start)
|
||||
line.DurationTotal = requestlog.Milliseconds(duration)
|
||||
line.DurationChecks = timing(rq.start, rq.checked)
|
||||
|
||||
var upstreamDuration time.Duration
|
||||
|
||||
if !rq.upstreamStart.IsZero() {
|
||||
upstreamDuration = now.Sub(rq.upstreamStart)
|
||||
line.DurationUpstreamTotal = new(requestlog.Milliseconds(upstreamDuration))
|
||||
|
||||
rq.mu.Lock()
|
||||
line.DurationUpstreamConnect = timing(rq.upstreamStart, rq.connected)
|
||||
line.DurationUpstreamFirstByte = timing(rq.upstreamStart, rq.answerStarted)
|
||||
rq.mu.Unlock()
|
||||
}
|
||||
|
||||
// Counted before the log line is written, so that the metrics count
|
||||
// every request whose line is out. An allowed request is the exception:
|
||||
// its report gives the status, bytes and times the metrics count.
|
||||
if rq.allowed {
|
||||
rq.h.metrics.RequestAllowed()
|
||||
} else {
|
||||
rq.h.metrics.RequestEnded(line, limit, duration, upstreamDuration)
|
||||
rq.h.metrics.RequestEnded(line, duration)
|
||||
}
|
||||
|
||||
rq.writeLine()
|
||||
@@ -657,18 +355,16 @@ func timing(start, end time.Time) *float64 {
|
||||
// before either was there, and one from GeoJS that comes later is added
|
||||
// when it comes.
|
||||
func (rq *request) addToHistory() {
|
||||
forwarded := !rq.upstreamStart.IsZero()
|
||||
request := ratelimit.Request{
|
||||
Forwarded: forwarded,
|
||||
Refused: !forwarded && rq.refused.Load() != nil,
|
||||
Forwarded: rq.allowed,
|
||||
Refused: rq.refused != nil,
|
||||
Status: rq.out.status,
|
||||
RequestBytes: rq.requestBytes(),
|
||||
RequestBytes: rq.requestBytes,
|
||||
ResponseBytes: rq.out.bytes,
|
||||
BrokeLimit: rq.line.Offence == requestlog.OffenceLimit,
|
||||
Attack: rq.attack,
|
||||
RuleBlocked: rq.ruleBlocked,
|
||||
WAFBlocked: rq.wafBlocked,
|
||||
TokenRefused: rq.tokenRefused,
|
||||
}
|
||||
|
||||
rq.h.limiter.AddToHistory(rq.h.clientGroup(rq.client), rq.h.now(), request)
|
||||
@@ -729,228 +425,38 @@ func (rq *request) answerAtTheEnd() (lookup.Answer, bool) {
|
||||
return rq.h.geojs.Kept(rq.h.clientGroup(rq.client))
|
||||
}
|
||||
|
||||
// requestBytes is how many bytes of the request's body have been read.
|
||||
func (rq *request) requestBytes() int64 {
|
||||
if rq.body == nil {
|
||||
return 0
|
||||
}
|
||||
// responseWriter is servewaf's answer to a request. It notes the status and
|
||||
// size for the log line, and the first error writing the answer.
|
||||
type responseWriter struct {
|
||||
http.ResponseWriter
|
||||
|
||||
return rq.body.bytes.Load()
|
||||
// status is the status sent, or zero before one is.
|
||||
status int
|
||||
bytes int64
|
||||
err error
|
||||
}
|
||||
|
||||
// clientRequestDeadline is when the client must have sent its whole
|
||||
// request, or zero when SWWAF_CLIENT_REQUEST_TIMEOUT is off.
|
||||
func (rq *request) clientRequestDeadline() time.Time {
|
||||
timeout := rq.h.config.ClientRequestTimeout
|
||||
if timeout == 0 {
|
||||
return time.Time{}
|
||||
// WriteHeader sends the status and headers.
|
||||
func (w *responseWriter) WriteHeader(status int) {
|
||||
if w.status == 0 {
|
||||
w.status = status
|
||||
}
|
||||
|
||||
return rq.start.Add(timeout)
|
||||
w.ResponseWriter.WriteHeader(status)
|
||||
}
|
||||
|
||||
// stopReadingBody ends, at deadline, the reading of a client body that has
|
||||
// not arrived whole: Go's server then reads no more of it, and closes the
|
||||
// connection after the answer.
|
||||
func (rq *request) stopReadingBody(deadline time.Time) {
|
||||
if rq.body == nil || rq.body.received.Load() {
|
||||
return
|
||||
// Write sends part of the body.
|
||||
func (w *responseWriter) Write(p []byte) (int, error) {
|
||||
if w.status == 0 {
|
||||
w.status = http.StatusOK
|
||||
}
|
||||
|
||||
_ = rq.rc.SetReadDeadline(deadline)
|
||||
}
|
||||
n, err := w.ResponseWriter.Write(p)
|
||||
w.bytes += int64(n)
|
||||
|
||||
// startRequestTimers starts the timeouts that run while the request goes
|
||||
// to the app: SWWAF_CLIENT_REQUEST_TIMEOUT until the client has sent its
|
||||
// whole body, and SWWAF_UPSTREAM_REQUEST_TIMEOUT until the app has been
|
||||
// sent the whole request.
|
||||
func (rq *request) startRequestTimers() {
|
||||
rq.mu.Lock()
|
||||
defer rq.mu.Unlock()
|
||||
|
||||
if rq.body != nil && rq.h.config.ClientRequestTimeout > 0 {
|
||||
rq.clientRequestTimer = time.AfterFunc(
|
||||
time.Until(rq.clientRequestDeadline()), func() {
|
||||
rq.requestTimedOut("SWWAF_CLIENT_REQUEST_TIMEOUT")
|
||||
})
|
||||
if w.err == nil {
|
||||
w.err = err
|
||||
}
|
||||
|
||||
timeout := rq.h.config.UpstreamRequestTimeout
|
||||
if timeout > 0 {
|
||||
rq.upstreamRequestTimer = time.AfterFunc(timeout, func() {
|
||||
rq.requestTimedOut("SWWAF_UPSTREAM_REQUEST_TIMEOUT")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// requestTimedOut is called when limit, SWWAF_CLIENT_REQUEST_TIMEOUT or
|
||||
// SWWAF_UPSTREAM_REQUEST_TIMEOUT, runs out while the request is still on
|
||||
// its way to the app. The answer names the side smallwebwaf was waiting
|
||||
// on at that moment: 408 when it was waiting for the client to send more
|
||||
// of its body, 504 when it was waiting for the app to be reached or to
|
||||
// take what it had.
|
||||
func (rq *request) requestTimedOut(limit string) {
|
||||
rq.mu.Lock()
|
||||
defer rq.mu.Unlock()
|
||||
|
||||
if rq.timersStopped {
|
||||
return
|
||||
}
|
||||
|
||||
if rq.body == nil || !rq.body.waiting.Load() {
|
||||
// Once the app has answered, the transport, ReverseProxy and Go's
|
||||
// server each close the client's body, and closing it reads what
|
||||
// is left of a body under 256 KiB without requestBody.Read, so
|
||||
// waiting is false while that read waits for the client. That
|
||||
// read ends now, before refuse: should smallwebwaf still answer
|
||||
// itself, answer then sets the deadline it gives its own answers.
|
||||
if !rq.answerStarted.IsZero() {
|
||||
rq.stopReadingBody(time.Now())
|
||||
}
|
||||
|
||||
rq.refuse(refusal{
|
||||
status: http.StatusGatewayTimeout,
|
||||
action: requestlog.ActionTimedOut,
|
||||
limit: limit,
|
||||
})
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
rq.refuse(refusal{
|
||||
status: http.StatusRequestTimeout,
|
||||
action: requestlog.ActionTimedOut,
|
||||
limit: limit,
|
||||
})
|
||||
// The transport gives up on the app only once its Read of the
|
||||
// client's body returns, so that Read is ended now. The lock keeps
|
||||
// this from reaching the connection after the request is handled.
|
||||
_ = rq.rc.SetReadDeadline(time.Now())
|
||||
}
|
||||
|
||||
// bodyReceived is called once the client has sent its whole body.
|
||||
func (rq *request) bodyReceived() {
|
||||
rq.mu.Lock()
|
||||
defer rq.mu.Unlock()
|
||||
|
||||
stopTimer(rq.clientRequestTimer)
|
||||
}
|
||||
|
||||
// gotConn is called once there is a connection to the app, a new one or
|
||||
// one kept open from an earlier request.
|
||||
func (rq *request) gotConn(httptrace.GotConnInfo) {
|
||||
rq.mu.Lock()
|
||||
defer rq.mu.Unlock()
|
||||
|
||||
rq.connected = time.Now()
|
||||
}
|
||||
|
||||
// gotFirstResponseByte is called once the first byte of the app's answer
|
||||
// has arrived.
|
||||
func (rq *request) gotFirstResponseByte() {
|
||||
rq.mu.Lock()
|
||||
defer rq.mu.Unlock()
|
||||
|
||||
rq.answerStarted = time.Now()
|
||||
}
|
||||
|
||||
// wroteRequest is called once the app has been sent the whole request:
|
||||
// the request timeouts end and SWWAF_UPSTREAM_RESPONSE_TIMEOUT starts.
|
||||
func (rq *request) wroteRequest(info httptrace.WroteRequestInfo) {
|
||||
if info.Err != nil {
|
||||
return // the transport gives up, or tries again
|
||||
}
|
||||
|
||||
rq.mu.Lock()
|
||||
defer rq.mu.Unlock()
|
||||
|
||||
if rq.timersStopped {
|
||||
return
|
||||
}
|
||||
|
||||
stopTimer(rq.clientRequestTimer)
|
||||
stopTimer(rq.upstreamRequestTimer)
|
||||
rq.requestSent = time.Now()
|
||||
|
||||
timeout := rq.h.config.UpstreamResponseTimeout
|
||||
if timeout > 0 {
|
||||
rq.upstreamResponseTimer = time.AfterFunc(timeout, rq.responseTimedOut)
|
||||
}
|
||||
}
|
||||
|
||||
// responseTimedOut is called when SWWAF_UPSTREAM_RESPONSE_TIMEOUT runs out
|
||||
// before the app has sent its whole answer.
|
||||
func (rq *request) responseTimedOut() {
|
||||
rq.mu.Lock()
|
||||
defer rq.mu.Unlock()
|
||||
|
||||
if !rq.timersStopped {
|
||||
rq.refuse(refusal{
|
||||
status: http.StatusGatewayTimeout,
|
||||
action: requestlog.ActionTimedOut,
|
||||
limit: "SWWAF_UPSTREAM_RESPONSE_TIMEOUT",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// responseReceived is called once the app has sent its whole answer. An
|
||||
// app may answer before the client has sent its whole body, as with a 404
|
||||
// it sends without reading it. The timeouts then run on: until the
|
||||
// transport's read of the body returns, Go's server cannot send the
|
||||
// answer, and SWWAF_CLIENT_REQUEST_TIMEOUT is what ends that read for a
|
||||
// client that stops sending.
|
||||
func (rq *request) responseReceived() {
|
||||
rq.complete = true
|
||||
|
||||
if rq.body != nil && !rq.body.received.Load() {
|
||||
return
|
||||
}
|
||||
|
||||
rq.stopTimers()
|
||||
}
|
||||
|
||||
// startClientResponseTimeout sets SWWAF_CLIENT_RESPONSE_TIMEOUT on the
|
||||
// connection to the client: the response must reach the client within it
|
||||
// of the end of the request, or of now if the app answers before it has
|
||||
// the whole request.
|
||||
func (rq *request) startClientResponseTimeout() {
|
||||
timeout := rq.h.config.ClientResponseTimeout
|
||||
if timeout == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
from := rq.sentAt()
|
||||
if from.IsZero() {
|
||||
from = time.Now()
|
||||
}
|
||||
|
||||
_ = rq.rc.SetWriteDeadline(from.Add(timeout))
|
||||
}
|
||||
|
||||
// sentAt is when the app had been sent the whole request, or zero.
|
||||
func (rq *request) sentAt() time.Time {
|
||||
rq.mu.Lock()
|
||||
defer rq.mu.Unlock()
|
||||
|
||||
return rq.requestSent
|
||||
}
|
||||
|
||||
// stopTimers stops the request's timeouts and keeps any from starting
|
||||
// later: the app's answer is complete, the connection upgraded, or the
|
||||
// request handled.
|
||||
func (rq *request) stopTimers() {
|
||||
rq.mu.Lock()
|
||||
defer rq.mu.Unlock()
|
||||
|
||||
rq.timersStopped = true
|
||||
stopTimer(rq.clientRequestTimer)
|
||||
stopTimer(rq.upstreamRequestTimer)
|
||||
stopTimer(rq.upstreamResponseTimer)
|
||||
}
|
||||
|
||||
// stopTimer stops t, which is nil when its timeout is off.
|
||||
func stopTimer(t *time.Timer) {
|
||||
if t != nil {
|
||||
t.Stop()
|
||||
}
|
||||
return n, err
|
||||
}
|
||||
|
||||
@@ -1,55 +0,0 @@
|
||||
package proxy
|
||||
|
||||
import (
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"sneak.berlin/go/smallwebwaf/internal/config"
|
||||
)
|
||||
|
||||
func TestAnAnswerWithoutAContentTypeGetsNone(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
srv := httptest.NewServer(http.HandlerFunc(
|
||||
func(w http.ResponseWriter, _ *http.Request) {
|
||||
rq := &request{
|
||||
h: &handler{config: &config.Config{}},
|
||||
out: &responseWriter{ResponseWriter: w},
|
||||
}
|
||||
app := &http.Response{
|
||||
StatusCode: http.StatusOK,
|
||||
Header: http.Header{},
|
||||
Body: http.NoBody,
|
||||
}
|
||||
|
||||
err := rq.modifyResponse(app)
|
||||
if err != nil {
|
||||
t.Errorf("modifyResponse: %v", err)
|
||||
}
|
||||
|
||||
// The headers go out with this, the first part of the body,
|
||||
// which Go's server would guess a Content-Type from.
|
||||
_, _ = io.WriteString(w, "<!doctype html><title>page</title>")
|
||||
}))
|
||||
t.Cleanup(srv.Close)
|
||||
|
||||
req, err := http.NewRequestWithContext(t.Context(), http.MethodGet, srv.URL,
|
||||
http.NoBody)
|
||||
if err != nil {
|
||||
t.Fatalf("new request: %v", err)
|
||||
}
|
||||
|
||||
res, err := srv.Client().Do(req)
|
||||
if err != nil {
|
||||
t.Fatalf("get: %v", err)
|
||||
}
|
||||
|
||||
_ = res.Body.Close()
|
||||
|
||||
if res.Header.Values("Content-Type") != nil {
|
||||
t.Errorf("client got Content-Type %q, want none",
|
||||
res.Header.Get("Content-Type"))
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,6 @@ package proxy_test
|
||||
|
||||
import (
|
||||
"io"
|
||||
"maps"
|
||||
"math"
|
||||
"net/http"
|
||||
"reflect"
|
||||
@@ -42,9 +41,6 @@ func TestLogLineHasEachFieldWhereItApplies(t *testing.T) {
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "text/html")
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
w.Header().Set("Location", "/elsewhere")
|
||||
w.WriteHeader(http.StatusFound)
|
||||
_, _ = io.WriteString(w, "moved")
|
||||
})
|
||||
@@ -70,7 +66,7 @@ func TestLogLineHasEachFieldWhereItApplies(t *testing.T) {
|
||||
|
||||
// A request's log line can come after its answer: each is waited for
|
||||
// before the next request, so that the lines are in order.
|
||||
full := out.requestLines(t, 1)[0]
|
||||
full := out.endedLines(t, 1)[0]
|
||||
|
||||
// This one comes from 127.0.0.1, which the rate limits do not count,
|
||||
// with a body of 4 bytes whose length it does not announce, so that its
|
||||
@@ -81,7 +77,7 @@ func TestLogLineHasEachFieldWhereItApplies(t *testing.T) {
|
||||
"Transfer-Encoding: chunked\r\n\r\n4\r\nbody\r\n0\r\n\r\n")
|
||||
wantStatus(t, readResponse(t, conn), http.StatusNoContent)
|
||||
|
||||
bare := out.requestLines(t, 2)[1]
|
||||
bare := out.endedLines(t, 2)[1]
|
||||
|
||||
wantFullLine(t, full)
|
||||
wantBareLine(t, bare)
|
||||
@@ -108,24 +104,23 @@ func wantFullLine(t *testing.T, line logLine) {
|
||||
headers := map[string]string{"accept": "text/html", "x-custom": "one, two"}
|
||||
|
||||
want := withTimings(line, requestlog.Line{
|
||||
Type: requestType, Time: line.Time, Instance: instance,
|
||||
Type: requestType, ServeWAFLine: requestlog.ServeWAFLineReport,
|
||||
Time: line.Time, Instance: instance,
|
||||
ClientIP: ipv6Client, Method: http.MethodPost, Scheme: secure,
|
||||
Host: appHost, Path: "/full", Protocol: protocol,
|
||||
Status: http.StatusFound, RequestBytes: 3, ResponseBytes: 5,
|
||||
RequestID: "from-traefik", PeerIP: localhost,
|
||||
RequestID: line.RequestID, PeerIP: localhost,
|
||||
ForwardedFor: "198.51.100.7, " + ipv6Client, ClientGroup: ipv6Group,
|
||||
ContentType: "application/x-www-form-urlencoded", ContentLength: 3,
|
||||
RequestHeaders: headers, HasAuthorization: true, HasCookie: true,
|
||||
ResponseContentType: "text/html", UpstreamStatus: http.StatusFound,
|
||||
CacheControl: "no-store", Location: "/elsewhere",
|
||||
Action: requestlog.ActionForward,
|
||||
// Its 3 bytes in and 5 out, each way counted by default.
|
||||
Counts: ratelimit.Counts{
|
||||
Minute: 1, Hour: 1, Day: 1, MinuteBytes: 8, HourBytes: 8, DayBytes: 8,
|
||||
},
|
||||
})
|
||||
if !reflect.DeepEqual(line.Line, want) {
|
||||
t.Errorf("log line\n%+v\nwant\n%+v", line.Line, want)
|
||||
if !reflect.DeepEqual(line.Line, want) || line.RequestID == "from-traefik" {
|
||||
t.Errorf("log line\n%+v\nwant\n%+v, with a new request id", line.Line, want)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -135,12 +130,13 @@ func wantBareLine(t *testing.T, line logLine) {
|
||||
t.Helper()
|
||||
|
||||
want := withTimings(line, requestlog.Line{
|
||||
Type: requestType, Time: line.Time, Instance: instance,
|
||||
Type: requestType, ServeWAFLine: requestlog.ServeWAFLineReport,
|
||||
Time: line.Time, Instance: instance,
|
||||
ClientIP: localhost, Method: http.MethodPost, Scheme: plain,
|
||||
Host: appHost, Path: "/bare", Protocol: protocol,
|
||||
Status: http.StatusNoContent, RequestBytes: 4, RequestID: line.RequestID,
|
||||
PeerIP: localhost, ClientGroup: localhost + "/32",
|
||||
UpstreamStatus: http.StatusNoContent, Action: requestlog.ActionForward,
|
||||
Action: requestlog.ActionForward,
|
||||
})
|
||||
if !reflect.DeepEqual(line.Line, want) || line.RequestID == "" {
|
||||
t.Errorf("log line\n%+v\nwant\n%+v, with a request id", line.Line, want)
|
||||
@@ -148,8 +144,7 @@ func wantBareLine(t *testing.T, line logLine) {
|
||||
|
||||
for _, name := range []string{
|
||||
"forwarded_for", "content_type", "content_length", "request_headers",
|
||||
"has_authorization", "has_cookie", "websocket", "response_content_type",
|
||||
"cache_control", "location", "counts",
|
||||
"has_authorization", "has_cookie", "response_content_type", "counts",
|
||||
} {
|
||||
_, present := line.fields[name]
|
||||
if present {
|
||||
@@ -162,8 +157,6 @@ func wantBareLine(t *testing.T, line logLine) {
|
||||
func withTimings(line logLine, want requestlog.Line) requestlog.Line {
|
||||
want.DurationTotal = line.DurationTotal
|
||||
want.DurationChecks = line.DurationChecks
|
||||
want.DurationUpstreamConnect = line.DurationUpstreamConnect
|
||||
want.DurationUpstreamFirstByte = line.DurationUpstreamFirstByte
|
||||
want.DurationUpstreamTotal = line.DurationUpstreamTotal
|
||||
|
||||
return want
|
||||
@@ -188,7 +181,7 @@ func TestHasAuthorizationAndHasCookieEachComeFromTheirOwnHeader(t *testing.T) {
|
||||
req.Header.Set(tc.header, "secret")
|
||||
wantStatus(t, do(t, req), http.StatusOK)
|
||||
|
||||
line := out.requestLine(t)
|
||||
line := out.endedLine(t)
|
||||
|
||||
_, otherPresent := line.fields[tc.other]
|
||||
if line.fields[tc.field] != true || otherPresent {
|
||||
@@ -199,81 +192,20 @@ func TestHasAuthorizationAndHasCookieEachComeFromTheirOwnHeader(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRequestIDAndSchemeComeOnlyFromATrustedProxy(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
const sentID = "from-traefik"
|
||||
|
||||
sent := http.Header{requestIDHeader: {sentID}, forwardedProto: {secure}}
|
||||
trusted := map[string]string{trustedProxies: trustLocalhost}
|
||||
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
env map[string]string
|
||||
header http.Header
|
||||
// wantID is the request id logged, "" for a new one.
|
||||
wantID, wantScheme string
|
||||
}{
|
||||
{"a trusted proxy's are kept", trusted, sent, sentID, secure},
|
||||
{"without them, the id is new and the scheme http", trusted, nil, "", plain},
|
||||
{"another peer's are replaced", nil, sent, "", plain},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
received := make(chan string, 2)
|
||||
app := startApp(t, func(_ http.ResponseWriter, r *http.Request) {
|
||||
received <- r.Header.Get(requestIDHeader)
|
||||
})
|
||||
addr, out := startProxy(t, app.URL, tc.env)
|
||||
|
||||
// Two requests, so that two new ids can be told apart.
|
||||
ids := make([]string, 0, 2)
|
||||
|
||||
for i := range 2 {
|
||||
req := newRequest(t, http.MethodGet, addr, "/", http.NoBody)
|
||||
maps.Copy(req.Header, tc.header)
|
||||
wantStatus(t, do(t, req), http.StatusOK)
|
||||
|
||||
line := out.requestLines(t, i+1)[i]
|
||||
ids = append(ids, line.RequestID)
|
||||
|
||||
got := <-received
|
||||
if line.RequestID != got || line.Scheme != tc.wantScheme {
|
||||
t.Errorf("log line has request_id %q and scheme %q, and the "+
|
||||
"app received id %q; want the same id and scheme %q",
|
||||
line.RequestID, line.Scheme, got, tc.wantScheme)
|
||||
}
|
||||
}
|
||||
|
||||
switch {
|
||||
case tc.wantID != "" && (ids[0] != tc.wantID || ids[1] != tc.wantID):
|
||||
t.Errorf("request ids %q, want %q", ids, tc.wantID)
|
||||
case tc.wantID == "" && (slices.Contains(ids, sentID) ||
|
||||
slices.Contains(ids, "") || ids[0] == ids[1]):
|
||||
t.Errorf("request ids %q, want two new ones", ids)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestTimingsAreInOrder(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
const denied = "192.0.2.50" // in SWWAF_DENY_NETS
|
||||
|
||||
app := startApp(t, func(w http.ResponseWriter, _ *http.Request) {
|
||||
// The pauses set the times apart; a hold-up of the test only
|
||||
// lengthens them.
|
||||
time.Sleep(time.Millisecond)
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_ = http.NewResponseController(w).Flush()
|
||||
|
||||
// The pause sets the times apart; a hold-up of the test only lengthens
|
||||
// it.
|
||||
time.Sleep(time.Millisecond)
|
||||
|
||||
_, _ = io.WriteString(w, "done")
|
||||
})
|
||||
addr, out := startProxy(t, app.URL, map[string]string{
|
||||
|
||||
addr, out, server := startProxyWithClock(t, app.URL, "", time.Now, map[string]string{
|
||||
trustedProxies: trustLocalhost,
|
||||
denyNets: denied,
|
||||
})
|
||||
@@ -281,20 +213,19 @@ func TestTimingsAreInOrder(t *testing.T) {
|
||||
// Each log line is waited for before the next request, so that the
|
||||
// lines are in order.
|
||||
wantStatus(t, get(t, addr, "/"), http.StatusOK)
|
||||
forwarded := out.requestLines(t, 1)[0]
|
||||
forwarded := out.endedLines(t, 1)[0]
|
||||
|
||||
req := newRequest(t, http.MethodGet, addr, "/", http.NoBody)
|
||||
req.Header.Set(forwardedFor, denied)
|
||||
wantStatus(t, do(t, req), http.StatusForbidden)
|
||||
refused := out.requestLines(t, 2)[1]
|
||||
refused := out.endedLines(t, 2)[1]
|
||||
|
||||
wantStatus(t, get(t, addr, proxy.HealthPath), http.StatusOK)
|
||||
health := out.requestLines(t, 3)[2]
|
||||
wantStatus(t, get(t, server.Addr, proxy.HealthPath), http.StatusOK)
|
||||
health := out.endedLines(t, 3)[2]
|
||||
|
||||
// A request passed to the app has every timing; one refused, none of
|
||||
// the app's; the health check, which runs no check, only the total.
|
||||
wantTimings(t, forwarded, "duration_total", "duration_checks",
|
||||
"duration_upstream_connect", "duration_upstream_first_byte",
|
||||
"duration_upstream_total")
|
||||
wantTimings(t, refused, "duration_total", "duration_checks")
|
||||
wantTimings(t, health, "duration_total")
|
||||
@@ -307,17 +238,12 @@ func TestTimingsAreInOrder(t *testing.T) {
|
||||
// exact.
|
||||
total := microseconds(forwarded.DurationTotal)
|
||||
checks := microseconds(*forwarded.DurationChecks)
|
||||
connect := microseconds(*forwarded.DurationUpstreamConnect)
|
||||
firstByte := microseconds(*forwarded.DurationUpstreamFirstByte)
|
||||
upstream := microseconds(*forwarded.DurationUpstreamTotal)
|
||||
|
||||
// The checks end before the request is handed to the app, and the
|
||||
// connection comes before the answer, which the app ends after a
|
||||
// pause.
|
||||
if checks+upstream > total || connect >= firstByte || firstByte >= upstream {
|
||||
t.Errorf("timings in microseconds: total %d, checks %d, connect %d, "+
|
||||
"first byte %d, upstream total %d", total, checks, connect, firstByte,
|
||||
upstream)
|
||||
// The checks end before the request is handed to the app.
|
||||
if checks+upstream > total {
|
||||
t.Errorf("timings in microseconds: total %d, checks %d, upstream total %d",
|
||||
total, checks, upstream)
|
||||
}
|
||||
|
||||
if *refused.DurationChecks > refused.DurationTotal {
|
||||
@@ -350,22 +276,3 @@ func wantTimings(t *testing.T, line logLine, want ...string) {
|
||||
func microseconds(milliseconds float64) int64 {
|
||||
return int64(math.Round(milliseconds * 1000))
|
||||
}
|
||||
|
||||
func TestLogsAnUpgradedConnection(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
app := startApp(t, echoAfterUpgrade)
|
||||
addr, out := startProxy(t, app.URL, nil)
|
||||
|
||||
conn := dial(t, addr)
|
||||
send(t, conn, "GET /socket HTTP/1.1\r\nHost: app\r\n"+
|
||||
"Connection: Upgrade\r\nUpgrade: websocket\r\n\r\n")
|
||||
wantStatus(t, readResponse(t, conn), http.StatusSwitchingProtocols)
|
||||
|
||||
_ = conn.Close()
|
||||
|
||||
line := out.requestLine(t)
|
||||
if line.fields["websocket"] != true {
|
||||
t.Errorf("log line has websocket %v, want true", line.fields["websocket"])
|
||||
}
|
||||
}
|
||||
|
||||
@@ -185,8 +185,6 @@ func TestObserveModeLogsWhatTheRulesWouldDo(t *testing.T) {
|
||||
func TestMetricsCountRuleMatchesAndBansForAnAttack(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
const scraper = "192.0.2.200"
|
||||
|
||||
s, _, _ := startWithClock(t, "", map[string]string{
|
||||
rulesDir: writeRules(t, testRules),
|
||||
metricsToken: token,
|
||||
@@ -196,7 +194,7 @@ func TestMetricsCountRuleMatchesAndBansForAnAttack(t *testing.T) {
|
||||
s.request(client, "/.env", http.StatusForbidden, requestlog.ActionBanned)
|
||||
s.get(client, http.StatusForbidden, requestlog.ActionBanned)
|
||||
|
||||
metrics := s.scrape(scraper)
|
||||
metrics := s.scrape()
|
||||
wantMetric(t, metrics,
|
||||
`smallwebwaf_rule_matches_total{action="block",instance="app",rule_id="blocked"}`, 1)
|
||||
wantMetric(t, metrics,
|
||||
|
||||
+42
-60
@@ -6,6 +6,7 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/netip"
|
||||
"strconv"
|
||||
@@ -13,8 +14,6 @@ import (
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
|
||||
lru "github.com/hashicorp/golang-lru/v2"
|
||||
|
||||
"sneak.berlin/go/smallwebwaf/internal/requestlog"
|
||||
)
|
||||
|
||||
@@ -105,45 +104,38 @@ type Report struct {
|
||||
}
|
||||
|
||||
// NewServeWAF returns the server `smallwebwaf servewaf` runs. It answers
|
||||
// rproxy's questions with the checks New runs, takes rproxy's reports and
|
||||
// answers its own endpoints. It passes nothing to an app. rproxy applies
|
||||
// the size and time limits to the visitor, so their settings do not apply
|
||||
// here. Go's default limit on a request's line and headers, 1 MiB, stays:
|
||||
// it is far above rproxy's, and a question refused for its size would have
|
||||
// rproxy let the request through.
|
||||
// rproxy's questions, takes rproxy's reports and answers its own endpoints. It
|
||||
// passes nothing to an app. rproxy applies the size and time limits to the
|
||||
// visitor. Go's default limit on a request's line and headers, 1 MiB, stays: it
|
||||
// is far above rproxy's, and a question refused for its size would have rproxy
|
||||
// let the request through.
|
||||
func NewServeWAF(params Params) *Server {
|
||||
cfg := *params.Config
|
||||
cfg.RequestMaxBytes = 0
|
||||
cfg.ClientRequestTimeout = serveWAFTimeout
|
||||
// The server's WriteTimeout does this. It sets a new deadline for each
|
||||
// request, where a deadline a handler sets stays on a kept-open
|
||||
// connection, and would run out under a later request's answer.
|
||||
cfg.ClientResponseTimeout = 0
|
||||
// A question's body of QuestionBodyMaxBytes may be cut short, and the
|
||||
// Core Rule Set does not read a JSON or XML body over its limit.
|
||||
cfg.WAFBodyLimit = min(cfg.WAFBodyLimit, QuestionBodyMaxBytes-1)
|
||||
params.Config = &cfg
|
||||
|
||||
h := newHandler(params)
|
||||
|
||||
awaitingReport, err := lru.New[string, *decision](maxAwaitingReport)
|
||||
if err != nil {
|
||||
panic(err) // New fails only for a size below one
|
||||
return &Server{
|
||||
Server: &http.Server{
|
||||
Addr: params.Config.ListenAddr,
|
||||
Handler: h,
|
||||
ErrorLog: slog.NewLogLogger(params.ProcessLog.Handler(), slog.LevelWarn),
|
||||
ReadTimeout: serveWAFTimeout,
|
||||
WriteTimeout: serveWAFTimeout,
|
||||
IdleTimeout: serveWAFIdleTimeout,
|
||||
},
|
||||
Ledger: h.ledger,
|
||||
Limiter: h.limiter,
|
||||
GeoJS: h.geojs,
|
||||
Anomalies: h.anomalies,
|
||||
LookupFile: h.lookupFile,
|
||||
Lists: h.lists,
|
||||
DNSBL: h.dnsbl,
|
||||
AbuseIPDB: h.abuseIPDB,
|
||||
Metrics: h.metrics,
|
||||
}
|
||||
|
||||
h.awaitingReport = awaitingReport
|
||||
|
||||
return h.server(&http.Server{
|
||||
Handler: http.HandlerFunc(h.serveWAF),
|
||||
ReadTimeout: serveWAFTimeout,
|
||||
WriteTimeout: serveWAFTimeout,
|
||||
IdleTimeout: serveWAFIdleTimeout,
|
||||
})
|
||||
}
|
||||
|
||||
// serveWAF handles one request to servewaf: a request carrying
|
||||
// ClientHeader is rproxy's question, and any other is for servewaf itself.
|
||||
func (h *handler) serveWAF(w http.ResponseWriter, r *http.Request) {
|
||||
// ServeHTTP handles one request to servewaf: a request carrying ClientHeader is
|
||||
// rproxy's question, and any other is for servewaf itself.
|
||||
func (h *handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
if len(r.Header.Values(ClientHeader)) > 0 {
|
||||
h.answerQuestion(w, r)
|
||||
|
||||
@@ -153,12 +145,6 @@ func (h *handler) serveWAF(w http.ResponseWriter, r *http.Request) {
|
||||
h.answerOwnEndpoint(w, r)
|
||||
}
|
||||
|
||||
// answersQuestions reports whether h is servewaf, which alone holds requests
|
||||
// for their reports.
|
||||
func (h *handler) answersQuestions() bool {
|
||||
return h.awaitingReport != nil
|
||||
}
|
||||
|
||||
// refusedWhateverTheAnswer reports whether the request is one that does not wait
|
||||
// for GeoJS, since the checks will refuse it at now whatever GeoJS answers about
|
||||
// its client: it is over the header limits, a blocklist lists its client while
|
||||
@@ -188,10 +174,6 @@ func (rq *request) refusedWhateverTheAnswer(now time.Time) bool {
|
||||
// characters, of which a header value can hold only tabs, so a part of Unicode
|
||||
// spaces is a cookie to it.
|
||||
func (rq *request) overHeaderLimits() bool {
|
||||
if !rq.h.answersQuestions() {
|
||||
return false
|
||||
}
|
||||
|
||||
fields, cookies := 0, 0
|
||||
|
||||
for _, values := range rq.in.Header {
|
||||
@@ -244,7 +226,8 @@ func (h *handler) answerQuestion(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
// The last deferred runs first, as in ServeHTTP.
|
||||
// The last deferred runs first: countRefusal before addToHistory, so that a
|
||||
// broken error burst is in the client's history.
|
||||
defer rq.addToHistory()
|
||||
defer rq.countAnomalies()
|
||||
defer rq.countRefusal()
|
||||
@@ -374,10 +357,11 @@ func (rq *request) holdForReport() {
|
||||
|
||||
// deny answers a question with what the visitor is to get for r.
|
||||
func (rq *request) deny(r refusal) {
|
||||
rq.refused = &r
|
||||
|
||||
if r.status == 0 {
|
||||
// SWWAF_BAN_RESPONSE is close. Written past rq.out, since the log line's
|
||||
// status stays 0: the visitor is sent nothing.
|
||||
rq.refused.CompareAndSwap(nil, &r)
|
||||
rq.out.Header().Set(DispositionHeader, DispositionClose)
|
||||
rq.out.ResponseWriter.WriteHeader(http.StatusOK)
|
||||
|
||||
@@ -385,7 +369,7 @@ func (rq *request) deny(r refusal) {
|
||||
}
|
||||
|
||||
rq.out.Header().Set(DispositionHeader, DispositionDeny)
|
||||
rq.answer(r)
|
||||
http.Error(rq.out, http.StatusText(r.status), r.status)
|
||||
}
|
||||
|
||||
// answerOwnEndpoint answers a request to servewaf's own address. No check
|
||||
@@ -425,7 +409,8 @@ func (h *handler) takeReport(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
held, found := h.awaitingReport.Peek(report.RequestID)
|
||||
// Remove reports true to one report only, of several that name the id.
|
||||
// Remove reports true to one report only, of several that name the id. A
|
||||
// question still being handled is held as nil.
|
||||
if found && held != nil && h.awaitingReport.Remove(report.RequestID) {
|
||||
h.finishWith(held, report)
|
||||
}
|
||||
@@ -490,18 +475,17 @@ func (report *Report) checkRanges() error {
|
||||
|
||||
// finishWith finishes a request with what its report says, and writes the
|
||||
// request's last log line, which holds its decision's fields and the report's.
|
||||
// For an allowed request it first takes the steps ServeHTTP takes once a
|
||||
// request passed to the app has ended, with the status and bytes of the report
|
||||
// in place of those it would have seen. A denied request's counting was
|
||||
// finished at its decision.
|
||||
// For an allowed request it first counts the request's bytes, and adds it to its
|
||||
// client's history, the anomaly counters and the metrics, with the status and
|
||||
// bytes the report gives. A denied request's counting was finished at its
|
||||
// decision.
|
||||
func (h *handler) finishWith(held *decision, report Report) {
|
||||
rq := &request{
|
||||
decision: *held,
|
||||
h: h,
|
||||
out: &responseWriter{status: report.Status, bytes: report.ResponseBytes},
|
||||
body: &requestBody{},
|
||||
decision: *held,
|
||||
h: h,
|
||||
out: &responseWriter{status: report.Status, bytes: report.ResponseBytes},
|
||||
requestBytes: report.RequestBytes,
|
||||
}
|
||||
rq.body.bytes.Store(report.RequestBytes)
|
||||
|
||||
rq.line.ServeWAFLine = requestlog.ServeWAFLineReport
|
||||
rq.line.Status = report.Status
|
||||
@@ -511,8 +495,6 @@ func (h *handler) finishWith(held *decision, report Report) {
|
||||
|
||||
if rq.allowed {
|
||||
rq.line.DurationUpstreamTotal = &report.DurationUpstreamTotal
|
||||
// addToHistory goes by this for whether the request reached the app.
|
||||
rq.upstreamStart = time.Now()
|
||||
|
||||
rq.countBytes()
|
||||
rq.countAnomalies()
|
||||
|
||||
@@ -84,26 +84,12 @@ func startServeWAFWith(
|
||||
}
|
||||
maps.Copy(settings, env)
|
||||
|
||||
server, out, queue := newServerWith(t, proxy.NewServeWAF,
|
||||
"http://127.0.0.1:8081", geojsURL, clk.Now, settings)
|
||||
server, out, queue := newServeWAF(t, geojsURL, clk.Now, settings)
|
||||
change(server)
|
||||
|
||||
listener, err := (&net.ListenConfig{}).Listen(t.Context(), "tcp", localhost+":0")
|
||||
if err != nil {
|
||||
t.Fatalf("listen: %v", err)
|
||||
}
|
||||
|
||||
go func() {
|
||||
_ = server.Serve(listener)
|
||||
}()
|
||||
|
||||
t.Cleanup(func() {
|
||||
_ = server.Close()
|
||||
})
|
||||
serve(t, server)
|
||||
|
||||
return &serveWAF{
|
||||
t: t, addr: listener.Addr().String(), out: out, server: server,
|
||||
clock: clk, queue: queue,
|
||||
t: t, addr: server.Addr, out: out, server: server, clock: clk, queue: queue,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -626,13 +612,10 @@ func TestQuestionWhoseBodyStopsArrivingGetsNoAnswer(t *testing.T) {
|
||||
wantProcessMessages(t, s, "a question's body stopped arriving", 1)
|
||||
wantNoRequestLines(t, s)
|
||||
|
||||
// The id was not taken, and the metrics count nothing for it.
|
||||
// The id was not taken.
|
||||
again := s.question(siteA, client, "/")
|
||||
again.Header.Set(requestIDHeader, "an-id")
|
||||
wantDisposition(t, do(t, again), http.StatusOK, proxy.DispositionAllow)
|
||||
wantNoSeries(t, s.metrics(),
|
||||
`smallwebwaf_size_and_time_limit_hits_total{instance="`+alertInstance+
|
||||
`",limit="SWWAF_CLIENT_REQUEST_TIMEOUT"}`)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1466,16 +1449,10 @@ func TestOwnEndpointsLinesDoNotSayWhichOfTheTwoTheyAre(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestSettingsOfTheLimitsAndTheProxiesAreIgnored(t *testing.T) {
|
||||
func TestALargeBodyAnnouncedAndLongHeadersAreNoReasonToRefuse(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
s := startServeWAF(t, "", map[string]string{
|
||||
requestMaxBytes: "1",
|
||||
clientHeaderMaxBytes: "5K",
|
||||
clientRequestTimeout: off,
|
||||
clientResponseTimeout: off,
|
||||
trustedProxies: localhost,
|
||||
})
|
||||
s := startServeWAF(t, "", nil)
|
||||
|
||||
req := s.questionWith(http.MethodPost, siteA, client, "/",
|
||||
strings.NewReader("a body"))
|
||||
@@ -1493,33 +1470,23 @@ func TestSettingsOfTheLimitsAndTheProxiesAreIgnored(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestShortClientResponseTimeoutIsIgnored(t *testing.T) {
|
||||
func TestLogLevelHoldsBackNoRequestLine(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// The setting would leave no time to send any answer.
|
||||
s := startServeWAF(t, "", map[string]string{
|
||||
clientResponseTimeout: time.Nanosecond.String(), denyNets: client,
|
||||
})
|
||||
s := startServeWAF(t, "", map[string]string{"SWWAF_LOG_LEVEL": "error"})
|
||||
|
||||
wantDisposition(t, s.ask(client, "/"), http.StatusForbidden, proxy.DispositionDeny)
|
||||
wantStatus(t, get(t, s.addr, proxy.HealthPath), http.StatusOK)
|
||||
}
|
||||
// A refused question would write a process warning, held back at error.
|
||||
req := s.question(siteA, client, "/")
|
||||
req.Header.Del(requestIDHeader)
|
||||
wantStatus(t, do(t, req), http.StatusBadRequest)
|
||||
|
||||
func TestShortClientRequestTimeoutIsIgnored(t *testing.T) {
|
||||
t.Parallel()
|
||||
s.allowed(client)
|
||||
wantLine(t, s.out.requestLine(t), http.StatusOK, requestlog.ActionForward)
|
||||
|
||||
// The setting would have run out while the Core Rule Set read the body. Go's
|
||||
// server would then take the connection for gone, and the line say aborted.
|
||||
s := startServeWAF(t, "", map[string]string{
|
||||
clientRequestTimeout: time.Nanosecond.String(), wafMode: block, wafBodyLimit: "1M",
|
||||
})
|
||||
|
||||
req := s.questionWith(http.MethodPost, siteA, client, "/", strings.NewReader("a=1"))
|
||||
req.Header.Set("Content-Type", formData)
|
||||
wantDisposition(t, do(t, req), http.StatusOK, proxy.DispositionAllow)
|
||||
|
||||
if line := s.out.requestLine(t); line.Aborted {
|
||||
t.Errorf("log line says aborted: %+v", line.Line)
|
||||
for _, line := range s.out.lines(t) {
|
||||
if line["type"] == "process" {
|
||||
t.Errorf("process line %v, want none at error", line)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1573,7 +1540,7 @@ func TestCoreRuleSetReadsTheBodyAQuestionCarries(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
s := startServeWAF(t, "", map[string]string{
|
||||
wafMode: block, wafBodyLimit: "1M",
|
||||
wafMode: block, wafBodyLimit: "64K",
|
||||
})
|
||||
|
||||
ask := func(contentType, body string) answer {
|
||||
|
||||
@@ -17,7 +17,7 @@ const (
|
||||
unlistedAddr = "2001:db8::2"
|
||||
)
|
||||
|
||||
func TestAllowNetsSkipEveryCheckButTheSizeLimit(t *testing.T) {
|
||||
func TestAllowNetsSkipEveryCheck(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var calls atomic.Int32
|
||||
@@ -34,7 +34,6 @@ func TestAllowNetsSkipEveryCheckButTheSizeLimit(t *testing.T) {
|
||||
denyNets: fromKP,
|
||||
deniedCountries: "kp",
|
||||
rateLimitPerMinute: "1",
|
||||
requestMaxBytes: "1K",
|
||||
})
|
||||
|
||||
// Neither SWWAF_DENY_NETS, the country lists nor the limit of one
|
||||
@@ -49,14 +48,6 @@ func TestAllowNetsSkipEveryCheckButTheSizeLimit(t *testing.T) {
|
||||
t.Errorf("GeoJS was asked about %v, want nothing", asked())
|
||||
}
|
||||
|
||||
// The size limit still applies.
|
||||
body := strings.NewReader(strings.Repeat("a", 2<<10))
|
||||
req := newRequest(t, http.MethodPost, addr, "/", body)
|
||||
req.Header.Set(forwardedFor, fromKP)
|
||||
wantStatus(t, do(t, req), http.StatusRequestEntityTooLarge)
|
||||
wantLine(t, out.requestLines(t, 3)[2],
|
||||
http.StatusRequestEntityTooLarge, requestlog.ActionTooLarge)
|
||||
|
||||
if calls.Load() != 2 {
|
||||
t.Errorf("the app was called %d times, want 2", calls.Load())
|
||||
}
|
||||
@@ -82,7 +73,7 @@ func TestRequestFromAllowNetsIsNotCounted(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
func TestDenyNetsRefuseBeforeTheLookupAndTheBody(t *testing.T) {
|
||||
func TestDenyNetsRefuseBeforeTheLookup(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var calls atomic.Int32
|
||||
@@ -101,13 +92,9 @@ func TestDenyNetsRefuseBeforeTheLookupAndTheBody(t *testing.T) {
|
||||
req.Header.Set(forwardedFor, fromDE)
|
||||
wantStatus(t, do(t, req), http.StatusForbidden)
|
||||
|
||||
line := out.requestLine(t)
|
||||
line := out.endedLine(t)
|
||||
wantLine(t, line, http.StatusForbidden, requestlog.ActionDenied)
|
||||
|
||||
if line.RequestBytes != 0 {
|
||||
t.Errorf("log line has request_bytes %d, want 0", line.RequestBytes)
|
||||
}
|
||||
|
||||
if len(asked()) != 0 {
|
||||
t.Errorf("GeoJS was asked about %v, want nothing", asked())
|
||||
}
|
||||
@@ -180,6 +167,6 @@ func wantAnswers(t *testing.T, addr string, out *output, requests []sentRequest)
|
||||
req := newRequest(t, http.MethodGet, addr, "/", http.NoBody)
|
||||
req.Header.Set(forwardedFor, sent.client)
|
||||
wantStatus(t, do(t, req), sent.status)
|
||||
wantLine(t, out.requestLines(t, i+1)[i], sent.status, sent.action)
|
||||
wantLine(t, out.endedLines(t, i+1)[i], sent.status, sent.action)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,453 +0,0 @@
|
||||
package proxy_test
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"sneak.berlin/go/smallwebwaf/internal/requestlog"
|
||||
)
|
||||
|
||||
// largeBodySize is more than the connections between the client,
|
||||
// smallwebwaf and the app can hold while nobody reads, so that a sender
|
||||
// soon waits.
|
||||
const largeBodySize = 64 << 20
|
||||
|
||||
// writeSize is how much a test sender writes at a time.
|
||||
const writeSize = 32 << 10
|
||||
|
||||
func TestRequestTimeouts(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
// limit is the setting set to shortTimeout, which runs out; long
|
||||
// is one set to longTimeoutSetting, which does not, or "".
|
||||
limit, long string
|
||||
// appTakesNothing has the app never read, while the client sends
|
||||
// as fast as it can; otherwise the app reads, and the client
|
||||
// stops sending halfway.
|
||||
appTakesNothing bool
|
||||
want int
|
||||
}{
|
||||
{
|
||||
name: "client request timeout, waiting on the client",
|
||||
limit: clientRequestTimeout,
|
||||
want: http.StatusRequestTimeout,
|
||||
},
|
||||
{
|
||||
name: "upstream request timeout, waiting on the client",
|
||||
limit: upstreamRequestTimeout,
|
||||
long: clientRequestTimeout,
|
||||
want: http.StatusRequestTimeout,
|
||||
},
|
||||
{
|
||||
name: "upstream request timeout, waiting on the app",
|
||||
limit: upstreamRequestTimeout,
|
||||
appTakesNothing: true,
|
||||
want: http.StatusGatewayTimeout,
|
||||
},
|
||||
{
|
||||
name: "client request timeout, waiting on the app",
|
||||
limit: clientRequestTimeout,
|
||||
long: upstreamRequestTimeout,
|
||||
appTakesNothing: true,
|
||||
want: http.StatusGatewayTimeout,
|
||||
},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var (
|
||||
app *httptest.Server
|
||||
appURL string
|
||||
sendRequest func(*testing.T, string) net.Conn
|
||||
appGotBody atomic.Bool
|
||||
)
|
||||
|
||||
if tc.appTakesNothing {
|
||||
appURL, sendRequest = startAppThatTakesNothing(t), sendLargeBody
|
||||
} else {
|
||||
app = startApp(t, func(_ http.ResponseWriter, r *http.Request) {
|
||||
n, _ := io.Copy(io.Discard, r.Body)
|
||||
appGotBody.Store(n > 0)
|
||||
})
|
||||
appURL, sendRequest = app.URL, sendPartOfBody
|
||||
}
|
||||
|
||||
env := map[string]string{tc.limit: shortTimeoutSetting, metricsToken: token}
|
||||
if tc.long != "" {
|
||||
env[tc.long] = longTimeoutSetting
|
||||
}
|
||||
|
||||
addr, out := startProxy(t, appURL, env)
|
||||
start := time.Now()
|
||||
got := readResponse(t, sendRequest(t, addr))
|
||||
wantTimedOut(t, start)
|
||||
|
||||
want := tc.want
|
||||
|
||||
if app != nil {
|
||||
// Close returns once the app has finished with the request.
|
||||
app.Close()
|
||||
|
||||
// Until some of the body has reached the app, smallwebwaf
|
||||
// waits on the app, and SPEC.md asks for 504; the timeout
|
||||
// runs out then only if the test process is held up.
|
||||
if !appGotBody.Load() {
|
||||
want = http.StatusGatewayTimeout
|
||||
}
|
||||
}
|
||||
|
||||
wantStatus(t, got, want)
|
||||
wantLine(t, out.requestLine(t), want, requestlog.ActionTimedOut)
|
||||
wantLimitHits(t, addr, tc.limit, 1)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// startAppThatTakesNothing starts an app that accepts connections and
|
||||
// never reads from them, and returns its URL.
|
||||
func startAppThatTakesNothing(t *testing.T) string {
|
||||
t.Helper()
|
||||
|
||||
listener, err := (&net.ListenConfig{}).Listen(t.Context(), "tcp", localhost+":0")
|
||||
if err != nil {
|
||||
t.Fatalf("listen: %v", err)
|
||||
}
|
||||
|
||||
var (
|
||||
mu sync.Mutex
|
||||
held []net.Conn
|
||||
)
|
||||
|
||||
hold := func(conn net.Conn) {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
|
||||
held = append(held, conn)
|
||||
}
|
||||
|
||||
go func() {
|
||||
for {
|
||||
conn, err := listener.Accept()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
hold(conn)
|
||||
}
|
||||
}()
|
||||
|
||||
t.Cleanup(func() {
|
||||
_ = listener.Close()
|
||||
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
|
||||
for _, conn := range held {
|
||||
_ = conn.Close()
|
||||
}
|
||||
})
|
||||
|
||||
return "http://" + listener.Addr().String()
|
||||
}
|
||||
|
||||
// sendPartOfBody sends a request that announces a large body, and only
|
||||
// the first bytes of it.
|
||||
func sendPartOfBody(t *testing.T, addr string) net.Conn {
|
||||
t.Helper()
|
||||
|
||||
conn := dial(t, addr)
|
||||
send(t, conn, "POST /upload HTTP/1.1\r\nHost: app\r\nContent-Length: "+
|
||||
strconv.Itoa(largeBodySize)+"\r\n\r\nthe first bytes")
|
||||
|
||||
return conn
|
||||
}
|
||||
|
||||
// sendLargeBody sends a request with a large body, as fast as smallwebwaf
|
||||
// takes it, from a goroutine of its own.
|
||||
func sendLargeBody(t *testing.T, addr string) net.Conn {
|
||||
t.Helper()
|
||||
|
||||
conn := dial(t, addr)
|
||||
send(t, conn, "POST /upload HTTP/1.1\r\nHost: app\r\nContent-Length: "+
|
||||
strconv.Itoa(largeBodySize)+"\r\n\r\n")
|
||||
|
||||
go func() {
|
||||
chunk := make([]byte, writeSize)
|
||||
for range largeBodySize / writeSize {
|
||||
_, err := conn.Write(chunk)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
return conn
|
||||
}
|
||||
|
||||
func TestClientStopsSendingAfterTheAppHasAnswered(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// answeredAt is when the app answered, or nil if the bytes the client
|
||||
// sent never reached it.
|
||||
var answeredAt atomic.Pointer[time.Time]
|
||||
|
||||
// The app answers without reading the whole body, as an app does for
|
||||
// a path it does not serve, and only once smallwebwaf waits for the
|
||||
// rest of it: the app takes the bytes the client sent, and then waits
|
||||
// half the timeout, far longer than smallwebwaf takes to go on to
|
||||
// wait.
|
||||
app := startApp(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
_, err := io.ReadFull(r.Body, make([]byte, len("the first bytes")))
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
time.Sleep(shortTimeout / 2)
|
||||
answeredAt.Store(new(time.Now()))
|
||||
http.NotFound(w, r)
|
||||
})
|
||||
addr, out := startProxy(t, app.URL, map[string]string{
|
||||
clientRequestTimeout: shortTimeoutSetting,
|
||||
metricsToken: token,
|
||||
})
|
||||
|
||||
start := time.Now()
|
||||
got := readResponse(t, sendPartOfBody(t, addr))
|
||||
wantTimedOut(t, start)
|
||||
|
||||
// Close returns once the app has finished with the request.
|
||||
app.Close()
|
||||
|
||||
// The app's answer came before the time ran out, so the client is
|
||||
// sent that answer, not smallwebwaf's 408. Only a hold-up of the test
|
||||
// process can make it come later, and SPEC.md then asks for 408, as
|
||||
// smallwebwaf was waiting for the client, or for 504 if the bytes the
|
||||
// client sent had not reached the app.
|
||||
at := answeredAt.Load()
|
||||
want := http.StatusNotFound
|
||||
|
||||
switch {
|
||||
case at == nil:
|
||||
want = http.StatusGatewayTimeout
|
||||
case at.Sub(start) >= shortTimeout:
|
||||
want = http.StatusRequestTimeout
|
||||
}
|
||||
|
||||
wantStatus(t, got, want)
|
||||
wantLine(t, out.requestLine(t), want, requestlog.ActionTimedOut)
|
||||
wantLimitHits(t, addr, clientRequestTimeout, 1)
|
||||
}
|
||||
|
||||
func TestClientStopsSendingWithLittleOfItsBodyLeft(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
// limit is the setting set to shortTimeout, which runs out, and
|
||||
// clientTimeout what SWWAF_CLIENT_REQUEST_TIMEOUT is set to when it
|
||||
// is not limit.
|
||||
limit, clientTimeout string
|
||||
}{
|
||||
{
|
||||
name: "client request timeout",
|
||||
limit: clientRequestTimeout,
|
||||
},
|
||||
{
|
||||
name: "upstream request timeout, client request timeout off",
|
||||
limit: upstreamRequestTimeout,
|
||||
clientTimeout: off,
|
||||
},
|
||||
{
|
||||
name: "upstream request timeout, client request timeout longer",
|
||||
limit: upstreamRequestTimeout,
|
||||
clientTimeout: longTimeoutSetting,
|
||||
},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// appClosed is closed once smallwebwaf has closed its
|
||||
// connection to the app, which it does once it has the app's
|
||||
// answer.
|
||||
appClosed := make(chan struct{})
|
||||
|
||||
// The app answers without reading the rest of the body, and
|
||||
// says it closes the connection, only once smallwebwaf waits
|
||||
// for the client to send more: it takes the bytes the client
|
||||
// sent, and then waits half the timeout, far longer than
|
||||
// smallwebwaf takes to go on to wait.
|
||||
app := startApp(t, func(w http.ResponseWriter, _ *http.Request) {
|
||||
conn, buf, err := http.NewResponseController(w).Hijack()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
_, _ = io.ReadFull(buf.Reader, make([]byte, len("the first bytes")))
|
||||
|
||||
time.Sleep(shortTimeout / 2)
|
||||
|
||||
_, _ = buf.WriteString("HTTP/1.1 404 Not Found\r\n" +
|
||||
"Content-Length: 0\r\nConnection: close\r\n\r\n")
|
||||
_ = buf.Flush()
|
||||
_, _ = io.Copy(io.Discard, buf.Reader)
|
||||
_ = conn.Close()
|
||||
|
||||
close(appClosed)
|
||||
})
|
||||
|
||||
env := map[string]string{tc.limit: shortTimeoutSetting, metricsToken: token}
|
||||
if tc.clientTimeout != "" {
|
||||
env[clientRequestTimeout] = tc.clientTimeout
|
||||
}
|
||||
|
||||
addr, out := startProxy(t, app.URL, env)
|
||||
|
||||
// Less than 256 KiB of the body is left after the first bytes,
|
||||
// little enough for Go's server in smallwebwaf to read the rest
|
||||
// itself before it sends the app's answer.
|
||||
start := time.Now()
|
||||
conn := dial(t, addr)
|
||||
send(t, conn, "POST /upload HTTP/1.1\r\nHost: app\r\nContent-Length: 200000\r\n\r\n"+
|
||||
"the first bytes")
|
||||
|
||||
select {
|
||||
case <-appClosed:
|
||||
case <-time.After(waitLimit):
|
||||
t.Fatal("smallwebwaf did not close its connection to the app")
|
||||
}
|
||||
|
||||
// More of the body, which can no longer go to the app, and then
|
||||
// nothing.
|
||||
send(t, conn, strings.Repeat("x", 8<<10))
|
||||
|
||||
got := readResponse(t, conn)
|
||||
wantTimedOut(t, start)
|
||||
wantStatus(t, got, http.StatusNotFound)
|
||||
wantLine(t, out.requestLine(t), http.StatusNotFound, requestlog.ActionTimedOut)
|
||||
wantLimitHits(t, addr, tc.limit, 1)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppTooSlowToAnswer(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
app := startApp(t, func(_ http.ResponseWriter, r *http.Request) {
|
||||
<-r.Context().Done()
|
||||
})
|
||||
addr, out := startProxy(t, app.URL, map[string]string{
|
||||
upstreamResponseTimeout: shortTimeoutSetting,
|
||||
metricsToken: token,
|
||||
})
|
||||
|
||||
start := time.Now()
|
||||
|
||||
wantStatus(t, get(t, addr, "/slow"), http.StatusGatewayTimeout)
|
||||
wantTimedOut(t, start)
|
||||
|
||||
line := out.requestLine(t)
|
||||
wantLine(t, line, http.StatusGatewayTimeout, requestlog.ActionTimedOut)
|
||||
|
||||
_, answered := line.fields["upstream_status"]
|
||||
if answered {
|
||||
t.Errorf("log line has upstream_status %v for an app that never answered",
|
||||
line.fields["upstream_status"])
|
||||
}
|
||||
|
||||
wantLimitHits(t, addr, upstreamResponseTimeout, 1)
|
||||
}
|
||||
|
||||
func TestAppTooSlowToFinishItsAnswer(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
app := startApp(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
_, _ = io.WriteString(w, "the first part")
|
||||
_ = http.NewResponseController(w).Flush()
|
||||
|
||||
<-r.Context().Done()
|
||||
})
|
||||
addr, out := startProxy(t, app.URL, map[string]string{
|
||||
upstreamResponseTimeout: shortTimeoutSetting,
|
||||
})
|
||||
|
||||
start := time.Now()
|
||||
got := get(t, addr, "/slow")
|
||||
wantStatus(t, got, http.StatusOK)
|
||||
|
||||
if string(got.body) != "the first part" || !errors.Is(got.err, io.ErrUnexpectedEOF) {
|
||||
t.Errorf("client read %q (%v), want the first part cut off", got.body, got.err)
|
||||
}
|
||||
|
||||
wantTimedOut(t, start)
|
||||
|
||||
line := out.requestLine(t)
|
||||
wantLine(t, line, http.StatusOK, requestlog.ActionTimedOut)
|
||||
|
||||
if line.UpstreamStatus != http.StatusOK {
|
||||
t.Errorf("log line has upstream_status %d, want %d",
|
||||
line.UpstreamStatus, http.StatusOK)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClientTooSlowToTakeTheAnswer(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
app := startApp(t, func(w http.ResponseWriter, _ *http.Request) {
|
||||
chunk := make([]byte, writeSize)
|
||||
for range largeBodySize / writeSize {
|
||||
_, err := w.Write(chunk)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
})
|
||||
addr, out := startProxy(t, app.URL, map[string]string{
|
||||
clientResponseTimeout: shortTimeoutSetting,
|
||||
metricsToken: token,
|
||||
})
|
||||
|
||||
start := time.Now()
|
||||
|
||||
// The client asks, and never reads the answer.
|
||||
conn := dial(t, addr)
|
||||
send(t, conn, "GET /large HTTP/1.1\r\nHost: app\r\n\r\n")
|
||||
|
||||
line := out.requestLine(t)
|
||||
wantTimedOut(t, start)
|
||||
wantLine(t, line, http.StatusOK, requestlog.ActionTimedOut)
|
||||
wantLimitHits(t, addr, clientResponseTimeout, 1)
|
||||
}
|
||||
|
||||
func TestClosesAnIdleConnection(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
app := startApp(t, func(http.ResponseWriter, *http.Request) {})
|
||||
addr, _ := startProxy(t, app.URL, map[string]string{
|
||||
clientIdleTimeout: shortTimeoutSetting,
|
||||
})
|
||||
|
||||
// The idle time starts once the answer is sent, so after start.
|
||||
start := time.Now()
|
||||
conn := dial(t, addr)
|
||||
send(t, conn, "GET / HTTP/1.1\r\nHost: app\r\n\r\n")
|
||||
wantStatus(t, readResponse(t, conn), http.StatusOK)
|
||||
|
||||
// The read deadline readResponse set still bounds this read.
|
||||
_, err := conn.Read(make([]byte, 1))
|
||||
if !errors.Is(err, io.EOF) {
|
||||
t.Fatalf("read on the idle connection: %v, want it closed", err)
|
||||
}
|
||||
|
||||
wantTimedOut(t, start)
|
||||
}
|
||||
@@ -23,8 +23,8 @@ func TestHistoryKeepsEveryRequest(t *testing.T) {
|
||||
{Forwarded: true, Status: 502, ResponseBytes: 12},
|
||||
// Closed without an answer: refused, and no response.
|
||||
{Refused: true, Status: 0},
|
||||
// Answered 404 at smallwebwaf's own endpoints: neither forwarded
|
||||
// nor refused.
|
||||
// Answered 404 for a path under /_smallwebwaf/: neither forwarded nor
|
||||
// refused.
|
||||
{Status: 404},
|
||||
} {
|
||||
limiter.AddToHistory(client, start.Add(time.Duration(i)*time.Minute), r)
|
||||
|
||||
@@ -92,10 +92,9 @@ type History struct {
|
||||
ASName string `json:"as_name,omitempty"`
|
||||
Country string `json:"country,omitempty"`
|
||||
LookedUp time.Time `json:"looked_up,omitzero"`
|
||||
// Requests are all the client's requests: Forwarded those passed to
|
||||
// the app, Refused those refused before anything reached it, a 401 at
|
||||
// smallwebwaf's own endpoints included, and neither the others
|
||||
// smallwebwaf answered there.
|
||||
// Requests are all the client's requests: Forwarded those servewaf allowed,
|
||||
// Refused those it refused, and neither those for a path under
|
||||
// /_smallwebwaf/.
|
||||
Requests int64 `json:"requests"`
|
||||
Forwarded int64 `json:"forwarded"`
|
||||
Refused int64 `json:"refused"`
|
||||
@@ -124,21 +123,20 @@ type Offences struct {
|
||||
// Limit is its requests that broke a rate limit, a byte limit or the
|
||||
// error burst, Attack those that were a clear sign of attack, a match
|
||||
// of a ban rule or a request for a trap path, RuleBlocked those a block
|
||||
// rule refused, WAFBlocked those the Core Rule Set refused, and
|
||||
// TokenRefused those refused for a missing or wrong token.
|
||||
Limit int64 `json:"limit"`
|
||||
Attack int64 `json:"attack"`
|
||||
RuleBlocked int64 `json:"rule_blocked"`
|
||||
WAFBlocked int64 `json:"waf_blocked"`
|
||||
TokenRefused int64 `json:"token_refused"`
|
||||
// rule refused, and WAFBlocked those the Core Rule Set refused.
|
||||
Limit int64 `json:"limit"`
|
||||
Attack int64 `json:"attack"`
|
||||
RuleBlocked int64 `json:"rule_blocked"`
|
||||
WAFBlocked int64 `json:"waf_blocked"`
|
||||
// TokenRefused is what a clients.json written by an earlier version gives,
|
||||
// which must still load. Nothing adds to it.
|
||||
TokenRefused int64 `json:"token_refused,omitempty"`
|
||||
}
|
||||
|
||||
// Request is what a client's history keeps of one of its requests.
|
||||
type Request struct {
|
||||
// Forwarded is true for a request passed to the app, Refused for one
|
||||
// refused before anything reached it, a 401 at smallwebwaf's own
|
||||
// endpoints included. Both are false for any other request smallwebwaf
|
||||
// answered there.
|
||||
// Forwarded is true for a request servewaf allowed, and Refused for one it
|
||||
// refused. Both are false for a request for a path under /_smallwebwaf/.
|
||||
Forwarded bool
|
||||
Refused bool
|
||||
// Status is what the client was sent, 0 if nothing was.
|
||||
@@ -149,14 +147,12 @@ type Request struct {
|
||||
ResponseBytes int64
|
||||
// BrokeLimit is true for a request that broke a rate limit, a byte
|
||||
// limit or the error burst, Attack for one that matched a ban rule or
|
||||
// asked for a trap path, RuleBlocked for one a block rule refused,
|
||||
// WAFBlocked for one the Core Rule Set refused, and TokenRefused for
|
||||
// one refused for a missing or wrong token.
|
||||
BrokeLimit bool
|
||||
Attack bool
|
||||
RuleBlocked bool
|
||||
WAFBlocked bool
|
||||
TokenRefused bool
|
||||
// asked for a trap path, RuleBlocked for one a block rule refused, and
|
||||
// WAFBlocked for one the Core Rule Set refused.
|
||||
BrokeLimit bool
|
||||
Attack bool
|
||||
RuleBlocked bool
|
||||
WAFBlocked bool
|
||||
}
|
||||
|
||||
// New returns a Limiter for limits, with no client counted yet, whose
|
||||
@@ -334,10 +330,6 @@ func (l *Limiter) AddToHistory(client netip.Prefix, now time.Time, r Request) {
|
||||
if r.WAFBlocked {
|
||||
h.Offences.WAFBlocked++
|
||||
}
|
||||
|
||||
if r.TokenRefused {
|
||||
h.Offences.TokenRefused++
|
||||
}
|
||||
}
|
||||
|
||||
// AddLookup gives client's history its AS number, AS name and country, as
|
||||
|
||||
@@ -14,19 +14,13 @@ import (
|
||||
"sneak.berlin/go/smallwebwaf/internal/ratelimit"
|
||||
)
|
||||
|
||||
// The action a request line names: what smallwebwaf did with the
|
||||
// request.
|
||||
// The action a request line names: what servewaf did with the request.
|
||||
const (
|
||||
// ActionForward is a request passed to the app.
|
||||
// ActionForward is a request servewaf allowed, which rproxy passes to the
|
||||
// app.
|
||||
ActionForward = "forward"
|
||||
// ActionTooLarge is a request or response over its size limit, or, in
|
||||
// servewaf, a request over the header limits.
|
||||
// ActionTooLarge is a request over the header limits.
|
||||
ActionTooLarge = "too_large"
|
||||
// ActionTimedOut is a request or response that ran out of time.
|
||||
ActionTimedOut = "timed_out"
|
||||
// ActionUpstreamError is a request the app could not be reached
|
||||
// for, or whose answer could not be passed on.
|
||||
ActionUpstreamError = "upstream_error"
|
||||
// ActionRateLimited is a request refused because it took its client
|
||||
// over a rate limit, which bans the client.
|
||||
ActionRateLimited = "rate_limited"
|
||||
@@ -48,8 +42,8 @@ const (
|
||||
ActionDenied = "denied"
|
||||
// ActionCountryDenied is a request refused for its client's country.
|
||||
ActionCountryDenied = "country_denied"
|
||||
// ActionAdmin is a request smallwebwaf answered at one of its own
|
||||
// endpoints, under /_smallwebwaf/.
|
||||
// ActionAdmin is a request servewaf answered at one of its own endpoints,
|
||||
// or a question about a path under /_smallwebwaf/.
|
||||
ActionAdmin = "admin"
|
||||
)
|
||||
|
||||
@@ -86,8 +80,8 @@ type Line struct {
|
||||
// two lines about a question.
|
||||
ServeWAFLine string `json:"servewaf_line,omitempty"`
|
||||
|
||||
// The standard web log fields. Scheme is how the client reached
|
||||
// smallwebwaf, or the trusted proxy in front of it.
|
||||
// The standard web log fields. Scheme is how the client reached rproxy, or
|
||||
// the trusted proxy in front of it.
|
||||
Time string `json:"time"`
|
||||
Instance string `json:"instance"`
|
||||
ClientIP string `json:"client_ip"`
|
||||
@@ -103,8 +97,8 @@ type Line struct {
|
||||
Referer string `json:"referer"`
|
||||
UserAgent string `json:"user_agent"`
|
||||
|
||||
// Request detail. RequestID is the X-Request-ID a trusted proxy sent,
|
||||
// or a new one, and is sent on to the app. ForwardedFor is the
|
||||
// Request detail. RequestID is the id rproxy made for the request, or for a
|
||||
// request to servewaf's own endpoints a new one. ForwardedFor is the
|
||||
// X-Forwarded-For header as received. ClientGroup is the netblock the
|
||||
// client is counted as. ASN, ASName and Country are the client's AS
|
||||
// number, AS name and country, as looked up.
|
||||
@@ -123,17 +117,10 @@ type Line struct {
|
||||
RequestHeaders map[string]string `json:"request_headers,omitempty"`
|
||||
HasAuthorization bool `json:"has_authorization,omitempty"`
|
||||
HasCookie bool `json:"has_cookie,omitempty"`
|
||||
// Websocket is true when the connection was upgraded, as for a
|
||||
// WebSocket.
|
||||
Websocket bool `json:"websocket,omitempty"`
|
||||
|
||||
// Response detail, from the headers of the answer: the app's, as
|
||||
// passed on, or those of smallwebwaf's own. Aborted is true when the
|
||||
// client went away early.
|
||||
// Response detail, of servewaf's own answer. Aborted is true when rproxy, or
|
||||
// the admin's tool, went away before the answer.
|
||||
ResponseContentType string `json:"response_content_type,omitempty"`
|
||||
UpstreamStatus int `json:"upstream_status,omitempty"`
|
||||
CacheControl string `json:"cache_control,omitempty"`
|
||||
Location string `json:"location,omitempty"`
|
||||
Aborted bool `json:"aborted,omitempty"`
|
||||
|
||||
// The decision.
|
||||
@@ -183,17 +170,13 @@ type Line struct {
|
||||
|
||||
// The timings, in milliseconds. DurationChecks is the time until the
|
||||
// checks were done, and DurationWAF the part of it the Core Rule Set
|
||||
// took. DurationUpstreamConnect, DurationUpstreamFirstByte and
|
||||
// DurationUpstreamTotal run from when the request was handed to the
|
||||
// app: until there was a connection to it, until the first byte of its
|
||||
// answer arrived, and until the end. Each but DurationTotal is nil for
|
||||
// a request that did not get that far.
|
||||
DurationTotal float64 `json:"duration_total"`
|
||||
DurationChecks *float64 `json:"duration_checks,omitempty"`
|
||||
DurationWAF *float64 `json:"duration_waf,omitempty"`
|
||||
DurationUpstreamConnect *float64 `json:"duration_upstream_connect,omitempty"`
|
||||
DurationUpstreamFirstByte *float64 `json:"duration_upstream_first_byte,omitempty"`
|
||||
DurationUpstreamTotal *float64 `json:"duration_upstream_total,omitempty"`
|
||||
// took. DurationUpstreamTotal is what the report of an allowed request gives,
|
||||
// from the hand-over to the app until the end of its answer. Each but
|
||||
// DurationTotal is nil for a request that did not get that far.
|
||||
DurationTotal float64 `json:"duration_total"`
|
||||
DurationChecks *float64 `json:"duration_checks,omitempty"`
|
||||
DurationWAF *float64 `json:"duration_waf,omitempty"`
|
||||
DurationUpstreamTotal *float64 `json:"duration_upstream_total,omitempty"`
|
||||
}
|
||||
|
||||
// RProxyLine is rproxy's short line in the request log about one request. The
|
||||
|
||||
@@ -128,6 +128,52 @@ func TestQuestionNamesTheVisitorATrustedProxySpeaksFor(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestQuestionNamesTheClientAsTheForwardedAddressesSay(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
header []string
|
||||
client string
|
||||
}{
|
||||
{
|
||||
"the first address outside the trusted proxies, from the right",
|
||||
[]string{"198.51.100.7, 203.0.113.9, 10.0.0.2"}, "203.0.113.9",
|
||||
},
|
||||
{
|
||||
"the leftmost, when every address is a trusted proxy",
|
||||
[]string{"10.0.0.5, 10.0.0.2"}, "10.0.0.5",
|
||||
},
|
||||
{"the peer, with no header", nil, localhost},
|
||||
{
|
||||
"the last address read, before an entry that is not an address",
|
||||
[]string{"203.0.113.9, unknown, 10.0.0.2"}, "10.0.0.2",
|
||||
},
|
||||
{
|
||||
"read as one list, with several header lines",
|
||||
[]string{"2001:db8::7", "10.0.0.2"}, "2001:db8::7",
|
||||
},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
servewaf := startServeWAF(t, allow)
|
||||
app := startApp(t, greet)
|
||||
addr, _ := startRProxy(t, servewaf.URL, app.URL, map[string]string{
|
||||
trustedProxies: localhost + "/32,10.0.0.0/8",
|
||||
})
|
||||
|
||||
req := newRequest(t, http.MethodGet, addr, "/", http.NoBody)
|
||||
req.Header[forwardedForHeader] = tc.header
|
||||
wantGreeting(t, visit(t, req))
|
||||
|
||||
if got := servewaf.question(t).header.Get(proxy.ClientHeader); got != tc.client {
|
||||
t.Errorf("the question names %s, want %s", got, tc.client)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// randomBytes returns n random bytes.
|
||||
func randomBytes(n int) []byte {
|
||||
body := make([]byte, n)
|
||||
|
||||
@@ -17,14 +17,15 @@ import (
|
||||
// healthCheckTimeout bounds the whole health check.
|
||||
const healthCheckTimeout = 5 * time.Second
|
||||
|
||||
var errHealthEndpoint = errors.New("smallwebwaf's health endpoint answered")
|
||||
var errHealthEndpoint = errors.New("the health endpoint answered")
|
||||
|
||||
// HealthCheck is the container's health check. It returns 0 while
|
||||
// smallwebwaf answers its health endpoint on 127.0.0.1, at the port in
|
||||
// SWWAF_LISTEN_ADDR, and the app accepts connections at the address in
|
||||
// SWWAF_UPSTREAM_URL. Otherwise it writes why to stderr and returns 1.
|
||||
// It reads no other setting, nor a file that another names, so neither
|
||||
// can fail it.
|
||||
// HealthCheck is the container's health check. In servewaf's container, where
|
||||
// SMALLWEBWAF_COMMAND is servewaf, it returns 0 while servewaf answers its health
|
||||
// endpoint on 127.0.0.1, at the port in SWWAF_LISTEN_ADDR. In any other container
|
||||
// it returns 0 while rproxy answers its health endpoint on 127.0.0.1, at the port
|
||||
// in SWRP_LISTEN_ADDR, and the app accepts connections at the address in
|
||||
// SWRP_UPSTREAM_URL. Otherwise it writes why to stderr and returns 1. It reads no
|
||||
// other setting, nor a file that another names, so neither can fail it.
|
||||
// args are the arguments after `healthcheck`; it takes none, and given
|
||||
// one it names it on stderr and returns 1 without checking anything.
|
||||
func HealthCheck(
|
||||
@@ -38,7 +39,15 @@ func HealthCheck(
|
||||
return 1
|
||||
}
|
||||
|
||||
err := healthCheck(ctx, lookupEnv)
|
||||
ctx, cancel := context.WithTimeout(ctx, healthCheckTimeout)
|
||||
defer cancel()
|
||||
|
||||
check := checkRProxyAndApp
|
||||
if command, _ := lookupEnv("SMALLWEBWAF_COMMAND"); command == "servewaf" {
|
||||
check = checkServeWAF
|
||||
}
|
||||
|
||||
err := check(ctx, lookupEnv)
|
||||
if err != nil {
|
||||
_, _ = fmt.Fprintln(stderr, "unhealthy:", err)
|
||||
|
||||
@@ -48,16 +57,48 @@ func HealthCheck(
|
||||
return 0
|
||||
}
|
||||
|
||||
func healthCheck(ctx context.Context, lookupEnv func(string) (string, bool)) error {
|
||||
ctx, cancel := context.WithTimeout(ctx, healthCheckTimeout)
|
||||
defer cancel()
|
||||
|
||||
listenAddr, upstreamURL, err := config.ListenAddrAndUpstreamURL(lookupEnv)
|
||||
// checkServeWAF asks servewaf's health endpoint.
|
||||
func checkServeWAF(ctx context.Context, lookupEnv func(string) (string, bool)) error {
|
||||
listenAddr, err := config.ServeWAFListenAddr(lookupEnv)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid setting: %w", err)
|
||||
}
|
||||
|
||||
// The settings have checked that the address has a port.
|
||||
err = askHealthEndpoint(ctx, listenAddr)
|
||||
if err != nil {
|
||||
return fmt.Errorf("ask servewaf: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// checkRProxyAndApp asks rproxy's health endpoint, and connects to the app.
|
||||
func checkRProxyAndApp(
|
||||
ctx context.Context, lookupEnv func(string) (string, bool),
|
||||
) error {
|
||||
listenAddr, upstreamURL, err := config.RProxyListenAddrAndUpstreamURL(lookupEnv)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid setting: %w", err)
|
||||
}
|
||||
|
||||
err = askHealthEndpoint(ctx, listenAddr)
|
||||
if err != nil {
|
||||
return fmt.Errorf("ask rproxy: %w", err)
|
||||
}
|
||||
|
||||
conn, err := (&net.Dialer{}).DialContext(ctx, "tcp", appAddress(upstreamURL))
|
||||
if err != nil {
|
||||
return fmt.Errorf("connect to the app: %w", err)
|
||||
}
|
||||
|
||||
_ = conn.Close()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// askHealthEndpoint asks the health endpoint on 127.0.0.1 at the port of
|
||||
// listenAddr, which the settings have checked has one.
|
||||
func askHealthEndpoint(ctx context.Context, listenAddr string) error {
|
||||
_, port, _ := net.SplitHostPort(listenAddr)
|
||||
health := "http://" + net.JoinHostPort("127.0.0.1", port) + proxy.HealthPath
|
||||
|
||||
@@ -68,7 +109,7 @@ func healthCheck(ctx context.Context, lookupEnv func(string) (string, bool)) err
|
||||
|
||||
res, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("ask smallwebwaf: %w", err)
|
||||
return fmt.Errorf("send the request: %w", err)
|
||||
}
|
||||
|
||||
_ = res.Body.Close()
|
||||
@@ -77,13 +118,6 @@ func healthCheck(ctx context.Context, lookupEnv func(string) (string, bool)) err
|
||||
return fmt.Errorf("%w %s", errHealthEndpoint, res.Status)
|
||||
}
|
||||
|
||||
conn, err := (&net.Dialer{}).DialContext(ctx, "tcp", appAddress(upstreamURL))
|
||||
if err != nil {
|
||||
return fmt.Errorf("connect to the app: %w", err)
|
||||
}
|
||||
|
||||
_ = conn.Close()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@@ -15,11 +15,16 @@ import (
|
||||
"sneak.berlin/go/smallwebwaf/internal/smallwebwaf"
|
||||
)
|
||||
|
||||
func TestHealthCheck(t *testing.T) {
|
||||
t.Parallel()
|
||||
const (
|
||||
// commandSetting says which command a container runs, and which the health
|
||||
// check checks.
|
||||
commandSetting = "SMALLWEBWAF_COMMAND"
|
||||
// serveWAFCommand is the command that runs servewaf.
|
||||
serveWAFCommand = "servewaf"
|
||||
)
|
||||
|
||||
app := httptest.NewServer(http.NotFoundHandler())
|
||||
defer app.Close()
|
||||
func TestHealthCheckInServeWAFsContainer(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ctx, stop := context.WithCancel(t.Context())
|
||||
defer stop()
|
||||
@@ -27,10 +32,9 @@ func TestHealthCheck(t *testing.T) {
|
||||
out := &output{}
|
||||
exited := make(chan int, 1)
|
||||
settings := map[string]string{
|
||||
listenAddr: localhost + ":0",
|
||||
upstreamURL: app.URL,
|
||||
stateDir: t.TempDir(),
|
||||
rulesDir: t.TempDir(),
|
||||
listenAddr: localhost + ":0",
|
||||
stateDir: t.TempDir(),
|
||||
rulesDir: t.TempDir(),
|
||||
}
|
||||
|
||||
go func() {
|
||||
@@ -39,40 +43,36 @@ func TestHealthCheck(t *testing.T) {
|
||||
|
||||
addr, _ := out.line(t, "msg", "starting")["address"].(string)
|
||||
_, port, _ := net.SplitHostPort(addr)
|
||||
|
||||
// The container's settings: an address to listen on with an empty
|
||||
// host part, which the health check asks at 127.0.0.1.
|
||||
env := map[string]string{listenAddr: ":" + port, upstreamURL: app.URL}
|
||||
wantHealthCheck(t, map[string]string{
|
||||
commandSetting: serveWAFCommand, listenAddr: ":" + port,
|
||||
}, 0, "")
|
||||
|
||||
wantHealthCheck(t, env, 0, "")
|
||||
|
||||
// The health check reads those two settings alone, here given as
|
||||
// files: a removed or invalid token file, or an invalid value of
|
||||
// another setting, does not fail it.
|
||||
// The health check reads that setting alone, here given as a file: a removed
|
||||
// or invalid token file, or an invalid value of another setting, does not
|
||||
// fail it.
|
||||
for _, other := range []struct{ name, value string }{
|
||||
{"SWWAF_METRICS_TOKEN_FILE", filepath.Join(t.TempDir(), "removed")},
|
||||
{"SWWAF_METRICS_TOKEN_FILE", writeFile(t, "too short\n")},
|
||||
{"SWWAF_MODE", "neither"},
|
||||
} {
|
||||
wantHealthCheck(t, map[string]string{
|
||||
listenAddr + "_FILE": writeFile(t, ":"+port+"\n"),
|
||||
upstreamURL + "_FILE": writeFile(t, app.URL+"\n"),
|
||||
other.name: other.value,
|
||||
commandSetting: serveWAFCommand,
|
||||
listenAddr + "_FILE": writeFile(t, ":"+port+"\n"),
|
||||
other.name: other.value,
|
||||
}, 0, "")
|
||||
}
|
||||
|
||||
// Another test can take a closed server's port at once, so the app's
|
||||
// address here is a multicast address, to which TCP cannot connect.
|
||||
wantHealthCheck(t, map[string]string{
|
||||
listenAddr: ":" + port,
|
||||
upstreamURL: "http://224.0.0.1",
|
||||
}, 1, "unhealthy: connect to the app: ")
|
||||
|
||||
// Listening on port 0 takes any free port, so nothing listens on port 0
|
||||
// itself.
|
||||
wantHealthCheck(t, map[string]string{listenAddr: ":0"}, 1,
|
||||
"unhealthy: ask smallwebwaf: ")
|
||||
wantHealthCheck(t, map[string]string{listenAddr: "8080"}, 1,
|
||||
"unhealthy: invalid setting: SWWAF_LISTEN_ADDR: ")
|
||||
wantHealthCheck(t, map[string]string{
|
||||
commandSetting: serveWAFCommand, listenAddr: ":0",
|
||||
}, 1, "unhealthy: ask servewaf: ")
|
||||
wantHealthCheck(t, map[string]string{
|
||||
commandSetting: serveWAFCommand, listenAddr: "8080",
|
||||
}, 1, "unhealthy: invalid setting: SWWAF_LISTEN_ADDR: ")
|
||||
|
||||
stop()
|
||||
|
||||
@@ -83,6 +83,50 @@ func TestHealthCheck(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestHealthCheckInRProxysContainer(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
app := httptest.NewServer(http.NotFoundHandler())
|
||||
defer app.Close()
|
||||
|
||||
// Nothing listens at the backend. The health check asks rproxy and the app,
|
||||
// whether or not servewaf answers.
|
||||
site, _ := startRProxy(t, "http://127.0.0.1:9", app.URL)
|
||||
_, port, _ := net.SplitHostPort(strings.TrimPrefix(site, "http://"))
|
||||
settings := func(more ...string) map[string]string {
|
||||
env := map[string]string{rproxyListenAddr: ":" + port, rproxyUpstreamURL: app.URL}
|
||||
for i := 0; i < len(more); i += 2 {
|
||||
env[more[i]] = more[i+1]
|
||||
}
|
||||
|
||||
return env
|
||||
}
|
||||
|
||||
wantHealthCheck(t, settings(), 0, "")
|
||||
wantHealthCheck(t, settings(commandSetting, "rproxy"), 0, "")
|
||||
|
||||
// Another setting does not fail it, even an invalid one, and neither does
|
||||
// SWRP_WAF_BACKEND being unset.
|
||||
wantHealthCheck(t, settings("SWRP_TIMEOUT_MS", "soon"), 0, "")
|
||||
|
||||
// Another test can take a closed server's port at once, so the app's
|
||||
// address here is a multicast address, to which TCP cannot connect.
|
||||
wantHealthCheck(t, settings(rproxyUpstreamURL, "http://224.0.0.1"), 1,
|
||||
"unhealthy: connect to the app: ")
|
||||
|
||||
// Listening on port 0 takes any free port, so nothing listens on port 0
|
||||
// itself.
|
||||
wantHealthCheck(t, settings(rproxyListenAddr, ":0"), 1, "unhealthy: ask rproxy: ")
|
||||
wantHealthCheck(t, settings(rproxyListenAddr, "8080"), 1,
|
||||
"unhealthy: invalid setting: SWRP_LISTEN_ADDR: ")
|
||||
|
||||
// No setting of rproxy's has a form given as a file.
|
||||
wantHealthCheck(t, map[string]string{
|
||||
rproxyListenAddr + "_FILE": writeFile(t, ":"+port+"\n"),
|
||||
rproxyUpstreamURL: app.URL,
|
||||
}, 1, "unhealthy: ask rproxy: ")
|
||||
}
|
||||
|
||||
func TestHealthCheckRefusesAnArgument(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
|
||||
@@ -28,6 +28,8 @@ const (
|
||||
wafBackend = "SWRP_WAF_BACKEND"
|
||||
// rproxyListenAddr is the setting that says where rproxy listens.
|
||||
rproxyListenAddr = "SWRP_LISTEN_ADDR"
|
||||
// rproxyUpstreamURL is the setting that says where the app is.
|
||||
rproxyUpstreamURL = "SWRP_UPSTREAM_URL"
|
||||
// rproxyTimeout is the setting that says how long rproxy waits for servewaf.
|
||||
rproxyTimeout = "SWRP_TIMEOUT_MS"
|
||||
// bannedProbe is a path the default rule file bans a client for.
|
||||
@@ -100,7 +102,7 @@ func startServeWAF(t *testing.T, env map[string]string) (string, *output, func()
|
||||
exited := make(chan int, 1)
|
||||
|
||||
go func() {
|
||||
exited <- runAs(ctx, true, env, out)
|
||||
exited <- run(ctx, env, out)
|
||||
}()
|
||||
|
||||
addr, _ := out.line(t, "msg", "starting")["address"].(string)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package smallwebwaf_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"io"
|
||||
@@ -33,59 +34,88 @@ func TestMain(m *testing.M) {
|
||||
os.Exit(smallwebwaf.Main(testVersion))
|
||||
}
|
||||
|
||||
func TestMainAnswersQuestionsOnlyAsServeWAF(t *testing.T) {
|
||||
func TestMainRunsServeWAF(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
for _, tc := range []struct {
|
||||
command, disposition string
|
||||
}{
|
||||
{"servewaf", proxy.DispositionAllow},
|
||||
{"", ""},
|
||||
} {
|
||||
t.Run("command "+tc.command, func(t *testing.T) {
|
||||
binary, err := os.Executable()
|
||||
if err != nil {
|
||||
t.Fatalf("find the test binary: %v", err)
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(t.Context(), 3*waitLimit)
|
||||
defer cancel()
|
||||
|
||||
out := &output{}
|
||||
cmd := exec.CommandContext(ctx, binary) //nolint:gosec // this test binary
|
||||
cmd.Stdout = out
|
||||
|
||||
cmd.Env = append(os.Environ(),
|
||||
commandForMain+"="+serveWAFCommand,
|
||||
listenAddr+"="+localhost+":0",
|
||||
stateDir+"="+t.TempDir(),
|
||||
rulesDir+"="+t.TempDir(),
|
||||
lookupSource+"=off")
|
||||
|
||||
err = cmd.Start()
|
||||
if err != nil {
|
||||
t.Fatalf("start: %v", err)
|
||||
}
|
||||
|
||||
t.Cleanup(func() {
|
||||
_ = cmd.Process.Kill()
|
||||
_ = cmd.Wait()
|
||||
})
|
||||
|
||||
addr, _ := out.line(t, "msg", "starting")["address"].(string)
|
||||
ask(t, addr, "/", http.StatusOK, proxy.DispositionAllow)
|
||||
|
||||
err = cmd.Process.Signal(syscall.SIGTERM)
|
||||
if err != nil {
|
||||
t.Fatalf("signal: %v", err)
|
||||
}
|
||||
|
||||
err = cmd.Wait()
|
||||
if err != nil {
|
||||
t.Errorf("exit: %v; output:\n%s", err, out.text())
|
||||
}
|
||||
}
|
||||
|
||||
func TestMainWithoutACommandItKnowsPrintsTheCommands(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
binary, err := os.Executable()
|
||||
if err != nil {
|
||||
t.Fatalf("find the test binary: %v", err)
|
||||
}
|
||||
|
||||
for _, command := range []string{"", "serve"} {
|
||||
t.Run("command "+command, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
binary, err := os.Executable()
|
||||
if err != nil {
|
||||
t.Fatalf("find the test binary: %v", err)
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(t.Context(), 3*waitLimit)
|
||||
ctx, cancel := context.WithTimeout(t.Context(), waitLimit)
|
||||
defer cancel()
|
||||
|
||||
out := &output{}
|
||||
cmd := exec.CommandContext(ctx, binary) //nolint:gosec // this test binary
|
||||
cmd.Stdout = out
|
||||
var stdout, stderr bytes.Buffer
|
||||
|
||||
cmd.Env = append(os.Environ(),
|
||||
commandForMain+"="+tc.command,
|
||||
listenAddr+"="+localhost+":0",
|
||||
upstreamURL+"="+startApp(t),
|
||||
stateDir+"="+t.TempDir(),
|
||||
rulesDir+"="+t.TempDir(),
|
||||
lookupSource+"=off")
|
||||
cmd := exec.CommandContext(ctx, binary)
|
||||
cmd.Stdout, cmd.Stderr = &stdout, &stderr
|
||||
|
||||
err = cmd.Start()
|
||||
if err != nil {
|
||||
t.Fatalf("start: %v", err)
|
||||
cmd.Env = append(os.Environ(), commandForMain+"="+command)
|
||||
|
||||
err := cmd.Run()
|
||||
|
||||
if code := cmd.ProcessState.ExitCode(); code != 2 {
|
||||
t.Errorf("exit status %d (%v), want 2", code, err)
|
||||
}
|
||||
|
||||
t.Cleanup(func() {
|
||||
_ = cmd.Process.Kill()
|
||||
_ = cmd.Wait()
|
||||
})
|
||||
|
||||
addr, _ := out.line(t, "msg", "starting")["address"].(string)
|
||||
ask(t, addr, "/", http.StatusOK, tc.disposition)
|
||||
|
||||
err = cmd.Process.Signal(syscall.SIGTERM)
|
||||
if err != nil {
|
||||
t.Fatalf("signal: %v", err)
|
||||
for _, name := range []string{"rproxy", serveWAFCommand, "healthcheck"} {
|
||||
if !strings.Contains(stderr.String(), name) {
|
||||
t.Errorf("stderr %q does not name %s", stderr.String(), name)
|
||||
}
|
||||
}
|
||||
|
||||
err = cmd.Wait()
|
||||
if err != nil {
|
||||
t.Errorf("exit: %v; output:\n%s", err, out.text())
|
||||
if stdout.Len() != 0 {
|
||||
t.Errorf("stdout %q, want nothing", stdout.String())
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -134,7 +164,7 @@ func runServeWAF(t *testing.T, env map[string]string, use func(addr string)) {
|
||||
exited := make(chan int, 1)
|
||||
|
||||
go func() {
|
||||
exited <- runAs(ctx, true, env, out)
|
||||
exited <- run(ctx, env, out)
|
||||
}()
|
||||
|
||||
addr, _ := out.line(t, "msg", "starting")["address"].(string)
|
||||
@@ -172,39 +202,3 @@ func TestServeWAFKeepsItsBansAcrossARestartAndNeedsNoApp(t *testing.T) {
|
||||
ask(t, addr, "/", http.StatusForbidden, proxy.DispositionDeny)
|
||||
})
|
||||
}
|
||||
|
||||
func TestWithoutACommandTheClientHeaderIsNoQuestion(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
runUntilStopped(t, map[string]string{
|
||||
listenAddr: localhost + ":0",
|
||||
upstreamURL: startApp(t),
|
||||
stateDir: t.TempDir(),
|
||||
rulesDir: t.TempDir(),
|
||||
}, func(url string) {
|
||||
req, err := http.NewRequestWithContext(t.Context(), http.MethodGet, url,
|
||||
http.NoBody)
|
||||
if err != nil {
|
||||
t.Fatalf("new request: %v", err)
|
||||
}
|
||||
|
||||
req.Header.Set(proxy.ClientHeader, placed)
|
||||
|
||||
transport := &http.Transport{}
|
||||
defer transport.CloseIdleConnections()
|
||||
|
||||
res, err := (&http.Client{Transport: transport}).Do(req)
|
||||
if err != nil {
|
||||
t.Fatalf("request: %v", err)
|
||||
}
|
||||
|
||||
body, err := io.ReadAll(res.Body)
|
||||
_ = res.Body.Close()
|
||||
|
||||
if err != nil || string(body) != greeting ||
|
||||
res.Header.Get(proxy.DispositionHeader) != "" {
|
||||
t.Errorf("answered %q (%v) with disposition %q, want the app's greeting",
|
||||
body, err, res.Header.Get(proxy.DispositionHeader))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,15 +1,16 @@
|
||||
// Package smallwebwaf runs the smallwebwaf process: it reads the settings,
|
||||
// the rule files, the lookup database and the state files, serves requests
|
||||
// until it is told to stop, and then stops in an orderly way, writing the
|
||||
// state files. It serves them in front of an app, or, as `smallwebwaf
|
||||
// servewaf`, by answering rproxy's questions about them. As `smallwebwaf
|
||||
// rproxy`, it instead reads rproxy's settings alone, and runs the reverse proxy
|
||||
// in front of one app that asks servewaf about each request.
|
||||
// Package smallwebwaf runs the smallwebwaf process, as the command it is given.
|
||||
// `smallwebwaf servewaf` reads the settings, the rule files, the lookup database
|
||||
// and the state files, answers rproxy's questions until it is told to stop, and
|
||||
// then stops in an orderly way, writing the state files. `smallwebwaf rproxy`
|
||||
// reads rproxy's settings alone, and runs the reverse proxy in front of one app
|
||||
// that asks servewaf about each request. `smallwebwaf healthcheck` is the
|
||||
// container's health check.
|
||||
package smallwebwaf
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net"
|
||||
@@ -35,12 +36,25 @@ import (
|
||||
// runit and docker wait a little longer before they kill the process.
|
||||
const shutdownTimeout = 5 * time.Second
|
||||
|
||||
// remoteLogStopTimeout is how long, as smallwebwaf stops, the log lines
|
||||
// still waiting are sent to SWWAF_LOG_REMOTE_URL before they are given
|
||||
// up. stdout has carried them.
|
||||
// remoteLogStopTimeout is how long, as servewaf stops, the log lines still
|
||||
// waiting are sent to SWWAF_LOG_REMOTE_URL before they are given up. stdout has
|
||||
// carried them.
|
||||
const remoteLogStopTimeout = 2 * time.Second
|
||||
|
||||
// Params are what Run needs from the process.
|
||||
// usageStatus is the exit status with no command, or one smallwebwaf does not
|
||||
// know.
|
||||
const usageStatus = 2
|
||||
|
||||
// usage is what smallwebwaf prints then.
|
||||
const usage = `usage: smallwebwaf <command>
|
||||
|
||||
commands:
|
||||
rproxy the reverse proxy in front of one app
|
||||
servewaf decides for rproxy, and keeps bans, counters and history
|
||||
healthcheck the container's health check
|
||||
`
|
||||
|
||||
// Params are what RunServeWAF and RunRProxy need from the process.
|
||||
type Params struct {
|
||||
// Version is the version of the binary, set when it is built.
|
||||
Version string
|
||||
@@ -48,44 +62,47 @@ type Params struct {
|
||||
LookupEnv func(string) (string, bool)
|
||||
// Stdout receives the request log and the process's own messages.
|
||||
Stdout io.Writer
|
||||
// ServeWAF makes it `smallwebwaf servewaf`, which answers rproxy's
|
||||
// questions, rather than the proxy in front of an app.
|
||||
ServeWAF bool
|
||||
}
|
||||
|
||||
// Main runs smallwebwaf until SIGTERM or SIGINT, and returns the
|
||||
// process's exit status. Run as `smallwebwaf servewaf`, it answers
|
||||
// rproxy's questions rather than pass requests to an app. Run as
|
||||
// `smallwebwaf rproxy`, it is the reverse proxy that asks those questions.
|
||||
// Run as `smallwebwaf healthcheck`, it is the container's health check
|
||||
// instead.
|
||||
// Main runs the command os.Args names, and returns the process's exit status.
|
||||
// `smallwebwaf rproxy` and `smallwebwaf servewaf` run until SIGTERM or SIGINT.
|
||||
// With no command, or one it does not know, it prints the commands and returns
|
||||
// usageStatus.
|
||||
func Main(version string) int {
|
||||
if len(os.Args) > 1 && os.Args[1] == "healthcheck" {
|
||||
return HealthCheck(context.Background(), os.Args[2:], os.LookupEnv, os.Stderr)
|
||||
args := os.Args[1:]
|
||||
if len(args) == 0 {
|
||||
return printUsage(os.Stderr)
|
||||
}
|
||||
|
||||
ctx, stop := signal.NotifyContext(context.Background(),
|
||||
syscall.SIGTERM, os.Interrupt)
|
||||
defer stop()
|
||||
|
||||
params := Params{
|
||||
Version: version,
|
||||
LookupEnv: os.LookupEnv,
|
||||
Stdout: os.Stdout,
|
||||
ServeWAF: len(os.Args) > 1 && os.Args[1] == "servewaf",
|
||||
}
|
||||
params := Params{Version: version, LookupEnv: os.LookupEnv, Stdout: os.Stdout}
|
||||
|
||||
if len(os.Args) > 1 && os.Args[1] == "rproxy" {
|
||||
switch args[0] {
|
||||
case "rproxy":
|
||||
return RunRProxy(ctx, params)
|
||||
case "servewaf":
|
||||
return RunServeWAF(ctx, params)
|
||||
case "healthcheck":
|
||||
return HealthCheck(ctx, args[1:], os.LookupEnv, os.Stderr)
|
||||
default:
|
||||
return printUsage(os.Stderr)
|
||||
}
|
||||
|
||||
return Run(ctx, params)
|
||||
}
|
||||
|
||||
// Run reads the settings, the rule files, the lookup database and the
|
||||
// state files, then serves requests until ctx is done. It returns the
|
||||
// process's exit status, 1 when smallwebwaf cannot start.
|
||||
func Run(ctx context.Context, params Params) int {
|
||||
// printUsage writes the commands to w, and returns usageStatus.
|
||||
func printUsage(w io.Writer) int {
|
||||
_, _ = fmt.Fprint(w, usage)
|
||||
|
||||
return usageStatus
|
||||
}
|
||||
|
||||
// RunServeWAF is `smallwebwaf servewaf`: it reads the settings, the rule files,
|
||||
// the lookup database and the state files, then serves requests until ctx is
|
||||
// done. It returns the process's exit status, 1 when servewaf cannot start.
|
||||
func RunServeWAF(ctx context.Context, params Params) int {
|
||||
// Until the settings are read, the one message is an invalid setting's
|
||||
// error, which every SWWAF_LOG_LEVEL lets through.
|
||||
processLog := requestlog.NewProcessLogger(params.Stdout,
|
||||
@@ -133,12 +150,7 @@ func Run(ctx context.Context, params Params) int {
|
||||
return 1
|
||||
}
|
||||
|
||||
newProxy := proxy.New
|
||||
if params.ServeWAF {
|
||||
newProxy = proxy.NewServeWAF
|
||||
}
|
||||
|
||||
server, err := newServer(newProxy, cfg, stdout, processLog, now, ruleFiles, alertQueue)
|
||||
server, err := newServer(cfg, stdout, processLog, now, ruleFiles, alertQueue)
|
||||
if err != nil {
|
||||
processLog.Error("cannot use the lookup database", "error", err.Error())
|
||||
|
||||
@@ -172,12 +184,10 @@ func Run(ctx context.Context, params Params) int {
|
||||
return serve(ctx, server, listener, files, ruleFiles, alertQueue, processLog)
|
||||
}
|
||||
|
||||
// newServer returns the server smallwebwaf runs, which newProxy makes, with
|
||||
// the metrics of the alerts, after reading the lookup database while
|
||||
// SWWAF_LOOKUP_SOURCE is file. A lookup database that cannot be read is an
|
||||
// error.
|
||||
// newServer returns the server servewaf runs, with the metrics of the alerts,
|
||||
// after reading the lookup database while SWWAF_LOOKUP_SOURCE is file. A lookup
|
||||
// database that cannot be read is an error.
|
||||
func newServer(
|
||||
newProxy func(proxy.Params) *proxy.Server,
|
||||
cfg *config.Config, stdout io.Writer, processLog *slog.Logger,
|
||||
now func() time.Time, ruleFiles *rules.Files, alertQueue *alerts.Queue,
|
||||
) (*proxy.Server, error) {
|
||||
@@ -197,7 +207,7 @@ func newServer(
|
||||
}
|
||||
}
|
||||
|
||||
server := newProxy(proxy.Params{
|
||||
server := proxy.NewServeWAF(proxy.Params{
|
||||
Config: cfg,
|
||||
RequestLog: stdout,
|
||||
ProcessLog: processLog,
|
||||
@@ -361,12 +371,10 @@ func serve(
|
||||
|
||||
// Run and Watch have ended, so nothing else reads or writes the
|
||||
// files, and no alert is being sent, so that alerts.json keeps every
|
||||
// alert not yet sent. Every request has ended too, but for two kinds
|
||||
// that Go's server does not wait for: one cut off because Shutdown
|
||||
// timed out, and one whose connection switched protocols, such as a
|
||||
// WebSocket. Such a request adds to its client's history only as it
|
||||
// ends, which can be after this write, and then that request is
|
||||
// missing from clients.json.
|
||||
// alert not yet sent. Every request has ended too, but for one cut off
|
||||
// because Shutdown timed out, which adds to its client's history only as
|
||||
// it ends. That can be after this write, and then that request is missing
|
||||
// from clients.json.
|
||||
<-written
|
||||
<-watched
|
||||
<-rulesWatched
|
||||
|
||||
@@ -3,6 +3,7 @@ package smallwebwaf_test
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net"
|
||||
@@ -19,6 +20,7 @@ import (
|
||||
"time"
|
||||
|
||||
"sneak.berlin/go/smallwebwaf/internal/lookup/lookuptest"
|
||||
"sneak.berlin/go/smallwebwaf/internal/proxy"
|
||||
"sneak.berlin/go/smallwebwaf/internal/smallwebwaf"
|
||||
)
|
||||
|
||||
@@ -32,8 +34,6 @@ const (
|
||||
// localhost is where the tests listen.
|
||||
localhost = "127.0.0.1"
|
||||
listenAddr = "SWWAF_LISTEN_ADDR"
|
||||
upstreamURL = "SWWAF_UPSTREAM_URL"
|
||||
trustedProxies = "SWWAF_TRUSTED_PROXIES"
|
||||
stateDir = "SWWAF_STATE_DIR"
|
||||
stateWriteDelay = "SWWAF_STATE_WRITE_DELAY"
|
||||
stateCounterInterval = "SWWAF_STATE_COUNTER_INTERVAL"
|
||||
@@ -122,18 +122,11 @@ func (o *output) text() string {
|
||||
return o.buf.String()
|
||||
}
|
||||
|
||||
// run runs smallwebwaf with the settings in env until ctx is done, and
|
||||
// returns its exit status. SWWAF_LOOKUP_SOURCE is off unless env sets it,
|
||||
// run runs `smallwebwaf servewaf` with the settings in env until ctx is done,
|
||||
// and returns its exit status. SWWAF_LOOKUP_SOURCE is off unless env sets it,
|
||||
// so that no test sends GeoJS its clients' addresses.
|
||||
func run(ctx context.Context, env map[string]string, out *output) int {
|
||||
return runAs(ctx, false, env, out)
|
||||
}
|
||||
|
||||
// runAs is run as `smallwebwaf servewaf` while serveWAF is true.
|
||||
func runAs(
|
||||
ctx context.Context, serveWAF bool, env map[string]string, out *output,
|
||||
) int {
|
||||
return smallwebwaf.Run(ctx, smallwebwaf.Params{
|
||||
return smallwebwaf.RunServeWAF(ctx, smallwebwaf.Params{
|
||||
Version: testVersion,
|
||||
LookupEnv: func(name string) (string, bool) {
|
||||
value, ok := env[name]
|
||||
@@ -143,8 +136,7 @@ func runAs(
|
||||
|
||||
return value, ok
|
||||
},
|
||||
Stdout: out,
|
||||
ServeWAF: serveWAF,
|
||||
Stdout: out,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -154,8 +146,8 @@ func TestInvalidSettingStopsTheStart(t *testing.T) {
|
||||
out := &output{}
|
||||
|
||||
status := run(t.Context(), map[string]string{
|
||||
"SWWAF_REQUEST_MAX_BYTES": "lots",
|
||||
instanceName: instance,
|
||||
"SWWAF_BYTES_LIMIT_PER_MINUTE": "lots",
|
||||
instanceName: instance,
|
||||
}, out)
|
||||
if status != 1 {
|
||||
t.Errorf("exit status %d, want 1", status)
|
||||
@@ -166,7 +158,7 @@ func TestInvalidSettingStopsTheStart(t *testing.T) {
|
||||
|
||||
if line["type"] != "process" || line["instance"] != instance ||
|
||||
line["level"] != "ERROR" ||
|
||||
!strings.HasPrefix(message, "SWWAF_REQUEST_MAX_BYTES: ") {
|
||||
!strings.HasPrefix(message, "SWWAF_BYTES_LIMIT_PER_MINUTE: ") {
|
||||
t.Errorf("start refused with %v", line)
|
||||
}
|
||||
}
|
||||
@@ -228,7 +220,6 @@ func TestAddressInUseStopsTheStart(t *testing.T) {
|
||||
func TestServesUntilToldToStop(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
appURL := startApp(t)
|
||||
dir := t.TempDir()
|
||||
|
||||
ctx, stop := context.WithCancel(t.Context())
|
||||
@@ -237,10 +228,9 @@ func TestServesUntilToldToStop(t *testing.T) {
|
||||
|
||||
go func() {
|
||||
exited <- run(ctx, map[string]string{
|
||||
listenAddr: localhost + ":0",
|
||||
upstreamURL: appURL,
|
||||
stateDir: dir,
|
||||
rulesDir: filepath.Join("..", "..", "share", "rules.d"),
|
||||
listenAddr: localhost + ":0",
|
||||
stateDir: dir,
|
||||
rulesDir: filepath.Join("..", "..", "share", "rules.d"),
|
||||
}, out)
|
||||
}()
|
||||
|
||||
@@ -250,10 +240,10 @@ func TestServesUntilToldToStop(t *testing.T) {
|
||||
}
|
||||
|
||||
starting := out.line(t, "msg", "starting")
|
||||
wantStartingLine(t, starting, appURL, dir)
|
||||
wantStartingLine(t, starting, dir)
|
||||
|
||||
addr, _ := starting["address"].(string)
|
||||
wantGreeting(t, "http://"+addr+"/")
|
||||
wantAllowed(t, "http://"+addr+"/")
|
||||
out.line(t, "type", "request")
|
||||
|
||||
stop()
|
||||
@@ -325,7 +315,6 @@ func TestEveryLogLineAndMetricCarriesTheInstanceName(t *testing.T) {
|
||||
|
||||
env := map[string]string{
|
||||
listenAddr: localhost + ":0",
|
||||
upstreamURL: startApp(t),
|
||||
stateDir: t.TempDir(),
|
||||
rulesDir: t.TempDir(),
|
||||
metricsToken: token,
|
||||
@@ -363,7 +352,6 @@ func TestStateKeptAcrossRestarts(t *testing.T) {
|
||||
|
||||
env := map[string]string{
|
||||
listenAddr: localhost + ":0",
|
||||
upstreamURL: startApp(t),
|
||||
stateDir: t.TempDir(),
|
||||
rulesDir: t.TempDir(),
|
||||
rateLimitPerDay: "2",
|
||||
@@ -375,8 +363,8 @@ func TestStateKeptAcrossRestarts(t *testing.T) {
|
||||
|
||||
// The two requests a day allows, and a stop.
|
||||
runUntilStopped(t, env, func(url string) {
|
||||
wantGreeting(t, url)
|
||||
wantGreeting(t, url)
|
||||
wantAllowed(t, url)
|
||||
wantAllowed(t, url)
|
||||
})
|
||||
|
||||
// After a restart the client has no fresh allowance: its third
|
||||
@@ -400,10 +388,8 @@ func TestBanRefusesItsNetblockAfterARestartWithAnotherScope(t *testing.T) {
|
||||
|
||||
env := map[string]string{
|
||||
listenAddr: localhost + ":0",
|
||||
upstreamURL: startApp(t),
|
||||
stateDir: t.TempDir(),
|
||||
rulesDir: t.TempDir(),
|
||||
trustedProxies: localhost + "/32",
|
||||
rateLimitPerDay: "1",
|
||||
scope: "24",
|
||||
}
|
||||
@@ -447,11 +433,9 @@ func TestBanAddedAndLiftedByEditingBansJSON(t *testing.T) {
|
||||
|
||||
dir := t.TempDir()
|
||||
env := map[string]string{
|
||||
listenAddr: localhost + ":0",
|
||||
upstreamURL: startApp(t),
|
||||
stateDir: dir,
|
||||
rulesDir: t.TempDir(),
|
||||
trustedProxies: localhost + "/32",
|
||||
listenAddr: localhost + ":0",
|
||||
stateDir: dir,
|
||||
rulesDir: t.TempDir(),
|
||||
// No write comes due in the test, so only the watch on the
|
||||
// directory can take the edits in.
|
||||
stateWriteDelay: "1h",
|
||||
@@ -472,12 +456,10 @@ func TestBanAddedAndLiftedThroughTheEndpointsKeptInBansJSON(t *testing.T) {
|
||||
|
||||
dir := t.TempDir()
|
||||
env := map[string]string{
|
||||
listenAddr: localhost + ":0",
|
||||
upstreamURL: startApp(t),
|
||||
stateDir: dir,
|
||||
rulesDir: t.TempDir(),
|
||||
trustedProxies: localhost + "/32",
|
||||
adminToken: adminSecret,
|
||||
listenAddr: localhost + ":0",
|
||||
stateDir: dir,
|
||||
rulesDir: t.TempDir(),
|
||||
adminToken: adminSecret,
|
||||
// Neither comes due in the test: the files are written as
|
||||
// smallwebwaf stops.
|
||||
stateWriteDelay: "1h",
|
||||
@@ -517,17 +499,16 @@ func TestRuleFileAddedWhileRunningTakesEffect(t *testing.T) {
|
||||
|
||||
dir := t.TempDir()
|
||||
env := map[string]string{
|
||||
listenAddr: localhost + ":0",
|
||||
upstreamURL: startApp(t),
|
||||
stateDir: t.TempDir(),
|
||||
rulesDir: dir,
|
||||
listenAddr: localhost + ":0",
|
||||
stateDir: t.TempDir(),
|
||||
rulesDir: dir,
|
||||
// The requests sent until the rule takes effect must not break a
|
||||
// rate limit, whose ban would refuse them too.
|
||||
"SWWAF_RATE_LIMIT_PER_MINUTE": "off",
|
||||
}
|
||||
|
||||
out := runUntilStopped(t, env, func(url string) {
|
||||
wantGreeting(t, url)
|
||||
wantAllowed(t, url)
|
||||
|
||||
// Written once: each change would start the rule files' wait
|
||||
// again. A file written before smallwebwaf watches the directory is
|
||||
@@ -580,10 +561,8 @@ func TestLookupDatabaseReplacedWhileRunningTakesEffect(t *testing.T) {
|
||||
|
||||
env := map[string]string{
|
||||
listenAddr: localhost + ":0",
|
||||
upstreamURL: startApp(t),
|
||||
stateDir: t.TempDir(),
|
||||
rulesDir: t.TempDir(),
|
||||
trustedProxies: localhost + "/32",
|
||||
metricsToken: token,
|
||||
instanceName: instance,
|
||||
lookupSource: "file",
|
||||
@@ -679,10 +658,8 @@ func TestBlocklistTriesAndCopiesKeptInReputationJSONAcrossRestarts(t *testing.T)
|
||||
dir := t.TempDir()
|
||||
env := map[string]string{
|
||||
listenAddr: localhost + ":0",
|
||||
upstreamURL: startApp(t),
|
||||
stateDir: dir,
|
||||
rulesDir: t.TempDir(),
|
||||
trustedProxies: localhost + "/32",
|
||||
metricsToken: token,
|
||||
instanceName: instance,
|
||||
"SWWAF_BLOCKLIST_URLS": torURL,
|
||||
@@ -763,10 +740,8 @@ func TestCrowdSecDecisionListKeptInReputationJSONAcrossARestart(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
env := map[string]string{
|
||||
listenAddr: localhost + ":0",
|
||||
upstreamURL: startApp(t),
|
||||
stateDir: dir,
|
||||
rulesDir: t.TempDir(),
|
||||
trustedProxies: localhost + "/32",
|
||||
"SWWAF_CROWDSEC_LAPI_URL": engine.URL,
|
||||
"SWWAF_CROWDSEC_LAPI_KEY": bouncerKey,
|
||||
}
|
||||
@@ -864,14 +839,13 @@ func TestEveryLineIsAlsoSentToTheRemoteLogEndpoint(t *testing.T) {
|
||||
|
||||
env := map[string]string{
|
||||
listenAddr: localhost + ":0",
|
||||
upstreamURL: startApp(t),
|
||||
stateDir: t.TempDir(),
|
||||
rulesDir: t.TempDir(),
|
||||
"SWWAF_LOG_REMOTE_URL": "syslog+tcp://" + endpoint.Addr().String(),
|
||||
}
|
||||
|
||||
out := runUntilStopped(t, env, func(url string) {
|
||||
wantGreeting(t, url)
|
||||
wantAllowed(t, url)
|
||||
})
|
||||
out.line(t, "type", "request")
|
||||
|
||||
@@ -920,7 +894,6 @@ func TestStalledRemoteLogEndpointHoldsUpNoRequest(t *testing.T) {
|
||||
|
||||
env := map[string]string{
|
||||
listenAddr: localhost + ":0",
|
||||
upstreamURL: startApp(t),
|
||||
stateDir: t.TempDir(),
|
||||
rulesDir: t.TempDir(),
|
||||
"SWWAF_LOG_REMOTE_URL": "syslog+tls://" + endpoint.Addr().String(),
|
||||
@@ -930,7 +903,7 @@ func TestStalledRemoteLogEndpointHoldsUpNoRequest(t *testing.T) {
|
||||
}
|
||||
|
||||
out := runUntilStopped(t, env, func(url string) {
|
||||
wantGreeting(t, url)
|
||||
wantAllowed(t, url)
|
||||
|
||||
// More than one line has been written, and the buffer holds the
|
||||
// last.
|
||||
@@ -980,7 +953,6 @@ func TestBanIsAlertedAndAnAlertNotSentIsKeptAcrossARestart(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
env := map[string]string{
|
||||
listenAddr: localhost + ":0",
|
||||
upstreamURL: startApp(t),
|
||||
stateDir: dir,
|
||||
rulesDir: rules,
|
||||
"SWWAF_ALERT_WEBHOOK_URL": webhook.url,
|
||||
@@ -1065,10 +1037,8 @@ func TestBanIsAlertedToSlackAndNtfy(t *testing.T) {
|
||||
slack, ntfy := startDestination(t), startDestination(t)
|
||||
env := map[string]string{
|
||||
listenAddr: localhost + ":0",
|
||||
upstreamURL: startApp(t),
|
||||
stateDir: t.TempDir(),
|
||||
rulesDir: t.TempDir(),
|
||||
trustedProxies: localhost + "/32",
|
||||
rateLimitPerDay: "1",
|
||||
// The metrics are read from 127.0.0.1, which no limit counts.
|
||||
"SWWAF_ALLOW_NETS": localhost + "/32",
|
||||
@@ -1208,8 +1178,8 @@ func startApp(t *testing.T) string {
|
||||
return app.URL
|
||||
}
|
||||
|
||||
// runUntilStopped runs smallwebwaf with the settings in env, has use send
|
||||
// it requests at url, then stops it as SIGTERM does, checks that it
|
||||
// runUntilStopped runs `smallwebwaf servewaf` with the settings in env, has use
|
||||
// send it requests at url, then stops it as SIGTERM does, checks that it
|
||||
// stopped in order, and returns its output.
|
||||
func runUntilStopped(
|
||||
t *testing.T, env map[string]string, use func(url string),
|
||||
@@ -1242,43 +1212,33 @@ func runUntilStopped(
|
||||
|
||||
// wantStartingLine checks that the line at start gives the version and
|
||||
// every setting's value.
|
||||
func wantStartingLine(t *testing.T, line map[string]any, appURL, dir string) {
|
||||
func wantStartingLine(t *testing.T, line map[string]any, dir string) {
|
||||
t.Helper()
|
||||
|
||||
settings, _ := line["settings"].(map[string]any)
|
||||
want := map[string]any{
|
||||
listenAddr: localhost + ":0",
|
||||
upstreamURL: appURL,
|
||||
stateDir: dir,
|
||||
"SWWAF_MODE": "enforce",
|
||||
stateWriteDelay: "10s",
|
||||
stateCounterInterval: "15m",
|
||||
trustedProxies: "10.0.0.0/8,172.16.0.0/12,192.168.0.0/16",
|
||||
"SWWAF_CLIENT_REQUEST_TIMEOUT": "60s",
|
||||
"SWWAF_CLIENT_REQUEST_HEADER_MAX_BYTES": "32K",
|
||||
"SWWAF_CLIENT_IDLE_TIMEOUT": "120s",
|
||||
"SWWAF_CLIENT_RESPONSE_TIMEOUT": "30m",
|
||||
"SWWAF_UPSTREAM_REQUEST_TIMEOUT": "60s",
|
||||
"SWWAF_UPSTREAM_RESPONSE_TIMEOUT": "30m",
|
||||
"SWWAF_REQUEST_MAX_BYTES": "100M",
|
||||
"SWWAF_RESPONSE_MAX_BYTES": "1G",
|
||||
"SWWAF_ALLOW_NETS": "",
|
||||
rateLimitExemptNets: "",
|
||||
"SWWAF_DENY_NETS": "",
|
||||
"SWWAF_RATE_LIMIT_PER_MINUTE": "500",
|
||||
"SWWAF_RATE_LIMIT_PER_HOUR": "20000",
|
||||
rateLimitPerDay: "300000",
|
||||
"SWWAF_RATE_LIMIT_EXEMPT_PATHS": "",
|
||||
"SWWAF_DENIED_COUNTRIES": "",
|
||||
"SWWAF_EXCLUSIVELY_ALLOWED_COUNTRIES": "",
|
||||
"SWWAF_BAN_RESPONSE": "403",
|
||||
"SWWAF_LIMIT_BAN_DURATION": "1h",
|
||||
"SWWAF_LIMIT_BAN_REPEAT_WINDOW": "24h",
|
||||
"SWWAF_MAX_BAN_DURATION": "7d",
|
||||
"SWWAF_ATTACK_BAN_DURATION": "7d",
|
||||
"SWWAF_MAX_BANS": "5000",
|
||||
"SWWAF_BAN_SCOPE_V4_PREFIX": "32",
|
||||
"SWWAF_RULES_ENABLED": "true",
|
||||
listenAddr: localhost + ":0",
|
||||
stateDir: dir,
|
||||
"SWWAF_MODE": "enforce",
|
||||
stateWriteDelay: "10s",
|
||||
stateCounterInterval: "15m",
|
||||
"SWWAF_ALLOW_NETS": "",
|
||||
rateLimitExemptNets: "",
|
||||
"SWWAF_DENY_NETS": "",
|
||||
"SWWAF_RATE_LIMIT_PER_MINUTE": "500",
|
||||
"SWWAF_RATE_LIMIT_PER_HOUR": "20000",
|
||||
rateLimitPerDay: "300000",
|
||||
"SWWAF_RATE_LIMIT_EXEMPT_PATHS": "",
|
||||
"SWWAF_DENIED_COUNTRIES": "",
|
||||
"SWWAF_EXCLUSIVELY_ALLOWED_COUNTRIES": "",
|
||||
"SWWAF_BAN_RESPONSE": "403",
|
||||
"SWWAF_LIMIT_BAN_DURATION": "1h",
|
||||
"SWWAF_LIMIT_BAN_REPEAT_WINDOW": "24h",
|
||||
"SWWAF_MAX_BAN_DURATION": "7d",
|
||||
"SWWAF_ATTACK_BAN_DURATION": "7d",
|
||||
"SWWAF_MAX_BANS": "5000",
|
||||
"SWWAF_BAN_SCOPE_V4_PREFIX": "32",
|
||||
"SWWAF_RULES_ENABLED": "true",
|
||||
}
|
||||
|
||||
for name, value := range want {
|
||||
@@ -1292,30 +1252,11 @@ func wantStartingLine(t *testing.T, line map[string]any, appURL, dir string) {
|
||||
}
|
||||
}
|
||||
|
||||
// wantGreeting checks that a request to url gets the app's answer.
|
||||
func wantGreeting(t *testing.T, url string) {
|
||||
// wantAllowed checks that servewaf at url allows a request from localhost.
|
||||
func wantAllowed(t *testing.T, url string) {
|
||||
t.Helper()
|
||||
|
||||
req, err := http.NewRequestWithContext(t.Context(), http.MethodGet, url,
|
||||
http.NoBody)
|
||||
if err != nil {
|
||||
t.Fatalf("new request: %v", err)
|
||||
}
|
||||
|
||||
transport := &http.Transport{}
|
||||
defer transport.CloseIdleConnections()
|
||||
|
||||
res, err := (&http.Client{Transport: transport}).Do(req)
|
||||
if err != nil {
|
||||
t.Fatalf("request: %v", err)
|
||||
}
|
||||
|
||||
body, err := io.ReadAll(res.Body)
|
||||
_ = res.Body.Close()
|
||||
|
||||
if err != nil || string(body) != greeting {
|
||||
t.Errorf("got %q (%v), want the app's answer", body, err)
|
||||
}
|
||||
wantStatus(t, url, localhost, http.StatusOK)
|
||||
}
|
||||
|
||||
// messages returns the message of each record in received, octet-counted
|
||||
@@ -1496,34 +1437,16 @@ func onlyBan(t *testing.T, dir string) map[string]any {
|
||||
return file.Bans[0]
|
||||
}
|
||||
|
||||
// wantRefused checks that a request to url is refused with 403, the
|
||||
// default SWWAF_BAN_RESPONSE.
|
||||
// wantRefused checks that servewaf at url refuses a request from localhost with
|
||||
// 403, the default SWWAF_BAN_RESPONSE.
|
||||
func wantRefused(t *testing.T, url string) {
|
||||
t.Helper()
|
||||
|
||||
req, err := http.NewRequestWithContext(t.Context(), http.MethodGet, url,
|
||||
http.NoBody)
|
||||
if err != nil {
|
||||
t.Fatalf("new request: %v", err)
|
||||
}
|
||||
|
||||
transport := &http.Transport{}
|
||||
defer transport.CloseIdleConnections()
|
||||
|
||||
res, err := (&http.Client{Transport: transport}).Do(req)
|
||||
if err != nil {
|
||||
t.Fatalf("request: %v", err)
|
||||
}
|
||||
|
||||
_ = res.Body.Close()
|
||||
|
||||
if res.StatusCode != http.StatusForbidden {
|
||||
t.Errorf("status %d, want %d", res.StatusCode, http.StatusForbidden)
|
||||
}
|
||||
wantStatus(t, url, localhost, http.StatusForbidden)
|
||||
}
|
||||
|
||||
// wantStatus checks that a request to url from the client at from, as
|
||||
// X-Forwarded-For names it, is answered with status.
|
||||
// wantStatus checks that a request to url from the client at from, as statusFrom
|
||||
// sends it, is answered with status.
|
||||
func wantStatus(t *testing.T, url, from string, status int) {
|
||||
t.Helper()
|
||||
|
||||
@@ -1690,8 +1613,10 @@ func (w *webhook) waitFor(t *testing.T, event string, answered bool) webhookPost
|
||||
}
|
||||
}
|
||||
|
||||
// statusFrom returns the status a request to url from the client at
|
||||
// from, as X-Forwarded-For names it, is answered with.
|
||||
// statusFrom returns the status a request to url from the client at from is
|
||||
// answered with. The request serves rproxy and servewaf alike. rproxy reads the
|
||||
// client from X-Forwarded-For, and replaces the other two headers. To servewaf,
|
||||
// they make it a question about that client.
|
||||
func statusFrom(t *testing.T, url, from string) int {
|
||||
t.Helper()
|
||||
|
||||
@@ -1702,6 +1627,8 @@ func statusFrom(t *testing.T, url, from string) int {
|
||||
}
|
||||
|
||||
req.Header.Set("X-Forwarded-For", from)
|
||||
req.Header.Set(proxy.ClientHeader, from)
|
||||
req.Header.Set("X-Request-ID", rand.Text())
|
||||
|
||||
transport := &http.Transport{}
|
||||
defer transport.CloseIdleConnections()
|
||||
|
||||
@@ -648,6 +648,27 @@ func TestClientWithRefusalsInTheMinuteWithoutTheirStartStopsTheStart(t *testing.
|
||||
`: entry 1 has no "minute_refusals.start"`)
|
||||
}
|
||||
|
||||
func TestClientsJSONFromAnEarlierVersionWithTokenRefusalsLoads(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
dir := t.TempDir()
|
||||
|
||||
err := os.WriteFile(filepath.Join(dir, clientsJSON),
|
||||
[]byte(`{"version": 1, "clients": [{"client": "203.0.113.9/32", `+
|
||||
`"history": {"offences": {"token_refused": 2}}}]}`), 0o600)
|
||||
if err != nil {
|
||||
t.Fatalf("write %s: %v", clientsJSON, err)
|
||||
}
|
||||
|
||||
params := newParams(dir)
|
||||
load(t, params)
|
||||
|
||||
clients := params.Limiter.Snapshot()
|
||||
if len(clients) != 1 || clients[0].History.Offences.TokenRefused != 2 {
|
||||
t.Errorf("clients %+v, want one with its two refusals kept", clients)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReputationJSONEntryWithoutAFieldItNeedsStopsTheStart(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
|
||||
+1
-2
@@ -212,8 +212,7 @@ type Result struct {
|
||||
// method, its URL with the query, and its headers, the Cookie header
|
||||
// without the cookies in cookiesNotRead, and on body, r's body as the
|
||||
// caller has it, as readBody reads it. It returns what it found, what it
|
||||
// read of body, which the app is still to be sent, and the error that
|
||||
// ended the reading early, if one did.
|
||||
// read of body, and the error that ended the reading early, if one did.
|
||||
func (c *CoreRuleSet) Inspect(
|
||||
r *http.Request, client netip.Addr, body io.Reader,
|
||||
) (Result, []byte, error) {
|
||||
|
||||
Reference in New Issue
Block a user