Rename the settings whose names do not say what they control (closes #234)
check / check (push) Waiting to run

Applies the setting renames of the naming audit under the SWW_WAF_ and
SWW_RPROXY_ prefixes, with sneak's names for the IP reputation cache
time and the two time limits, which now take milliseconds. The rproxy
log field default_disposition becomes waf_backend_no_answer, and
config.Config and config.FromEnvironment become config.WAF and
config.WAFFromEnvironment. Go fields, test names and prose follow. No
aliases; nothing reads the old names.

Model: opus-5-5
This commit is contained in:
clawbot committed 2026-11-12 09:48:09 +00:00
1 parent 3304beca46
commit 4d4ae61abf
100 files changed
+2261 -2019

No files matched your search

+3 -3
View File
@@ -1,7 +1,7 @@
package waf
// The heuristic abuseipdb. It reads SWW_WAF_ABUSEIPDB_KEY,
// SWW_WAF_ABUSEIPDB_MIN_SCORE and SWW_WAF_REPUTATION_ACTION.
// SWW_WAF_ABUSEIPDB_MIN_SCORE and SWW_WAF_DNSBL_ABUSEIPDB_ACTION.
import (
"context"
@@ -16,7 +16,7 @@ import (
// abuseIPDB adds the tag abuseipdb and notes AbuseIPDB, as noteHit does, with
// the score, when its score of the client is a hit, and refuses the request
// when SWW_WAF_REPUTATION_ACTION is deny, as dnsbl does for a zone. While
// when SWW_WAF_DNSBL_ABUSEIPDB_ACTION is deny, as dnsbl does for a zone. While
// SWW_WAF_ABUSEIPDB_KEY is unset it does nothing. A client without a score is
// checked in the background, by the request's address, if its history counts
// an offence, and the request does not wait for the answer. The score is then
@@ -54,7 +54,7 @@ func (abuseIPDB) check(
"scored by AbuseIPDB at or over SWW_WAF_ABUSEIPDB_MIN_SCORE",
)
if rq.h.config.ReputationAction != deny {
if rq.h.config.DNSBLAbuseIPDBAction != deny {
return nil
}
+3 -3
View File
@@ -57,7 +57,7 @@ func TestAttackBanRaisesABanAlertThenAPermanentBanAlert(t *testing.T) {
t.Parallel()
s, clk, server, queue := startWithAlerts(t, map[string]string{
rulesDir: writeRules(t, testRules),
ruleFilesDir: writeRules(t, testRules),
})
start := clk.Now()
netblock := netip.MustParsePrefix(client + "/32")
@@ -121,7 +121,7 @@ func TestObserveModeRaisesTheBanAlertsItWouldHave(t *testing.T) {
s, clk, server, queue := startWithAlerts(t, map[string]string{
mode: observe,
rateLimitPerMinute: "2",
rulesDir: writeRules(t, testRules),
ruleFilesDir: writeRules(t, testRules),
})
start := clk.Now()
@@ -209,7 +209,7 @@ func TestObserveModeWorksOutABanOnlyWhenItsAlertWouldBeSent(t *testing.T) {
s, _, _, queue := startWithAlerts(t, map[string]string{
mode: observe,
rateLimitPerMinute: "2",
rulesDir: writeRules(t, testRules),
ruleFilesDir: writeRules(t, testRules),
alertMaxPerHour: "2",
})
+1 -1
View File
@@ -19,7 +19,7 @@ func TestWithEveryAnomalyThresholdOffARequestIsNotCounted(t *testing.T) {
client: netip.MustParseAddr("203.0.113.9"),
lookedUp: true,
},
h: &handler{config: &config.Config{LookupSource: "geojs"}},
h: &handler{config: &config.WAF{LookupSource: "geojs"}},
}
defer func() {
+13 -13
View File
@@ -21,11 +21,11 @@ import (
// The anomaly thresholds: the prefix of a scope followed by the end of a
// count.
const (
anomalyClient = "SWW_WAF_ANOMALY_CLIENT_"
anomalyNet = "SWW_WAF_ANOMALY_NET_"
anomalyASN = "SWW_WAF_ANOMALY_ASN_"
anomalyTotal = "SWW_WAF_ANOMALY_TOTAL_"
anomalyWatch = "SWW_WAF_WATCH_"
anomalyClient = "SWW_WAF_ANOMALY_CLIENT_"
anomalyNet = "SWW_WAF_ANOMALY_NET_"
anomalyASN = "SWW_WAF_ANOMALY_ASN_"
anomalyTotal = "SWW_WAF_ANOMALY_TOTAL_"
anomalyWatchedNet = "SWW_WAF_ANOMALY_WATCHED_NET_"
requestsPerMinute = "REQUESTS_PER_MINUTE"
requestsPerHour = "REQUESTS_PER_HOUR"
@@ -37,7 +37,7 @@ const (
const (
anomalyNetV4Prefix = "SWW_WAF_ANOMALY_NET_V4_PREFIX"
anomalyNetV6Prefix = "SWW_WAF_ANOMALY_NET_V6_PREFIX"
watchNets = "SWW_WAF_WATCH_NETS"
watchedNets = "SWW_WAF_ANOMALY_WATCHED_NETS"
)
const (
@@ -75,7 +75,7 @@ func TestEachScopeAndWindowOverItsThresholdAlertsOncePerCooldown(
{anomalyASN, anomaly.ScopeASN, netip.Prefix{}, asnDE, map[string]any{"asn": asnDE}},
{anomalyTotal, anomaly.ScopeTotal, netip.Prefix{}, "the whole service", nil},
{
anomalyWatch, anomaly.ScopeWatchedNet, netip.MustParsePrefix(clientsNet),
anomalyWatchedNet, anomaly.ScopeWatchedNet, netip.MustParsePrefix(clientsNet),
"the named netblock office, " + clientsNet, map[string]any{"name": "office"},
},
} {
@@ -97,7 +97,7 @@ func TestEachScopeAndWindowOverItsThresholdAlertsOncePerCooldown(
t.Run(setting, func(t *testing.T) {
t.Parallel()
env := map[string]string{setting: value, watchNets: office}
env := map[string]string{setting: value, watchedNets: office}
s, clk, server, queue := startWithLookupsAndClock(t, env)
start := clk.Now()
@@ -219,7 +219,7 @@ func TestThresholdsOffCountNothingAndAlertNothing(t *testing.T) {
// With every threshold off, nothing is counted.
s, server, queue := startWithLookups(
t,
map[string]string{watchNets: office},
map[string]string{watchedNets: office},
)
for range 5 {
@@ -234,7 +234,7 @@ func TestThresholdsOffCountNothingAndAlertNothing(t *testing.T) {
// With one set, its count alone is counted, in its scope alone.
s, clk, server, queue := startWithLookupsAndClock(t, map[string]string{
anomalyNet + requestsPerMinute: aLot, watchNets: office,
anomalyNet + requestsPerMinute: aLot, watchedNets: office,
})
for range 5 {
@@ -348,7 +348,7 @@ func TestRequestCountsForTheASNumberGeoJSGivesBeforeItEnds(t *testing.T) {
addr, out, started := startProxyWithClock(t, app.URL, geojsURL, clk.Now,
map[string]string{
trustedProxies: trustLocalhost,
lookupTimeout: "1h",
asnAndCountryLookupTimeout: "3600000",
anomalyASN + requestsPerMinute: aLot,
})
server.Store(started)
@@ -379,8 +379,8 @@ func TestEachNamedNetblockCountsTheClientsInIt(t *testing.T) {
t,
readAndAnswer,
map[string]string{
anomalyWatch + requestsPerMinute: aLot,
watchNets: office + ",wide=203.0.0.0/16" +
anomalyWatchedNet + requestsPerMinute: aLot,
watchedNets: office + ",wide=203.0.0.0/16" +
",other=198.51.100.0/25",
},
)
+40 -28
View File
@@ -29,13 +29,13 @@ import (
)
const (
// answerTimeLimit is the default of rproxy's SWW_RPROXY_TIMEOUT_MS, the time
// waf has to answer.
// answerTimeLimit is the default of rproxy's
// SWW_RPROXY_WAF_BACKEND_TIMEOUT_MS, the time waf has to answer.
answerTimeLimit = 2 * time.Second
// defaultLookupWait is the default of SWW_WAF_LOOKUP_TIMEOUT.
// defaultLookupWait is the default of SWW_WAF_ASN_AND_COUNTRY_LOOKUP_TIMEOUT_MS.
defaultLookupWait = 800 * time.Millisecond
// defaultReputationWait is the default of SWW_WAF_REPUTATION_TIMEOUT.
defaultReputationWait = 2 * time.Second
// defaultDNSBLAbuseIPDBWait is the default of SWW_WAF_DNSBL_ABUSEIPDB_TIMEOUT_MS.
defaultDNSBLAbuseIPDBWait = 2 * time.Second
// The header limits SPEC.md gives.
cookieLimit = 50
headerFieldLimit = 100
@@ -80,7 +80,7 @@ func TestSixteenThousandCookiesAreRefusedBeforeTheCoreRuleSetReadsThem(
) {
t.Parallel()
s := startWAF(t, "", map[string]string{wafMode: block})
s := startWAF(t, "", map[string]string{coreRuleSetMode: block})
req := s.question(siteA, client, "/")
req.Header.Set(cookieHeader, strings.Repeat("a;", 16000))
@@ -160,7 +160,7 @@ func TestEachHeaderLimitRefusesARequestOverIt(t *testing.T) {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
s := startWAF(t, "", map[string]string{wafMode: block})
s := startWAF(t, "", map[string]string{coreRuleSetMode: block})
req := s.question(siteA, client, "/")
tc.make(req)
@@ -195,9 +195,9 @@ func TestHeaderLimitsHoldWhateverTheCoreRuleSetSettingsSay(t *testing.T) {
t.Parallel()
for _, env := range []map[string]string{
{wafMode: off},
{wafMode: "detect"},
{wafMode: block, "SWW_WAF_CORE_RULE_SET_EXEMPT_PATHS": "/"},
{coreRuleSetMode: off},
{coreRuleSetMode: "detect"},
{coreRuleSetMode: block, "SWW_WAF_CORE_RULE_SET_EXEMPT_PATHS": "/"},
} {
t.Run(fmt.Sprint(env), func(t *testing.T) {
t.Parallel()
@@ -217,7 +217,7 @@ func TestClientInTheAllowNetsSkipsTheHeaderLimits(t *testing.T) {
s := startWAF(
t,
"",
map[string]string{wafMode: block, allowNets: client},
map[string]string{coreRuleSetMode: block, allowNets: client},
)
req := s.question(siteA, client, "/")
req.Header.Set(cookieHeader, cookies(cookieLimit+1))
@@ -228,7 +228,11 @@ func TestClientInTheAllowNetsSkipsTheHeaderLimits(t *testing.T) {
func TestObserveModeAllowsARequestOverTheHeaderLimitsUnread(t *testing.T) {
t.Parallel()
s := startWAF(t, "", map[string]string{mode: observe, wafMode: block})
s := startWAF(
t,
"",
map[string]string{mode: observe, coreRuleSetMode: block},
)
req := s.question(siteA, client, "/")
req.Header.Set(cookieHeader, strings.Repeat("a;", 16000))
@@ -298,7 +302,7 @@ func TestTheHeaderLimitsComeBeforeEveryCheckThatRefuses(t *testing.T) {
},
{
"a DNS blocklist", map[string]string{
dnsblZones: dnsblZone, dnsblResolver: noResolver, reputationAction: actionDeny,
dnsblZones: dnsblZone, dnsblResolver: noResolver, dnsblAbuseIPDBAction: actionDeny,
},
"/", func(_ *testing.T, server *waf.Server, _ time.Time) {
loadVerdicts(server, map[string][]string{client: {dnsblZone}})
@@ -306,7 +310,7 @@ func TestTheHeaderLimitsComeBeforeEveryCheckThatRefuses(t *testing.T) {
},
{
"AbuseIPDB",
map[string]string{abuseIPDBKey: accountKey, reputationAction: actionDeny},
map[string]string{abuseIPDBKey: accountKey, dnsblAbuseIPDBAction: actionDeny},
"/", func(_ *testing.T, server *waf.Server, _ time.Time) {
// Over SWW_WAF_ABUSEIPDB_MIN_SCORE, 75 by default.
loadScores(server, map[string]int64{client: 80})
@@ -325,7 +329,9 @@ func TestTheHeaderLimitsComeBeforeEveryCheckThatRefuses(t *testing.T) {
leaveAsItIs,
},
{
"a block rule", map[string]string{rulesDir: writeRules(t, testRules)}, "/blocked",
"a block rule",
map[string]string{ruleFilesDir: writeRules(t, testRules)},
"/blocked",
leaveAsItIs,
},
} {
@@ -366,9 +372,9 @@ func TestARequestOverTheHeaderLimitsRunsNoOtherCheck(t *testing.T) {
t,
map[string]standInRoute{client: {prefixDE, 64496}},
),
dnsblZones: dnsblZone, reputationAction: actionLog,
rulesDir: writeRules(t, "noted path log ^/\n"),
wafMode: block,
dnsblZones: dnsblZone, dnsblAbuseIPDBAction: actionLog,
ruleFilesDir: writeRules(t, "noted path log ^/\n"),
coreRuleSetMode: block,
})
loadLists(t, s.server, map[string][]string{vpnList: {client}})
loadVerdicts(s.server, map[string][]string{client: {dnsblZone}})
@@ -426,7 +432,9 @@ func TestNotWaitingForGeoJSNeverLowersAClientsLimit(t *testing.T) {
// not wait.
geojsURL, _ := startGeoJS(t)
s := startWAF(t, geojsURL, map[string]string{
unknownLimitPercent: "0", lookupTimeout: "1h", rateLimitPerMinute: "1",
unknownCountryLimitPercent: "0",
asnAndCountryLookupTimeout: "3600000",
rateLimitPerMinute: "1",
})
countClient(t, s.server, s.clock.Now())
@@ -459,7 +467,7 @@ func TestAClientGeoJSPlacedInNoCountryCountsAsUnknownWithoutWaiting(
// not wait.
geojsURL, _ := startGeoJS(t)
s := startWAF(t, geojsURL, map[string]string{
unknownLimitPercent: "0", rateLimitPerMinute: "1",
unknownCountryLimitPercent: "0", rateLimitPerMinute: "1",
})
s.server.GeoJS.Load([]lookup.Answer{{
Client: netip.MustParsePrefix(client + "/32"), ASN: "AS64512",
@@ -494,7 +502,7 @@ func TestObserveModeAsksGeoJSNothingForARequestOverTheHeaderLimits(
geojsURL, asked := startGeoJS(t)
s := startWAF(t, geojsURL, map[string]string{
mode: observe, addLookupHeaders: on, lookupTimeout: "1h",
mode: observe, addLookupHeaders: on, asnAndCountryLookupTimeout: "3600000",
})
req := s.question(siteA, fromDE, "/")
req.Header.Set(cookieHeader, cookies(cookieLimit+1))
@@ -622,8 +630,10 @@ func TestARefusedRequestDoesNotWaitForASlowGeoJS(t *testing.T) {
action: requestlog.ActionClientIsRateLimited,
},
{
name: "the rate limits, with a biased threshold",
env: map[string]string{rateLimitPerMinute: "1", unknownLimitPercent: "50"},
name: "the rate limits, with a biased threshold",
env: map[string]string{
rateLimitPerMinute: "1", unknownCountryLimitPercent: "50",
},
prepare: countClient, status: forbidden,
action: requestlog.ActionClientIsRateLimited,
},
@@ -646,8 +656,9 @@ func TestARefusedRequestDoesNotWaitForASlowGeoJS(t *testing.T) {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
// In a synctest bubble, as TestRequestWaitsAsLongAsTheLookupTimeoutSays
// says, so that a request that does not wait takes no time at all.
// In a synctest bubble, as
// TestRequestWaitsAsLongAsTheASNAndCountryLookupTimeoutSays says,
// so that a request that does not wait takes no time at all.
synctest.Test(t, func(t *testing.T) {
server, out := newWAFInBubble(t, tc.env)
tc.prepare(t, server, time.Now())
@@ -759,7 +770,7 @@ func TestWAFAnswersWithinTheTimeLimitWhileEveryOutsideSourceHangs(
return waf.New(params)
}, silentGeoJSURL, time.Now, map[string]string{
tagDeny: "country=KP", wafMode: block,
tagDeny: "country=KP", coreRuleSetMode: block,
dnsblZones: dnsblZone, dnsblResolver: resolver,
abuseIPDBKey: accountKey,
blocklistURLs: held + "/drop.txt", asnLimitPercentURL: held + "/asn.txt",
@@ -770,7 +781,8 @@ func TestWAFAnswersWithinTheTimeLimitWhileEveryOutsideSourceHangs(
// network inside it would keep its clock from moving on.
go server.Lists.Run(t.Context())
// In the bubble, as TestRequestWaitsAsLongAsTheLookupTimeoutSays says, the
// In the bubble, as
// TestRequestWaitsAsLongAsTheASNAndCountryLookupTimeoutSays says, the
// answer takes exactly as long as waf waits, however busy the host is.
// GeoJS and AbuseIPDB never answer there, without the network. The query to
// the DNS blocklist waits on the network, which stops the bubble's clock,
@@ -832,7 +844,7 @@ func TestWAFAnswersWithinTheTimeLimitWhileEveryOutsideSourceHangs(
// The query is answered, and the check with AbuseIPDB abandoned,
// before the bubble ends.
releaseResolver()
time.Sleep(defaultReputationWait)
time.Sleep(defaultDNSBLAbuseIPDBWait)
})
}
+4 -4
View File
@@ -81,11 +81,11 @@ func limitFigures(hit ratelimit.Hit) map[string]any {
// 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 SWW_WAF_BYTES_COUNT says.
// bytes, the request's, or both, as SWW_WAF_BYTES_COUNTED says.
func (rq *request) countedBytes() int64 {
response, request := rq.out.bytes, rq.requestBytes
switch rq.h.config.BytesCount {
switch rq.h.config.BytesCounted {
case "response":
return response
case "request":
@@ -100,8 +100,8 @@ func (rq *request) countedBytes() int64 {
// the metrics. status is what the client was sent, or is sent:
// SWW_WAF_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
// refusal limit. The ban's notes give the client's limit percentage for a
// rate limit or a byte limit; the refusal limit is not lowered. The ban sets
// the client's counters back to zero. In observe mode it makes no ban and
// sets nothing back, and raises the alert for the ban it would have made,
// if that alert would be sent.
+16 -16
View File
@@ -90,10 +90,10 @@ func TestBanLengthsFollowTheSettings(t *testing.T) {
t.Parallel()
s, clk, _ := startWithClock(t, "", map[string]string{
rateLimitPerMinute: "1",
limitBanDuration: "10m",
limitBanRepeatWindow: "1h",
maxBanDuration: "1h",
rateLimitPerMinute: "1",
limitBanDuration: "10m",
limitBanRepeatWindow: "1h",
limitBanPermanentAbove: "1h",
})
// breakLimit has client go over the limit of one a minute, and
@@ -130,7 +130,7 @@ func TestBanLengthsFollowTheSettings(t *testing.T) {
clk.advance(10 * time.Minute)
wantExpires(breakLimit(), 30*time.Minute)
// 90 minutes would be longer than SWW_WAF_MAX_BAN_DURATION: the ban is
// 90 minutes would be longer than SWW_WAF_LIMIT_BAN_PERMANENT_ABOVE: the ban is
// permanent.
clk.advance(30 * time.Minute)
@@ -256,10 +256,10 @@ func TestBannedClientIsRefusedBeforeItsCountryIsLookedUp(t *testing.T) {
geojsURL, asked := startGeoJS(t)
s, _, _ := startWithClock(t, geojsURL, map[string]string{
lookupTimeout: "1h",
rateLimitPerMinute: "1",
banScopeV4Prefix: "24",
tagDeny: denyKP,
asnAndCountryLookupTimeout: "3600000",
rateLimitPerMinute: "1",
banScopeV4Prefix: "24",
tagDeny: denyKP,
})
// fromDE's ban covers otherClient, which is refused unasked about.
@@ -319,10 +319,10 @@ func TestBanResponseAnswersEveryRefusalButTheSizeLimits(t *testing.T) {
geojsURL, _ := startGeoJS(t)
env := map[string]string{
lookupTimeout: "1h",
rateLimitPerMinute: "1",
denyNets: denied,
tagDeny: denyKP,
asnAndCountryLookupTimeout: "3600000",
rateLimitPerMinute: "1",
denyNets: denied,
tagDeny: denyKP,
}
if tc.setting != "" {
@@ -345,9 +345,9 @@ func TestBanNotes(t *testing.T) {
geojsURL, _ := startGeoJS(t)
s, clk, server := startWithClock(t, geojsURL, map[string]string{
lookupTimeout: "1h",
rateLimitPerMinute: "1",
tagDeny: denyKP,
asnAndCountryLookupTimeout: "3600000",
rateLimitPerMinute: "1",
tagDeny: denyKP,
})
start := clk.Now()
+28 -27
View File
@@ -17,14 +17,15 @@ type percentage struct {
}
// biasedThresholdsSet reports whether a biased threshold can lower a
// client's limits by its lookup: SWW_WAF_ASN_BYTES_PERCENT or
// SWW_WAF_COUNTRY_BYTES_PERCENT is not empty, SWW_WAF_UNKNOWN_LIMIT_PERCENT is
// below 100, or SWW_WAF_ASN_LIMIT_PERCENT_URL is set. The client's lookup is
// then needed before its request goes on.
func biasedThresholdsSet(cfg *config.Config) bool {
return len(cfg.ASNBytesPercent) > 0 ||
len(cfg.CountryBytesPercent) > 0 ||
cfg.UnknownLimitPercent < whole ||
// client's limits by its lookup: SWW_WAF_ASN_BYTES_LIMIT_PERCENT or
// SWW_WAF_COUNTRY_BYTES_LIMIT_PERCENT is not empty,
// SWW_WAF_UNKNOWN_COUNTRY_LIMIT_PERCENT is below 100, or
// SWW_WAF_ASN_LIMIT_PERCENT_URL is set. The client's lookup is then needed
// before its request goes on.
func biasedThresholdsSet(cfg *config.WAF) bool {
return len(cfg.ASNBytesLimitPercent) > 0 ||
len(cfg.CountryBytesLimitPercent) > 0 ||
cfg.UnknownCountryLimitPercent < whole ||
cfg.ASNLimitPercentURL != ""
}
@@ -37,13 +38,13 @@ func biasedThresholdsSet(cfg *config.Config) bool {
// SWW_WAF_ASN_LIMIT_PERCENT_URL names gives its AS number, the one
// SWW_WAF_TAG_LIMIT_PERCENT gives its country= tag, the lowest it gives its
// other tags, for a client without a country, unless its request skipped
// the wait for GeoJS, SWW_WAF_UNKNOWN_LIMIT_PERCENT, for a client a blocklist
// the wait for GeoJS, SWW_WAF_UNKNOWN_COUNTRY_LIMIT_PERCENT, for a client a blocklist
// lists, the percentage of SWW_WAF_BLOCKLIST_ACTION while it is limit, and
// for a client a DNSBL zone's verdict lists, or whose AbuseIPDB score is
// a hit, the percentage of SWW_WAF_REPUTATION_ACTION while it is limit. For
// the byte limits, SWW_WAF_ASN_BYTES_PERCENT and SWW_WAF_COUNTRY_BYTES_PERCENT
// take the place of the first three for an AS number or a country they
// list.
// a hit, the percentage of SWW_WAF_DNSBL_ABUSEIPDB_ACTION while it is limit. For
// the byte limits, SWW_WAF_ASN_BYTES_LIMIT_PERCENT and
// SWW_WAF_COUNTRY_BYTES_LIMIT_PERCENT take the place of the first three for
// an AS number or a country they list.
func (rq *request) limitPercentages() (percentage, percentage) {
cfg := rq.h.config
asn, country := rq.line.ASN, rq.line.Country
@@ -51,8 +52,8 @@ func (rq *request) limitPercentages() (percentage, percentage) {
unknown := percentage{percent: whole}
if country == "" && !rq.skippedLookupWait {
unknown = percentage{
cfg.UnknownLimitPercent,
"SWW_WAF_UNKNOWN_LIMIT_PERCENT",
cfg.UnknownCountryLimitPercent,
"SWW_WAF_UNKNOWN_COUNTRY_LIMIT_PERCENT",
}
}
@@ -69,12 +70,12 @@ func (rq *request) limitPercentages() (percentage, percentage) {
}
}
reputationListed := percentage{percent: whole}
dnsblAbuseIPDBListed := percentage{percent: whole}
if (rq.dnsblListed || rq.abuseIPDBHit) &&
cfg.ReputationAction == "limit" {
reputationListed = percentage{
cfg.ReputationLimitPercent,
"SWW_WAF_REPUTATION_ACTION",
cfg.DNSBLAbuseIPDBAction == "limit" {
dnsblAbuseIPDBListed = percentage{
cfg.DNSBLAbuseIPDBLimitPercent,
"SWW_WAF_DNSBL_ABUSEIPDB_ACTION",
}
}
@@ -102,24 +103,24 @@ func (rq *request) limitPercentages() (percentage, percentage) {
}
asnBytes, countryBytes := asnRequests, countryRequests
if _, listed := cfg.ASNBytesPercent[asn]; listed {
if _, listed := cfg.ASNBytesLimitPercent[asn]; listed {
asnBytes = given(
cfg.ASNBytesPercent,
cfg.ASNBytesLimitPercent,
asn,
"SWW_WAF_ASN_BYTES_PERCENT",
"SWW_WAF_ASN_BYTES_LIMIT_PERCENT",
)
}
if _, listed := cfg.CountryBytesPercent[country]; listed {
if _, listed := cfg.CountryBytesLimitPercent[country]; listed {
countryBytes = given(
cfg.CountryBytesPercent,
cfg.CountryBytesLimitPercent,
country,
"SWW_WAF_COUNTRY_BYTES_PERCENT",
"SWW_WAF_COUNTRY_BYTES_LIMIT_PERCENT",
)
}
// The limits that lower requests and bytes alike.
common := lowest(otherTags, unknown, blocklisted, reputationListed)
common := lowest(otherTags, unknown, blocklisted, dnsblAbuseIPDBListed)
return lowest(asnRequests, countryRequests, common),
lowest(asnBytes, countryBytes, common)
+51 -37
View File
@@ -8,6 +8,7 @@ import (
"net/http/httptest"
"net/netip"
"path/filepath"
"strconv"
"strings"
"testing"
"testing/synctest"
@@ -22,10 +23,10 @@ import (
// The biased thresholds.
const (
tagLimitPercent = "SWW_WAF_TAG_LIMIT_PERCENT"
asnBytesPercent = "SWW_WAF_ASN_BYTES_PERCENT"
countryBytesPercent = "SWW_WAF_COUNTRY_BYTES_PERCENT"
unknownLimitPercent = "SWW_WAF_UNKNOWN_LIMIT_PERCENT"
tagLimitPercent = "SWW_WAF_TAG_LIMIT_PERCENT"
asnBytesLimitPercent = "SWW_WAF_ASN_BYTES_LIMIT_PERCENT"
countryBytesLimitPercent = "SWW_WAF_COUNTRY_BYTES_LIMIT_PERCENT"
unknownCountryLimitPercent = "SWW_WAF_UNKNOWN_COUNTRY_LIMIT_PERCENT"
)
const (
@@ -60,7 +61,7 @@ func TestEachBiasedThresholdLowersTheRateLimits(t *testing.T) {
}{
{tagLimitPercent, asnTagDEHalf, fromDE},
{tagLimitPercent, countryTagDEHalf, fromDE},
{unknownLimitPercent, "50", unplaced},
{unknownCountryLimitPercent, "50", unplaced},
} {
t.Run(tc.setting, func(t *testing.T) {
t.Parallel()
@@ -108,9 +109,9 @@ func TestEachBiasedThresholdLowersTheByteLimits(t *testing.T) {
}{
{tagLimitPercent, asnTagDEHalf, fromDE},
{tagLimitPercent, "Country=de:50", fromDE},
{unknownLimitPercent, "50", unplaced},
{asnBytesPercent, "as64496:50", fromDE},
{countryBytesPercent, countryDEHalf, fromDE},
{unknownCountryLimitPercent, "50", unplaced},
{asnBytesLimitPercent, "as64496:50", fromDE},
{countryBytesLimitPercent, countryDEHalf, fromDE},
} {
t.Run(tc.setting, func(t *testing.T) {
t.Parallel()
@@ -149,7 +150,7 @@ func TestEachBiasedThresholdLowersTheByteLimits(t *testing.T) {
}
}
func TestBytesPercentSettingsTakeThePlaceOfTheOthersForByteLimits(
func TestBytesLimitPercentSettingsTakeThePlaceOfTheOthersForByteLimits(
t *testing.T,
) {
t.Parallel()
@@ -163,22 +164,28 @@ func TestBytesPercentSettingsTakeThePlaceOfTheOthersForByteLimits(
}{
{
"lowering the byte limits alone",
map[string]string{asnBytesPercent: asnDEHalf},
none, "50 from " + asnBytesPercent, minuteBytes,
map[string]string{asnBytesLimitPercent: asnDEHalf},
none, "50 from " + asnBytesLimitPercent, minuteBytes,
},
{
"lowering the byte limits alone, by country",
map[string]string{countryBytesPercent: countryDEHalf},
none, "50 from " + countryBytesPercent, minuteBytes,
map[string]string{countryBytesLimitPercent: countryDEHalf},
none, "50 from " + countryBytesLimitPercent, minuteBytes,
},
{
"raising the byte limits back",
map[string]string{tagLimitPercent: asnTagDEHalf, asnBytesPercent: asnDE + ":100"},
map[string]string{
tagLimitPercent: asnTagDEHalf,
asnBytesLimitPercent: asnDE + ":100",
},
"50 from " + tagLimitPercent, none, "",
},
{
"raising the byte limits back, by country",
map[string]string{tagLimitPercent: countryTagDEHalf, countryBytesPercent: "de:100"},
map[string]string{
tagLimitPercent: countryTagDEHalf,
countryBytesLimitPercent: "de:100",
},
"50 from " + tagLimitPercent, none, "",
},
} {
@@ -214,7 +221,7 @@ func TestZeroPercentIsAZeroAllowance(t *testing.T) {
s, _, _ := startWithLookups(
t,
map[string]string{unknownLimitPercent: "0"},
map[string]string{unknownCountryLimitPercent: "0"},
)
// The first request breaks the limit, and bans the client; the log line
@@ -225,12 +232,12 @@ func TestZeroPercentIsAZeroAllowance(t *testing.T) {
requestlog.ActionClientIsRateLimited,
)
if line.fields["rate_limit_percent"] != float64(0) ||
line.fields["rate_limit_percent_setting"] != unknownLimitPercent {
line.fields["rate_limit_percent_setting"] != unknownCountryLimitPercent {
t.Errorf(
"log line has rate_limit_percent %v from %v, want 0 from %s",
line.fields["rate_limit_percent"],
line.fields["rate_limit_percent_setting"],
unknownLimitPercent,
unknownCountryLimitPercent,
)
}
@@ -264,11 +271,11 @@ func TestLowestPercentageApplies(t *testing.T) {
},
{
"that for a client without a country",
map[string]string{tagLimitPercent: "asn=64500:50", unknownLimitPercent: "25"},
noCountry, "25 from " + unknownLimitPercent,
map[string]string{tagLimitPercent: "asn=64500:50", unknownCountryLimitPercent: "25"},
noCountry, "25 from " + unknownCountryLimitPercent,
},
{
// SWW_WAF_UNKNOWN_LIMIT_PERCENT is left at its default, 100.
// SWW_WAF_UNKNOWN_COUNTRY_LIMIT_PERCENT is left at its default, 100.
"the AS number's, for a client without a country",
map[string]string{tagLimitPercent: "asn=64500:25"},
noCountry, "25 from " + tagLimitPercent,
@@ -296,13 +303,13 @@ func TestLowestPercentageApplies(t *testing.T) {
}
}
func TestUnknownLimitPercentGivesEveryClientWithoutACountryItsPercentage(
func TestUnknownCountryLimitPercentGivesEveryClientWithoutACountryItsPercentage(
t *testing.T,
) {
t.Parallel()
s, _, _ := startWithLookups(t, map[string]string{
rateLimitPerMinute: fourAMinute, unknownLimitPercent: "50",
rateLimitPerMinute: fourAMinute, unknownCountryLimitPercent: "50",
})
// One the lookup database does not hold, and one on a private address,
@@ -323,14 +330,17 @@ func TestUnknownLimitPercentGivesEveryClientWithoutACountryItsPercentage(
}
}
func TestClientWithoutAnAnswerInTimeHasTheUnknownLimitPercent(t *testing.T) {
func TestClientWithoutAnAnswerInTimeHasTheUnknownCountryLimitPercent(
t *testing.T,
) {
t.Parallel()
// In a synctest bubble, as TestRequestWaitsAsLongAsTheLookupTimeoutSays
// says, with a GeoJS that never answers.
// In a synctest bubble, as
// TestRequestWaitsAsLongAsTheASNAndCountryLookupTimeoutSays says, with
// a GeoJS that never answers.
synctest.Test(t, func(t *testing.T) {
server, out, _ := newWAF(t, silentGeoJSURL, time.Now,
map[string]string{unknownLimitPercent: "0"})
map[string]string{unknownCountryLimitPercent: "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
@@ -346,7 +356,7 @@ func TestClientWithoutAnAnswerInTimeHasTheUnknownLimitPercent(t *testing.T) {
)
wantPercent(t, "rate_limit_percent",
line.RateLimitPercent, line.RateLimitPercentSetting,
"0 from "+unknownLimitPercent)
"0 from "+unknownCountryLimitPercent)
})
}
@@ -365,22 +375,26 @@ func TestRequestWaitsForItsLookupWhileABiasedThresholdIsSet(t *testing.T) {
// A tag the lookup does not give needs no answer.
{tagLimitPercent, "vpn:50", false},
{tagDeny, "vpn", false},
{asnBytesPercent, asnDEHalf, true},
{countryBytesPercent, countryDEHalf, true},
{unknownLimitPercent, "99", true},
{asnBytesLimitPercent, asnDEHalf, true},
{countryBytesLimitPercent, countryDEHalf, true},
{unknownCountryLimitPercent, "99", true},
{asnLimitPercentURL, asnURL, true},
// At 100, its default, it lowers no limit.
{unknownLimitPercent, "100", false},
{unknownCountryLimitPercent, "100", false},
} {
t.Run(tc.setting+"="+tc.value, func(t *testing.T) {
t.Parallel()
// In a synctest bubble, as TestRequestWaitsAsLongAsTheLookupTimeoutSays
// says, with a GeoJS that never answers.
// In a synctest bubble, as
// TestRequestWaitsAsLongAsTheASNAndCountryLookupTimeoutSays says,
// with a GeoJS that never answers.
synctest.Test(t, func(t *testing.T) {
server, out, _ := newWAF(t, silentGeoJSURL, time.Now,
map[string]string{
lookupTimeout: timeout.String(), tagLists: "vpn=" + vpnList, tc.setting: tc.value,
asnAndCountryLookupTimeout: strconv.FormatInt(
timeout.Milliseconds(), 10),
tagLists: "vpn=" + vpnList,
tc.setting: tc.value,
})
began := time.Now()
@@ -449,10 +463,10 @@ func TestBanForALoweredLimitGivesThePercentageInItsNotesAndItsAlert(
"a byte limit",
map[string]string{
bytesLimitPerMinute: twoUploads, tagLimitPercent: asnTagDEQuarter,
asnBytesPercent: asnDEHalf,
asnBytesLimitPercent: asnDEHalf,
},
0, http.StatusOK, requestlog.ActionForward,
"bytes per minute over the limit of 99", "50 from " + asnBytesPercent,
"bytes per minute over the limit of 99", "50 from " + asnBytesLimitPercent,
},
} {
t.Run(tc.name, func(t *testing.T) {
+1 -1
View File
@@ -1,6 +1,6 @@
package waf
// The heuristic byte_limits. It reads SWW_WAF_BYTES_COUNT, and the byte limits
// The heuristic byte_limits. It reads SWW_WAF_BYTES_COUNTED, and the byte limits
// and biased thresholds through the limiter and limitPercentages.
import (
+5 -5
View File
@@ -22,10 +22,10 @@ const (
bytesLimitPerMinute = "SWW_WAF_BYTES_LIMIT_PER_MINUTE"
bytesLimitPerHour = "SWW_WAF_BYTES_LIMIT_PER_HOUR"
bytesLimitPerDay = "SWW_WAF_BYTES_LIMIT_PER_DAY"
bytesCount = "SWW_WAF_BYTES_COUNT"
bytesCounted = "SWW_WAF_BYTES_COUNTED"
)
// The values of SWW_WAF_BYTES_COUNT.
// The values of SWW_WAF_BYTES_COUNTED.
const (
countResponse = "response"
countRequest = "request"
@@ -212,7 +212,7 @@ func TestWebSocketBytesAreCountedOnceItCloses(t *testing.T) {
t,
answerAfterUpgrade,
map[string]string{
bytesLimitPerMinute: "29", bytesCount: tc.setting,
bytesLimitPerMinute: "29", bytesCounted: tc.setting,
},
)
@@ -239,7 +239,7 @@ func TestWebSocketBytesAreCountedOnceItCloses(t *testing.T) {
}
}
func TestBytesCountSaysWhichBytesCount(t *testing.T) {
func TestBytesCountedSaysWhichBytesAreCounted(t *testing.T) {
t.Parallel()
for _, tc := range []struct {
@@ -257,7 +257,7 @@ func TestBytesCountSaysWhichBytesCount(t *testing.T) {
t.Parallel()
s, _ := startWithAnswers(t, map[string]string{
bytesLimitPerMinute: byteLimit, bytesCount: tc.setting,
bytesLimitPerMinute: byteLimit, bytesCounted: tc.setting,
})
for i := 1; i <= tc.breaking; i++ {
+11 -11
View File
@@ -1,7 +1,7 @@
package waf
// The heuristic core_rule_set. It reads SWW_WAF_CORE_RULE_SET_MODE,
// SWW_WAF_CORE_RULE_SET_EXEMPT_PATHS and SWW_WAF_CORE_RULE_SET_ANOMALY_THRESHOLD.
// SWW_WAF_CORE_RULE_SET_EXEMPT_PATHS and SWW_WAF_CORE_RULE_SET_SCORE_THRESHOLD.
import (
"context"
@@ -17,7 +17,7 @@ import (
// SWW_WAF_CORE_RULE_SET_MODE is off or SWW_WAF_CORE_RULE_SET_EXEMPT_PATHS
// exempts its path, as pathExempt decides, and notes the rules it matched and
// its score in the log line, and the rules in the metrics. A score at or over
// SWW_WAF_CORE_RULE_SET_ANOMALY_THRESHOLD is a match: it adds the tag
// SWW_WAF_CORE_RULE_SET_SCORE_THRESHOLD is a match: it adds the tag
// exploitsignature, raises the core_rule_set_match alert, and in block mode
// refuses the request with 403, which is an offence its client's history
// counts. Its figures are the score and the threshold.
@@ -31,8 +31,8 @@ func (coreRuleSet) check(
_ time.Time,
) *verdict {
cfg := rq.h.config
if cfg.WAFMode == config.WAFModeOff ||
pathExempt(rq.in, cfg.WAFExemptPaths) {
if cfg.CoreRuleSetMode == config.CoreRuleSetModeOff ||
pathExempt(rq.in, cfg.CoreRuleSetExemptPaths) {
return nil
}
@@ -50,10 +50,10 @@ func (coreRuleSet) check(
rq.line.CoreRuleSetScore = &result.Score
for _, id := range result.RuleIDs {
rq.h.metrics.CoreRuleSetMatched(cfg.WAFMode, id)
rq.h.metrics.CoreRuleSetMatched(cfg.CoreRuleSetMode, id)
}
threshold := cfg.WAFAnomalyThreshold
threshold := cfg.CoreRuleSetScoreThreshold
if threshold == 0 || result.Score < threshold {
return nil
}
@@ -61,7 +61,7 @@ func (coreRuleSet) check(
rq.addTag(config.ExploitSignatureTag)
rq.alertCoreRuleSetMatch(result)
if cfg.WAFMode == config.WAFModeDetect {
if cfg.CoreRuleSetMode == config.CoreRuleSetModeDetect {
return nil
}
@@ -78,7 +78,7 @@ func (coreRuleSet) check(
// alertCoreRuleSetMatch raises the core_rule_set_match alert for the request,
// which the Core Rule Set scored at result, at or over
// SWW_WAF_CORE_RULE_SET_ANOMALY_THRESHOLD. Its detail gives the rule ids, the
// SWW_WAF_CORE_RULE_SET_SCORE_THRESHOLD. Its detail gives the rule ids, the
// score, the method and the path with the query, and, for a request that is
// not refused for it, the mode: detect, or observe in observe mode.
func (rq *request) alertCoreRuleSetMatch(result coreruleset.Result) {
@@ -90,8 +90,8 @@ func (rq *request) alertCoreRuleSetMatch(result coreruleset.Result) {
}
switch {
case rq.h.config.WAFMode == config.WAFModeDetect:
detail["mode"] = config.WAFModeDetect
case rq.h.config.CoreRuleSetMode == config.CoreRuleSetModeDetect:
detail["mode"] = config.CoreRuleSetModeDetect
case rq.h.config.Observe:
detail["mode"] = "observe"
}
@@ -105,7 +105,7 @@ func (rq *request) alertCoreRuleSetMatch(result coreruleset.Result) {
ASName: rq.line.ASName,
Country: rq.line.Country,
Reason: "scored by the Core Rule Set at or over " +
"SWW_WAF_CORE_RULE_SET_ANOMALY_THRESHOLD",
"SWW_WAF_CORE_RULE_SET_SCORE_THRESHOLD",
Detail: detail,
})
}
+56 -40
View File
@@ -18,20 +18,20 @@ import (
// The Core Rule Set's settings the tests set, besides SWW_WAF_CORE_RULE_SET_MODE, and
// its two modes that inspect requests.
const (
wafAnomalyThreshold = "SWW_WAF_CORE_RULE_SET_ANOMALY_THRESHOLD"
wafDisabledRules = "SWW_WAF_CORE_RULE_SET_DISABLED_RULES"
wafExemptPaths = "SWW_WAF_CORE_RULE_SET_EXEMPT_PATHS"
wafBodyLimit = "SWW_WAF_CORE_RULE_SET_BODY_LIMIT"
apps = "SWW_WAF_APPS"
block = "block"
detect = "detect"
coreRuleSetScoreThreshold = "SWW_WAF_CORE_RULE_SET_SCORE_THRESHOLD"
coreRuleSetDisabledRules = "SWW_WAF_CORE_RULE_SET_DISABLED_RULES"
coreRuleSetExemptPaths = "SWW_WAF_CORE_RULE_SET_EXEMPT_PATHS"
coreRuleSetBodyInspectionSize = "SWW_WAF_CORE_RULE_SET_BODY_INSPECTION_SIZE"
apps = "SWW_WAF_APPS"
block = "block"
detect = "detect"
)
// formData is the type of a form's body.
const formData = "application/x-www-form-urlencoded"
// sizeLimit is SWW_WAF_CORE_RULE_SET_BODY_LIMIT in the tests that read bodies, 1K as a
// setting.
// sizeLimit is SWW_WAF_CORE_RULE_SET_BODY_INSPECTION_SIZE in the tests that
// read bodies, 1K as a setting.
const (
sizeLimit = 1 << 10
sizeLimitSetting = "1K"
@@ -39,7 +39,7 @@ const (
// 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
// SWW_WAF_CORE_RULE_SET_ANOMALY_THRESHOLD.
// SWW_WAF_CORE_RULE_SET_SCORE_THRESHOLD.
const sqlInjection = "/?id=1'%20OR%20'1'='1"
// wantCoreRuleSet checks the request log line's core_rule_set_rule_ids and
@@ -95,7 +95,7 @@ func TestCoreRuleSetMatchAddsTheExploitSignatureTag(t *testing.T) {
s, _, _ := startWithClock(
t,
"",
map[string]string{wafMode: tc.mode},
map[string]string{coreRuleSetMode: tc.mode},
)
wantTags(
@@ -115,7 +115,7 @@ func TestDetectedAttackIsRefusedByTagDenyLast(t *testing.T) {
t.Parallel()
s, _, _ := startWithClock(t, "", map[string]string{
wafMode: detect, tagDeny: "exploitsignature",
coreRuleSetMode: detect, tagDeny: "exploitsignature",
})
line := s.request(client, sqlInjection, http.StatusForbidden,
@@ -167,7 +167,7 @@ func TestCoreRuleSetRefusesAttacksInBlockModeAndOnlyLogsThemInDetectMode(
s, _, _ := startWithClock(
t,
"",
map[string]string{wafMode: tc.mode},
map[string]string{coreRuleSetMode: tc.mode},
)
line := s.requestWithHeader(
@@ -186,7 +186,11 @@ func TestCoreRuleSetRefusesAttacksInBlockModeAndOnlyLogsThemInDetectMode(
func TestCoreRuleSetRefusalNamesItsHeuristicScoreAndThreshold(t *testing.T) {
t.Parallel()
s, _, _ := startWithClock(t, "", map[string]string{wafMode: block})
s, _, _ := startWithClock(
t,
"",
map[string]string{coreRuleSetMode: block},
)
line := s.request(client, sqlInjection, http.StatusForbidden,
requestlog.ActionBlockedByCoreRuleSet)
@@ -196,7 +200,11 @@ func TestCoreRuleSetRefusalNamesItsHeuristicScoreAndThreshold(t *testing.T) {
func TestOrdinaryRequestIsInspectedAndPassed(t *testing.T) {
t.Parallel()
s, _, _ := startWithClock(t, "", map[string]string{wafMode: block})
s, _, _ := startWithClock(
t,
"",
map[string]string{coreRuleSetMode: block},
)
line := s.request(
client,
@@ -215,10 +223,10 @@ func TestCoreRuleSetIsNotRunWhenOffOrForAnExemptClientPathOrRuleFileRefusal(
const allowed = "192.0.2.60" // in SWW_WAF_ALLOW_NETS
s, _, _ := startWithClock(t, "", map[string]string{
wafMode: block,
wafExemptPaths: "/api/",
allowNets: allowed,
rulesDir: writeRules(t, testRules),
coreRuleSetMode: block,
coreRuleSetExemptPaths: "/api/",
allowNets: allowed,
ruleFilesDir: writeRules(t, testRules),
})
// A client in SWW_WAF_ALLOW_NETS, and a path SWW_WAF_CORE_RULE_SET_EXEMPT_PATHS
@@ -258,7 +266,7 @@ func TestCoreRuleSetIsNotRunWhenOffOrForAnExemptClientPathOrRuleFileRefusal(
wantCoreRuleSet(t, line, nil)
// With SWW_WAF_CORE_RULE_SET_MODE off, no request is.
s, _, _ = startWithClock(t, "", map[string]string{wafMode: off})
s, _, _ = startWithClock(t, "", map[string]string{coreRuleSetMode: off})
line = s.request(
client,
sqlInjection,
@@ -268,14 +276,14 @@ func TestCoreRuleSetIsNotRunWhenOffOrForAnExemptClientPathOrRuleFileRefusal(
wantCoreRuleSet(t, line, nil)
}
func TestAnomalyThreshold(t *testing.T) {
func TestCoreRuleSetScoreThreshold(t *testing.T) {
t.Parallel()
// A score under the threshold, or with the threshold off, is logged,
// and refuses nothing.
for _, threshold := range []string{"6", off} {
s, _, _ := startWithClock(t, "", map[string]string{
wafMode: block, wafAnomalyThreshold: threshold,
coreRuleSetMode: block, coreRuleSetScoreThreshold: threshold,
})
line := s.request(
@@ -288,7 +296,7 @@ func TestAnomalyThreshold(t *testing.T) {
}
s, _, _ := startWithClock(t, "", map[string]string{
wafMode: block, wafAnomalyThreshold: "5",
coreRuleSetMode: block, coreRuleSetScoreThreshold: "5",
})
s.request(client, sqlInjection, http.StatusForbidden,
requestlog.ActionBlockedByCoreRuleSet)
@@ -334,7 +342,7 @@ func TestDisabledRulesSwitchOffWhatGiteaWouldBeRefused(t *testing.T) {
s, _, _ := startWithClock(
t,
"",
map[string]string{wafMode: block},
map[string]string{coreRuleSetMode: block},
)
line, _ := s.requestWithBody(
request.method,
@@ -349,7 +357,7 @@ func TestDisabledRulesSwitchOffWhatGiteaWouldBeRefused(t *testing.T) {
// A list given replaces the default.
s, _, _ = startWithClock(t, "", map[string]string{
wafMode: block, wafDisabledRules: "942100",
coreRuleSetMode: block, coreRuleSetDisabledRules: "942100",
})
score := 5 * len(request.ruleIDs)
line, _ = s.requestWithBody(
@@ -383,7 +391,7 @@ func TestGiteasFixesApplyOnlyToAHostnameThatServesGitea(t *testing.T) {
const fileName = "/?path=.gitignore"
s := startWAF(t, "", map[string]string{
wafMode: block, apps: siteA + ":gitea",
coreRuleSetMode: block, apps: siteA + ":gitea",
})
wantDisposition(t, do(t, s.question(siteA, client, fileName)),
@@ -409,13 +417,17 @@ func TestAttackInAFormBodyIsRefusedOnlyWhileBodiesAreRead(t *testing.T) {
header := "Content-Type: " + formData + "\r\nContent-Length: " +
strconv.Itoa(len(body))
s, _, _ := startWithClock(t, "", map[string]string{wafMode: block})
s, _, _ := startWithClock(
t,
"",
map[string]string{coreRuleSetMode: block},
)
line, _ := s.requestWithBody(http.MethodPost, client, "/", header, body,
http.StatusOK, requestlog.ActionForward)
wantCoreRuleSet(t, line, new(0))
s, _, _ = startWithClock(t, "", map[string]string{
wafMode: block, wafBodyLimit: sizeLimitSetting,
coreRuleSetMode: block, coreRuleSetBodyInspectionSize: sizeLimitSetting,
})
line, _ = s.requestWithBody(http.MethodPost, client, "/", header, body,
http.StatusForbidden, requestlog.ActionBlockedByCoreRuleSet)
@@ -432,7 +444,7 @@ func TestBodiesReachTheAppAsSentWhileBodiesAreRead(t *testing.T) {
_, _ = w.Write(body)
})
addr, out := startProxy(t, app.URL, map[string]string{
wafMode: block, wafBodyLimit: sizeLimitSetting,
coreRuleSetMode: block, coreRuleSetBodyInspectionSize: sizeLimitSetting,
})
longer := "a=" + strings.Repeat("b", 64*sizeLimit)
@@ -496,7 +508,11 @@ func TestResponsesAreNotInspected(t *testing.T) {
app := startApp(t, func(w http.ResponseWriter, _ *http.Request) {
_, _ = w.Write([]byte(page))
})
addr, out := startProxy(t, app.URL, map[string]string{wafMode: block})
addr, out := startProxy(
t,
app.URL,
map[string]string{coreRuleSetMode: block},
)
got := get(t, addr, "/owner/repo/raw/branch/main/build.sh")
if got.status != http.StatusOK || string(got.body) != page {
@@ -510,13 +526,13 @@ func TestResponsesAreNotInspected(t *testing.T) {
wantLine(t, out.endedLine(t), http.StatusOK, requestlog.ActionForward)
}
func TestCoreRuleSetRefusalIsAnOffenceAndCountsTowardTheErrorBurst(
func TestCoreRuleSetRefusalIsAnOffenceAndCountsTowardTheRefusalLimit(
t *testing.T,
) {
t.Parallel()
s, _, server := startWithClock(t, "", map[string]string{
wafMode: block, errorBurstThreshold: "2", metricsToken: token,
coreRuleSetMode: block, refusalsLimitPerMinute: "2", metricsToken: token,
})
for range 2 {
@@ -524,7 +540,7 @@ func TestCoreRuleSetRefusalIsAnOffenceAndCountsTowardTheErrorBurst(
requestlog.ActionBlockedByCoreRuleSet)
}
// The third refusal in a minute breaks the error burst, and bans the
// The third refusal in a minute breaks the refusal limit, and bans the
// client.
line := s.request(client, sqlInjection, http.StatusForbidden,
requestlog.ActionBlockedByCoreRuleSet)
@@ -566,13 +582,13 @@ func TestCoreRuleSetRefusalIsAnOffenceAndCountsTowardTheErrorBurst(
)
}
func TestDetectModeMatchIsNoOffenceAndNotCountedTowardTheErrorBurst(
func TestDetectModeMatchIsNoOffenceAndNotCountedTowardTheRefusalLimit(
t *testing.T,
) {
t.Parallel()
s, _, server := startWithClock(t, "", map[string]string{
wafMode: detect, errorBurstThreshold: "2", metricsToken: token,
coreRuleSetMode: detect, refusalsLimitPerMinute: "2", metricsToken: token,
})
for range 3 {
@@ -608,7 +624,7 @@ func TestObserveModeLogsWhatTheCoreRuleSetWouldDo(t *testing.T) {
s, _, server := startWithClock(
t,
"",
map[string]string{wafMode: block, mode: observe},
map[string]string{coreRuleSetMode: block, mode: observe},
)
line := s.request(
@@ -640,15 +656,15 @@ func TestCoreRuleSetMatchRaisesTheCoreRuleSetMatchAlert(t *testing.T) {
action, alertMode string
}{
{
"block", map[string]string{wafMode: block},
"block", map[string]string{coreRuleSetMode: block},
http.StatusForbidden, requestlog.ActionBlockedByCoreRuleSet, "",
},
{
"detect", map[string]string{wafMode: detect},
"detect", map[string]string{coreRuleSetMode: detect},
http.StatusOK, requestlog.ActionForward, detect,
},
{
"block in observe mode", map[string]string{wafMode: block, mode: observe},
"block in observe mode", map[string]string{coreRuleSetMode: block, mode: observe},
http.StatusOK, requestlog.ActionForward, observe,
},
} {
@@ -683,7 +699,7 @@ func TestCoreRuleSetMatchRaisesTheCoreRuleSetMatchAlert(t *testing.T) {
Client: netip.MustParseAddr(client),
Netblock: netip.MustParsePrefix(client + "/32"),
Reason: "scored by the Core Rule Set at or over " +
"SWW_WAF_CORE_RULE_SET_ANOMALY_THRESHOLD",
"SWW_WAF_CORE_RULE_SET_SCORE_THRESHOLD",
Detail: detail,
})
+17 -16
View File
@@ -53,8 +53,8 @@ func TestCountryLists(t *testing.T) {
})
geojsURL, _ := startGeoJS(t)
env := map[string]string{
trustedProxies: trustLocalhost,
lookupTimeout: "1h",
trustedProxies: trustLocalhost,
asnAndCountryLookupTimeout: "3600000",
}
maps.Copy(env, tc.env)
addr, out := startProxyWithGeoJS(t, app.URL, geojsURL, env)
@@ -109,7 +109,7 @@ func TestCountryRefusalNamesItsHeuristicCountryAndSetting(t *testing.T) {
geojsURL, _ := startGeoJS(t)
s, _, _ := startWithClock(t, geojsURL, map[string]string{
lookupTimeout: "1h", allowedCountries: "de",
asnAndCountryLookupTimeout: "3600000", allowedCountries: "de",
})
line := s.get(
@@ -147,9 +147,9 @@ func TestCountryRefusalKeepsARequestFromTheApp(t *testing.T) {
})
geojsURL, _ := startGeoJS(t)
addr, out := startProxyWithGeoJS(t, app.URL, geojsURL, map[string]string{
trustedProxies: trustLocalhost,
lookupTimeout: "1h",
allowedCountries: "de",
trustedProxies: trustLocalhost,
asnAndCountryLookupTimeout: "3600000",
allowedCountries: "de",
})
req := newRequest(
@@ -198,10 +198,10 @@ func TestRequestRefusedByCountryIsNotCounted(t *testing.T) {
app := startApp(t, func(http.ResponseWriter, *http.Request) {})
addr, _ := startProxyWithGeoJS(t, app.URL, geojs.URL, map[string]string{
trustedProxies: trustLocalhost,
lookupTimeout: "1h",
allowedCountries: "de",
rateLimitPerMinute: "1",
trustedProxies: trustLocalhost,
asnAndCountryLookupTimeout: "3600000",
allowedCountries: "de",
rateLimitPerMinute: "1",
})
request := func() answer {
@@ -248,8 +248,8 @@ func TestPrivateAddressIsNeverLookedUp(t *testing.T) {
app := startApp(t, func(http.ResponseWriter, *http.Request) {})
geojsURL, asked := startGeoJS(t)
env := map[string]string{
trustedProxies: trustLocalhost,
lookupTimeout: "1h",
trustedProxies: trustLocalhost,
asnAndCountryLookupTimeout: "3600000",
}
maps.Copy(env, tc.env)
addr, out := startProxyWithGeoJS(t, app.URL, geojsURL, env)
@@ -352,10 +352,11 @@ func TestExclusiveListRefusesAPrivateAddressUnlessAllowed(t *testing.T) {
// startGeoJS starts a stand-in for GeoJS, which places fromDE and fromKP,
// each in an AS of its own, and no other address. It returns its URL, and
// what returns the addresses it has been asked about. A test that needs
// the stand-in to be asked or to answer sets SWW_WAF_LOOKUP_TIMEOUT to an
// hour, whether or not a request waits for the answer: on the default, a
// hold-up of the test process can abandon the request to the stand-in, and
// leave the client unknown.
// the stand-in to be asked or to answer sets
// SWW_WAF_ASN_AND_COUNTRY_LOOKUP_TIMEOUT_MS to an hour, whether or not a
// request waits for the answer: on the default, a hold-up of the test
// process can abandon the request to the stand-in, and leave the client
// unknown.
func startGeoJS(t *testing.T) (string, func() []string) {
t.Helper()
+3 -3
View File
@@ -1,6 +1,6 @@
package waf
// The heuristic dnsbl. It reads SWW_WAF_REPUTATION_ACTION, and the DNSBL zones'
// The heuristic dnsbl. It reads SWW_WAF_DNSBL_ABUSEIPDB_ACTION, and the DNSBL zones'
// verdicts through the DNSBL checker.
import (
@@ -14,7 +14,7 @@ import (
// dnsbl notes the DNSBL zones whose verdict lists the client, as noteListed
// does, adds the tag dnsbl=<zone> for each, the zone's key masked, and refuses
// the request when SWW_WAF_REPUTATION_ACTION is deny. Being
// the request when SWW_WAF_DNSBL_ABUSEIPDB_ACTION is deny. Being
// limit, it lowers the client's limits instead (see limitPercentages), and
// being log, it does nothing more. A zone without a verdict on the client is
// asked about it in the background, and the request does not wait for the
@@ -32,7 +32,7 @@ func (dnsbl) check(ctx context.Context, rq *request, _ time.Time) *verdict {
rq.addTag(config.DNSBLTagPrefix + strings.ToLower(zone))
}
if !rq.dnsblListed || rq.h.config.ReputationAction != deny {
if !rq.dnsblListed || rq.h.config.DNSBLAbuseIPDBAction != deny {
return nil
}
+1 -1
View File
@@ -7,7 +7,7 @@ import (
// To add a heuristic, write one file with a type that implements heuristic,
// and add one line for it to one of the lists below. Its settings go in
// config.Config, and the comment at the top of its file names them.
// config.WAF, and the comment at the top of its file names them.
// heuristic is one of the checks that decide what waf does with a
// request.
+3 -3
View File
@@ -18,9 +18,9 @@ func TestHistoryKeepsEachRequestOfTheClient(t *testing.T) {
geojsURL, _ := startGeoJS(t)
s, clk, server := startWithClock(t, geojsURL, map[string]string{
lookupTimeout: "1h",
rateLimitPerMinute: "2",
tagDeny: denyKP,
asnAndCountryLookupTimeout: "3600000",
rateLimitPerMinute: "2",
tagDeny: denyKP,
})
start := clk.Now()
+14 -10
View File
@@ -5,6 +5,7 @@ import (
"net/netip"
"path/filepath"
"slices"
"strconv"
"sync"
"testing"
"testing/synctest"
@@ -34,8 +35,8 @@ func TestEveryClientIsLookedUpWithoutWaitingWhileNoSettingNeedsIt(
// waitLimit.
geojsURL, asked, release := startHeldGeoJS(t)
s, _, server := startWithClock(t, geojsURL, map[string]string{
lookupTimeout: "1h",
rateLimitPerMinute: "1",
asnAndCountryLookupTimeout: "3600000",
rateLimitPerMinute: "1",
})
// fromDE's second request breaks the limit and bans it, and fromKP
@@ -104,9 +105,9 @@ func TestRequestThatWaitedForItsLookupIsCountedWhenItGoesOn(t *testing.T) {
clk := &clock{now: time.Date(2026, 10, 6, 0, 0, 58, 0, time.UTC)}
addr, out, server := startProxyWithClock(t, app.URL, geojsURL, clk.Now,
map[string]string{
trustedProxies: trustLocalhost,
lookupTimeout: "1h",
tagDeny: denyKP,
trustedProxies: trustLocalhost,
asnAndCountryLookupTimeout: "3600000",
tagDeny: denyKP,
})
requestFromDE := func() *http.Request {
@@ -364,9 +365,9 @@ func TestLookupHeadersArePassedToTheAppAndTheClientsOwnRemoved(
geojsURL,
time.Now,
map[string]string{
trustedProxies: trustLocalhost,
lookupTimeout: "1h",
addLookupHeaders: on,
trustedProxies: trustLocalhost,
asnAndCountryLookupTimeout: "3600000",
addLookupHeaders: on,
},
)
s := &sender{t: t, addr: addr, out: out}
@@ -437,7 +438,9 @@ func TestClientsOwnLookupHeadersAreRemovedWhileTheSettingIsOff(
}
}
func TestRequestWaitsAsLongAsTheLookupTimeoutSays(t *testing.T) {
func TestRequestWaitsAsLongAsTheASNAndCountryLookupTimeoutSays(
t *testing.T,
) {
t.Parallel()
// The test runs in a synctest bubble, where the time package runs on a
@@ -452,7 +455,8 @@ func TestRequestWaitsAsLongAsTheLookupTimeoutSays(t *testing.T) {
server, out, _ := newWAF(t, silentGeoJSURL, time.Now,
map[string]string{
lookupTimeout: timeout.String(),
asnAndCountryLookupTimeout: strconv.FormatInt(
timeout.Milliseconds(), 10),
allowedCountries: "DE",
})
+18 -16
View File
@@ -18,7 +18,8 @@ import (
const (
metricsToken = "SWW_WAF_METRICS_TOKEN" //nolint:gosec // the setting's name
metricsTopN = "SWW_WAF_METRICS_TOP_N"
metricsBusiestCountriesAndASNs = "SWW_WAF_METRICS_BUSIEST_COUNTRIES_AND_ASNS"
// token is the SWW_WAF_METRICS_TOKEN the tests set, and bearer how a
// request carries it.
token = "0123456789abcdef0123456789abcdef"
@@ -133,12 +134,12 @@ func TestMetricsCountLimitsAndBans(t *testing.T) {
const denied = "192.0.2.50" // in SWW_WAF_DENY_NETS
s, clk, _ := startWithClock(t, "", map[string]string{
metricsToken: token,
rateLimitPerMinute: "1",
denyNets: denied,
banResponse: banResponseClose,
limitBanDuration: "1h",
maxBanDuration: "2h",
metricsToken: token,
rateLimitPerMinute: "1",
denyNets: denied,
banResponse: banResponseClose,
limitBanDuration: "1h",
limitBanPermanentAbove: "2h",
})
// SWW_WAF_BAN_RESPONSE=close sends no status at all.
@@ -176,7 +177,7 @@ func TestMetricsCountLimitsAndBans(t *testing.T) {
wantMetric(t, s.scrape(), `sww_active_bans{instance="app"}`, 0)
// A limit broken again right after would ban for three hours, longer
// than SWW_WAF_MAX_BAN_DURATION, so the ban is permanent.
// than SWW_WAF_LIMIT_BAN_PERMANENT_ABOVE, so the ban is permanent.
s.get(client, http.StatusOK, requestlog.ActionForward)
s.get(client, 0, requestlog.ActionClientIsRateLimited)
@@ -236,10 +237,10 @@ func TestMetricsByCountryKeepTheBusiestAndCountTheRestAsOther(t *testing.T) {
_, _ = io.WriteString(w, "hello")
})
env := map[string]string{
trustedProxies: trustLocalhost,
metricsToken: token,
metricsTopN: "2",
allowedCountries: "de,fr",
trustedProxies: trustLocalhost,
metricsToken: token,
metricsBusiestCountriesAndASNs: "2",
allowedCountries: "de,fr",
}
geojsURL, _ := startGeoJS(t)
addr, out, server := startProxyWithClock(t, app.URL, geojsURL,
@@ -322,8 +323,8 @@ func TestMetricsByASNumberKeepTheBusiestAndCountTheRestAsOther(
geojsURL, _ := startGeoJS(t)
s, clk, server := startWithClock(t, geojsURL, map[string]string{
metricsToken: token,
metricsTopN: "1",
metricsToken: token,
metricsBusiestCountriesAndASNs: "1",
})
// The answers are kept before the requests, so that GeoJS gives none
@@ -379,8 +380,9 @@ func TestMetricsCountGeoJSRequestsAndFailures(t *testing.T) {
req.Header.Set(forwardedFor, fromDE)
wantStatus(t, do(t, req), http.StatusOK)
// The client stops waiting for GeoJS once SWW_WAF_LOOKUP_TIMEOUT is up, so
// GeoJS's failure can come after its request has ended.
// The client stops waiting for GeoJS once
// SWW_WAF_ASN_AND_COUNTRY_LOOKUP_TIMEOUT_MS is up, so GeoJS's failure can
// come after its request has ended.
deadline := time.Now().Add(waitLimit)
metrics := scrape(t, server.Addr)
+4 -4
View File
@@ -149,10 +149,10 @@ func TestObserveModeForwardsWhatEnforceModeRefuses(t *testing.T) {
geojsURL, _ := startGeoJS(t)
env := map[string]string{
lookupTimeout: "1h",
rateLimitPerMinute: "1",
denyNets: denied,
tagDeny: denyKP,
asnAndCountryLookupTimeout: "3600000",
rateLimitPerMinute: "1",
denyNets: denied,
tagDeny: denyKP,
}
if tc.setting != "" {
+30 -22
View File
@@ -47,7 +47,7 @@ const ClientsPath = "/_sww/clients/"
// Params are what New needs.
type Params struct {
Config *config.Config
Config *config.WAF
// RequestLog receives two JSON lines per question, one at its decision and
// one when its report comes, and one per request to waf's own
// endpoints.
@@ -77,7 +77,7 @@ type Params struct {
// 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
// SWW_WAF_CORE_RULE_SET_ANOMALY_THRESHOLD, and for GeoJS failing, a fetch of a list
// SWW_WAF_CORE_RULE_SET_SCORE_THRESHOLD, and for GeoJS failing, a fetch of a list
// failing, a query to a DNSBL zone or a check with AbuseIPDB failing,
// or the day's AbuseIPDB checks used up.
Alerts *alerts.Queue
@@ -106,7 +106,10 @@ type Server struct {
// newHandler returns the handler with what every request shares, as the
// settings in params make it.
func newHandler(params Params) *handler {
m := metrics.New(params.Config.MetricsTopN, params.Config.InstanceName)
m := metrics.New(
params.Config.MetricsBusiestCountriesAndASNs,
params.Config.InstanceName,
)
lists, dnsbl, abuseIPDB := newReputation(params, m)
awaitingReport, err := lru.New[string, *decision](maxAwaitingReport)
@@ -130,21 +133,21 @@ func newHandler(params Params) *handler {
BytesPerDay: params.Config.BytesLimitPerDay,
}, params.Config.MaxTrackedClients),
ledger: bans.New(bans.Params{
LimitBanDuration: params.Config.LimitBanDuration,
LimitBanRepeatWindow: params.Config.LimitBanRepeatWindow,
MaxBanDuration: params.Config.MaxBanDuration,
AttackBanDuration: params.Config.AttackBanDuration,
MaxBans: params.Config.MaxBans,
LimitBanDuration: params.Config.LimitBanDuration,
LimitBanRepeatWindow: params.Config.LimitBanRepeatWindow,
LimitBanPermanentAbove: params.Config.LimitBanPermanentAbove,
AttackBanDuration: params.Config.AttackBanDuration,
MaxBans: params.Config.MaxBans,
}),
anomalies: anomaly.New(anomaly.Params{
Client: params.Config.AnomalyClient,
Net: params.Config.AnomalyNet,
ASN: params.Config.AnomalyASN,
Total: params.Config.AnomalyTotal,
Watch: params.Config.AnomalyWatch,
WatchedNet: params.Config.AnomalyWatchedNet,
NetV4Prefix: params.Config.AnomalyNetV4Prefix,
NetV6Prefix: params.Config.AnomalyNetV6Prefix,
NamedNetblocks: params.Config.WatchNets,
NamedNetblocks: params.Config.AnomalyWatchedNets,
Alerts: params.Alerts,
}),
lookupFile: params.LookupFile,
@@ -163,7 +166,7 @@ func newHandler(params Params) *handler {
h.geojs = lookup.New(lookup.Params{
URL: params.GeoJSURL,
Timeout: params.Config.LookupTimeout,
Timeout: params.Config.ASNAndCountryLookupTimeout,
// The country list, the headers, the tag settings that name an asn=
// or country= tag and the biased thresholds act on the answer before
// the request goes on.
@@ -198,14 +201,15 @@ func newReputation(
Now: params.Now, ProcessLog: params.ProcessLog, Alerts: params.Alerts,
})
dnsbl := reputation.NewDNSBL(reputation.DNSBLParams{
Zones: cfg.DNSBLZones, Resolver: cfg.DNSBLResolver, CacheTTL: cfg.ReputationCacheTTL,
Timeout: cfg.ReputationTimeout, Now: params.Now, ProcessLog: params.ProcessLog,
Zones: cfg.DNSBLZones, Resolver: cfg.DNSBLResolver,
CacheTTL: cfg.IPReputationCacheTTL,
Timeout: cfg.DNSBLAbuseIPDBTimeout, Now: params.Now, ProcessLog: params.ProcessLog,
Alerts: params.Alerts,
})
abuseIPDB := reputation.NewAbuseIPDB(reputation.AbuseIPDBParams{
URL: params.AbuseIPDBURL, Key: cfg.AbuseIPDBKey, MinScore: cfg.AbuseIPDBMinScore,
DailyBudget: cfg.AbuseIPDBDailyBudget, CacheTTL: cfg.ReputationCacheTTL,
Timeout: cfg.ReputationTimeout, Now: params.Now, ProcessLog: params.ProcessLog,
DailyBudget: cfg.AbuseIPDBDailyBudget, CacheTTL: cfg.IPReputationCacheTTL,
Timeout: cfg.DNSBLAbuseIPDBTimeout, Now: params.Now, ProcessLog: params.ProcessLog,
Alerts: params.Alerts,
})
@@ -220,7 +224,7 @@ func newReputation(
// NewCoreRuleSet returns the Core Rule Set at SWW_WAF_CORE_RULE_SET_PARANOIA_LEVEL,
// without the rules SWW_WAF_CORE_RULE_SET_DISABLED_RULES switches off, reading bodies
// up to SWW_WAF_CORE_RULE_SET_BODY_LIMIT, with the exclusions in
// up to SWW_WAF_CORE_RULE_SET_BODY_INSPECTION_SIZE, with the exclusions in
// SWW_WAF_CORE_RULE_SET_EXCLUSIONS_FILE and the fixes of the apps in SWW_WAF_APPS,
// or nil while SWW_WAF_CORE_RULE_SET_MODE is off. It
// reads at most QuestionBodyMaxBytes less one byte: a question's body of
@@ -228,15 +232,19 @@ func newReputation(
// JSON or XML body over its limit. An exclusions file that coreruleset.New refuses is
// an error in every mode. While the mode is off, a set file is loaded with the
// Core Rule Set only to check it.
func NewCoreRuleSet(cfg *config.Config) (*coreruleset.CoreRuleSet, error) {
if cfg.WAFMode == config.WAFModeOff &&
func NewCoreRuleSet(cfg *config.WAF) (*coreruleset.CoreRuleSet, error) {
if cfg.CoreRuleSetMode == config.CoreRuleSetModeOff &&
cfg.CoreRuleSetExclusionsFile == "" {
return nil, nil //nolint:nilnil // off is no Core Rule Set, and no error
}
coreRuleSet, err := coreruleset.New(coreruleset.Params{
ParanoiaLevel: cfg.WAFParanoiaLevel, DisabledRules: cfg.WAFDisabledRules,
BodyLimit: min(cfg.WAFBodyLimit, QuestionBodyMaxBytes-1),
ParanoiaLevel: cfg.CoreRuleSetParanoiaLevel,
DisabledRules: cfg.CoreRuleSetDisabledRules,
BodyInspectionSize: min(
cfg.CoreRuleSetBodyInspectionSize,
QuestionBodyMaxBytes-1,
),
ExclusionsFile: cfg.CoreRuleSetExclusionsFile,
Apps: cfg.Apps,
})
@@ -247,7 +255,7 @@ func NewCoreRuleSet(cfg *config.Config) (*coreruleset.CoreRuleSet, error) {
)
}
if cfg.WAFMode == config.WAFModeOff {
if cfg.CoreRuleSetMode == config.CoreRuleSetModeOff {
return nil, nil //nolint:nilnil // off is no Core Rule Set, and no error
}
@@ -257,7 +265,7 @@ func NewCoreRuleSet(cfg *config.Config) (*coreruleset.CoreRuleSet, error) {
// handler is waf's handler. It holds what every request shares; what
// belongs to one request is in a request.
type handler struct {
config *config.Config
config *config.WAF
requestLog io.Writer
processLog *slog.Logger
now func() time.Time
+39 -37
View File
@@ -56,35 +56,35 @@ const (
// The settings the tests set.
const (
mode = "SWW_WAF_MODE"
trustedProxies = "SWW_RPROXY_TRUSTED_PROXIES"
ipv6GroupPrefix = "SWW_WAF_IPV6_GROUP_PREFIX"
maxTrackedClients = "SWW_WAF_MAX_TRACKED_CLIENTS"
allowNets = "SWW_WAF_ALLOW_NETS"
rateLimitExemptNets = "SWW_WAF_RATE_LIMIT_EXEMPT_NETS"
denyNets = "SWW_WAF_DENY_NETS"
rateLimitPerMinute = "SWW_WAF_RATE_LIMIT_PER_MINUTE"
rateLimitPerDay = "SWW_WAF_RATE_LIMIT_PER_DAY"
rateLimitExemptPaths = "SWW_WAF_RATE_LIMIT_EXEMPT_PATHS"
lookupSource = "SWW_WAF_LOOKUP_SOURCE"
lookupDBPath = "SWW_WAF_LOOKUP_DB_PATH"
lookupTimeout = "SWW_WAF_LOOKUP_TIMEOUT"
addLookupHeaders = "SWW_WAF_ADD_LOOKUP_HEADERS"
tagDeny = "SWW_WAF_TAG_DENY"
mode = "SWW_WAF_MODE"
trustedProxies = "SWW_RPROXY_TRUSTED_PROXIES"
ipv6GroupPrefix = "SWW_WAF_IPV6_GROUP_PREFIX"
maxTrackedClients = "SWW_WAF_MAX_TRACKED_CLIENTS"
allowNets = "SWW_WAF_ALLOW_NETS"
rateLimitExemptNets = "SWW_WAF_RATE_LIMIT_EXEMPT_NETS"
denyNets = "SWW_WAF_DENY_NETS"
rateLimitPerMinute = "SWW_WAF_RATE_LIMIT_PER_MINUTE"
rateLimitPerDay = "SWW_WAF_RATE_LIMIT_PER_DAY"
rateLimitExemptPaths = "SWW_WAF_RATE_LIMIT_EXEMPT_PATHS"
lookupSource = "SWW_WAF_LOOKUP_SOURCE"
lookupDBPath = "SWW_WAF_LOOKUP_DB_PATH"
asnAndCountryLookupTimeout = "SWW_WAF_ASN_AND_COUNTRY_LOOKUP_TIMEOUT_MS"
addLookupHeaders = "SWW_WAF_ADD_LOOKUP_HEADERS"
tagDeny = "SWW_WAF_TAG_DENY"
// denyKP is the value of SWW_WAF_TAG_DENY that refuses fromKP's country.
denyKP = "country=KP"
allowedCountries = "SWW_WAF_EXCLUSIVELY_ALLOWED_COUNTRIES"
banResponse = "SWW_WAF_BAN_RESPONSE"
limitBanDuration = "SWW_WAF_LIMIT_BAN_DURATION"
limitBanRepeatWindow = "SWW_WAF_LIMIT_BAN_REPEAT_WINDOW"
maxBanDuration = "SWW_WAF_MAX_BAN_DURATION"
maxBans = "SWW_WAF_MAX_BANS"
banScopeV4Prefix = "SWW_WAF_BAN_SCOPE_V4_PREFIX"
instanceName = "SWW_WAF_INSTANCE_NAME"
logRequestHeaders = "SWW_WAF_LOG_REQUEST_HEADERS"
attackBanDuration = "SWW_WAF_ATTACK_BAN_DURATION"
rulesDir = "SWW_WAF_RULES_DIR"
wafMode = "SWW_WAF_CORE_RULE_SET_MODE"
denyKP = "country=KP"
allowedCountries = "SWW_WAF_EXCLUSIVELY_ALLOWED_COUNTRIES"
banResponse = "SWW_WAF_BAN_RESPONSE"
limitBanDuration = "SWW_WAF_LIMIT_BAN_DURATION"
limitBanRepeatWindow = "SWW_WAF_LIMIT_BAN_REPEAT_WINDOW"
limitBanPermanentAbove = "SWW_WAF_LIMIT_BAN_PERMANENT_ABOVE"
maxBans = "SWW_WAF_MAX_BANS"
banScopeV4Prefix = "SWW_WAF_BAN_SCOPE_V4_PREFIX"
instanceName = "SWW_WAF_INSTANCE_NAME"
logRequestHeaders = "SWW_WAF_LOG_REQUEST_HEADERS"
attackBanDuration = "SWW_WAF_ATTACK_BAN_DURATION"
ruleFilesDir = "SWW_WAF_RULE_FILES_DIR"
coreRuleSetMode = "SWW_WAF_CORE_RULE_SET_MODE"
)
// off is the value that switches a setting off.
@@ -301,7 +301,7 @@ func startProxyWithGeoJS(
// startProxyWithClock is startProxyWithGeoJS with requests counted and
// bans made by the time now tells, and returns waf's server as well, whose
// Addr is where waf listens. Unless env sets SWW_WAF_RULES_DIR, it is an empty
// Addr is where waf listens. Unless env sets SWW_WAF_RULE_FILES_DIR, it is an empty
// directory, of no rules, and unless it sets SWW_WAF_INSTANCE_NAME, that is app,
// the label instance of every metric.
func startProxyWithClock(
@@ -362,17 +362,19 @@ func serve(t *testing.T, server *waf.Server) {
// startRProxy starts rproxy in front of the app at appURL, asking the waf
// at wafAddr, with the settings in env on top of the defaults, and returns
// where it listens. What it writes is dropped. SWW_RPROXY_TIMEOUT_MS is a minute unless
// env sets it, so that on a busy host SWW_RPROXY_DEFAULT_DISPOSITION does not decide.
// where it listens. What it writes is dropped.
// SWW_RPROXY_WAF_BACKEND_TIMEOUT_MS is a minute unless env sets it, so that
// on a busy host SWW_RPROXY_WAF_BACKEND_NO_ANSWER_DISPOSITION does not
// decide.
func startRProxy(
t *testing.T, wafAddr, appURL string, env map[string]string,
) string {
t.Helper()
settings := map[string]string{
"SWW_RPROXY_WAF_BACKEND": "http://" + wafAddr,
"SWW_RPROXY_UPSTREAM_URL": appURL,
"SWW_RPROXY_TIMEOUT_MS": "60000",
"SWW_RPROXY_WAF_BACKEND": "http://" + wafAddr,
"SWW_RPROXY_UPSTREAM_URL": appURL,
"SWW_RPROXY_WAF_BACKEND_TIMEOUT_MS": "60000",
}
maps.Copy(settings, env)
@@ -440,7 +442,7 @@ func newWAFWith(
t.Helper()
settings := map[string]string{
rulesDir: t.TempDir(), instanceName: "app", wafMode: off,
ruleFilesDir: t.TempDir(), instanceName: "app", coreRuleSetMode: off,
}
if geojsURL == "" {
settings[lookupSource] = off
@@ -448,7 +450,7 @@ func newWAFWith(
maps.Copy(settings, env)
cfg, err := config.FromEnvironment(configtest.Viper(settings))
cfg, err := config.WAFFromEnvironment(configtest.Viper(settings))
if err != nil {
t.Fatalf("settings %v: %v", settings, err)
}
@@ -461,7 +463,7 @@ func newWAFWith(
)
ruleFiles, err := rules.Load(rules.Params{
Dir: cfg.RulesDir, Enabled: cfg.RulesEnabled, ProcessLog: processLog,
Dir: cfg.RuleFilesDir, Enabled: cfg.RuleFilesEnabled, ProcessLog: processLog,
})
if err != nil {
t.Fatalf("rule files: %v", err)
+5 -5
View File
@@ -1,6 +1,6 @@
package waf
// The heuristic refusal_limit. It reads SWW_WAF_ERROR_BURST_THRESHOLD and
// The heuristic refusal_limit. It reads SWW_WAF_REFUSALS_LIMIT_PER_MINUTE and
// SWW_WAF_BAN_RESPONSE.
import (
@@ -14,9 +14,9 @@ import (
// refusalLimit counts the request once it has been answered, if waf
// 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
// SWW_WAF_ERROR_BURST_THRESHOLD such refusals of the client within a minute break
// SWW_WAF_REFUSALS_LIMIT_PER_MINUTE such refusals of the client within a minute break
// a limit, which bans the client. A client in SWW_WAF_ALLOW_NETS, which the
// heuristics skip, is not counted, and nothing is while the threshold is off.
// heuristics skip, is not counted, and nothing is while the limit is off.
// Its figures are the window, the count and the limit.
type refusalLimit struct{}
@@ -28,7 +28,7 @@ func (refusalLimit) check(
now time.Time,
) *verdict {
cfg := rq.h.config
if cfg.ErrorBurstThreshold == 0 {
if cfg.RefusalsLimitPerMinute == 0 {
return nil
}
@@ -37,7 +37,7 @@ func (refusalLimit) check(
}
hit, over := rq.h.limiter.CountRefusal(rq.h.clientGroup(rq.client), now,
cfg.ErrorBurstThreshold)
cfg.RefusalsLimitPerMinute)
if !over {
return nil
}
+42 -42
View File
@@ -15,7 +15,7 @@ import (
"sneak.berlin/go/smallwebwaf/internal/waf"
)
const errorBurstThreshold = "SWW_WAF_ERROR_BURST_THRESHOLD"
const refusalsLimitPerMinute = "SWW_WAF_REFUSALS_LIMIT_PER_MINUTE"
// refused is a request the tests here send, which waf refuses after a rule
// file match.
@@ -48,29 +48,29 @@ func (r refused) send(s *sender, from string) logLine {
return logLine{}
}
// startForErrorBurst is startWithClock with testRules, the metrics token and
// SWW_WAF_ERROR_BURST_THRESHOLD at threshold, and the settings in env.
func startForErrorBurst(
t *testing.T, threshold string, env map[string]string,
// startForRefusalLimit is startWithClock with testRules, the metrics token and
// SWW_WAF_REFUSALS_LIMIT_PER_MINUTE at limit, and the settings in env.
func startForRefusalLimit(
t *testing.T, limit string, env map[string]string,
) (*sender, *clock, *waf.Server) {
t.Helper()
settings := map[string]string{
errorBurstThreshold: threshold,
rulesDir: writeRules(t, testRules),
metricsToken: token,
refusalsLimitPerMinute: limit,
ruleFilesDir: writeRules(t, testRules),
metricsToken: token,
}
maps.Copy(settings, env)
return startWithClock(t, "", settings)
}
func TestErrorBurstBreaksAtOneOverTheThreshold(t *testing.T) {
func TestRefusalLimitBreaksAtOneOverTheLimit(t *testing.T) {
t.Parallel()
for _, tc := range []struct {
name string
// refusals are four, one over the threshold of three.
// refusals are four, one over the limit of three.
refusals []refused
// nextAction is the action of the client's next request: the next
// request after a ban for a ban rule makes it permanent.
@@ -90,7 +90,7 @@ func TestErrorBurstBreaksAtOneOverTheThreshold(t *testing.T) {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
s, _, _ := startForErrorBurst(t, "3", nil)
s, _, _ := startForRefusalLimit(t, "3", nil)
// Three refusals break nothing, and the app's answers between
// them are not counted.
@@ -109,8 +109,8 @@ func TestErrorBurstBreaksAtOneOverTheThreshold(t *testing.T) {
s.get(client, http.StatusOK, requestlog.ActionForward)
}
// The fourth is answered as the others were, breaks the error
// burst, and bans the client.
// The fourth is answered as the others were, breaks the refusal
// limit, and bans the client.
line := tc.refusals[3].send(s, client)
if line.LimitHit != requestlog.LimitHitMinuteRefusals ||
line.Offence != requestlog.OffenceLimit {
@@ -130,7 +130,7 @@ func TestErrorBurstBreaksAtOneOverTheThreshold(t *testing.T) {
func TestRefusalLimitBanNamesItsHeuristicWindowCountAndLimit(t *testing.T) {
t.Parallel()
s, _, _ := startForErrorBurst(t, "1", nil)
s, _, _ := startForRefusalLimit(t, "1", nil)
const ruleFigures = `{"rule_id": "blocked", "rule_action": "block"}`
@@ -146,10 +146,10 @@ func TestRefusalLimitBanNamesItsHeuristicWindowCountAndLimit(t *testing.T) {
`{"window": "minute", "count": 2, "limit": 1}`)
}
func TestErrorBurstBanNotesHistoryAndMetrics(t *testing.T) {
func TestRefusalLimitBanNotesHistoryAndMetrics(t *testing.T) {
t.Parallel()
s, clk, server := startForErrorBurst(t, "2", nil)
s, clk, server := startForRefusalLimit(t, "2", nil)
start := clk.Now()
blockRule.send(s, client)
@@ -221,18 +221,18 @@ func TestErrorBurstBanNotesHistoryAndMetrics(t *testing.T) {
)
}
func TestErrorBurstIsNotLoweredForAClientWithLowerLimits(t *testing.T) {
func TestRefusalLimitIsNotLoweredForAClientWithLowerLimits(t *testing.T) {
t.Parallel()
geojsURL, _ := startGeoJS(t)
s, _, server := startWithClock(t, geojsURL, map[string]string{
lookupTimeout: "1h",
errorBurstThreshold: "2",
rulesDir: writeRules(t, testRules),
tagLimitPercent: countryTagDEHalf,
asnAndCountryLookupTimeout: "3600000",
refusalsLimitPerMinute: "2",
ruleFilesDir: writeRules(t, testRules),
tagLimitPercent: countryTagDEHalf,
})
// Half of the threshold would be one, which the second refusal is over.
// Half of the limit would be one, which the second refusal is over.
for range 2 {
line := blockRule.send(s, fromDE)
if line.LimitHit != "" {
@@ -252,7 +252,7 @@ func TestErrorBurstIsNotLoweredForAClientWithLowerLimits(t *testing.T) {
}
}
func TestErrorBurstDoesNotCountTheAppsAnswers(t *testing.T) {
func TestRefusalLimitDoesNotCountTheAppsAnswers(t *testing.T) {
t.Parallel()
statuses := map[string]int{
@@ -266,8 +266,8 @@ func TestErrorBurstDoesNotCountTheAppsAnswers(t *testing.T) {
w.WriteHeader(statuses[r.URL.Path])
},
map[string]string{
errorBurstThreshold: "1",
rulesDir: writeRules(t, testRules),
refusalsLimitPerMinute: "1",
ruleFilesDir: writeRules(t, testRules),
},
)
@@ -277,7 +277,7 @@ func TestErrorBurstDoesNotCountTheAppsAnswers(t *testing.T) {
}
}
// The first refusal is one, not over the threshold.
// The first refusal is one, not over the limit.
line := blockRule.send(s, client)
if line.LimitHit != "" {
t.Errorf("log line has limit_hit %q, want none", line.LimitHit)
@@ -293,23 +293,23 @@ func TestErrorBurstDoesNotCountTheAppsAnswers(t *testing.T) {
wantAlerts(t, queue)
}
func TestErrorBurstOffOrAtItsDefault(t *testing.T) {
func TestRefusalLimitOffOrAtItsDefault(t *testing.T) {
t.Parallel()
for _, tc := range []struct {
threshold string
// broken is whether the 31st refusal breaks the error burst.
limit string
// broken is whether the 31st refusal breaks the refusal limit.
broken bool
}{
{"", true},
{off, false},
} {
t.Run(errorBurstThreshold+"="+tc.threshold, func(t *testing.T) {
t.Run(refusalsLimitPerMinute+"="+tc.limit, func(t *testing.T) {
t.Parallel()
env := map[string]string{rulesDir: writeRules(t, testRules)}
if tc.threshold != "" {
env[errorBurstThreshold] = tc.threshold
env := map[string]string{ruleFilesDir: writeRules(t, testRules)}
if tc.limit != "" {
env[refusalsLimitPerMinute] = tc.limit
}
s, _, _ := startWithClock(t, "", env)
@@ -322,7 +322,7 @@ func TestErrorBurstOffOrAtItsDefault(t *testing.T) {
broken := line.LimitHit == requestlog.LimitHitMinuteRefusals
if broken != tc.broken {
t.Errorf(
"the 31st refusal broke the error burst: %t, want %t",
"the 31st refusal broke the refusal limit: %t, want %t",
broken,
tc.broken,
)
@@ -331,7 +331,7 @@ func TestErrorBurstOffOrAtItsDefault(t *testing.T) {
}
}
func TestErrorBurstCountsEachClientTheChecksApplyTo(t *testing.T) {
func TestRefusalLimitCountsEachClientTheChecksApplyTo(t *testing.T) {
t.Parallel()
const (
@@ -339,7 +339,7 @@ func TestErrorBurstCountsEachClientTheChecksApplyTo(t *testing.T) {
exempt = "192.0.2.50" // in SWW_WAF_RATE_LIMIT_EXEMPT_NETS
)
s, _, _ := startForErrorBurst(t, "1", map[string]string{
s, _, _ := startForRefusalLimit(t, "1", map[string]string{
allowNets: allowed, rateLimitExemptNets: exempt,
})
@@ -367,10 +367,10 @@ func TestErrorBurstCountsEachClientTheChecksApplyTo(t *testing.T) {
s.get(exempt, http.StatusForbidden, requestlog.ActionTemporarilyBanned)
}
func TestErrorBurstBanSetsTheRefusalsBackToZero(t *testing.T) {
func TestRefusalLimitBanSetsTheRefusalsBackToZero(t *testing.T) {
t.Parallel()
s, clk, _ := startForErrorBurst(
s, clk, _ := startForRefusalLimit(
t,
"1",
map[string]string{limitBanDuration: "1s"},
@@ -389,13 +389,13 @@ func TestErrorBurstBanSetsTheRefusalsBackToZero(t *testing.T) {
}
}
func TestObserveModeLogsAndAlertsTheErrorBurst(t *testing.T) {
func TestObserveModeLogsAndAlertsABrokenRefusalLimit(t *testing.T) {
t.Parallel()
s, clk, server, queue := startWithAlerts(t, map[string]string{
mode: observe,
errorBurstThreshold: "1",
rulesDir: writeRules(t, testRules),
mode: observe,
refusalsLimitPerMinute: "1",
ruleFilesDir: writeRules(t, testRules),
})
start := clk.Now()
held := bans.Ban{
+1 -1
View File
@@ -8,7 +8,7 @@ import (
"sneak.berlin/go/smallwebwaf/internal/bans"
)
// deny is the SWW_WAF_BLOCKLIST_ACTION and the SWW_WAF_REPUTATION_ACTION that
// deny is the SWW_WAF_BLOCKLIST_ACTION and the SWW_WAF_DNSBL_ABUSEIPDB_ACTION that
// refuses the requests of a client a source lists.
const deny = "deny"
+37 -34
View File
@@ -28,7 +28,7 @@ const (
asnLimitPercentURL = "SWW_WAF_ASN_LIMIT_PERCENT_URL"
)
// The actions of SWW_WAF_BLOCKLIST_ACTION and SWW_WAF_REPUTATION_ACTION:
// The actions of SWW_WAF_BLOCKLIST_ACTION and SWW_WAF_DNSBL_ABUSEIPDB_ACTION:
// limitHalf gives a listed client half of every limit, and limitQuarter a
// quarter.
const (
@@ -155,7 +155,7 @@ func TestReputationRefusalNamesItsHeuristicAndWhatListedTheClient(
"dnsbl",
map[string]string{
dnsblZones: dnsblZone, dnsblResolver: noResolver,
reputationAction: actionDeny,
dnsblAbuseIPDBAction: actionDeny,
},
func(_ *testing.T, server *waf.Server) {
loadVerdicts(server, map[string][]string{client: {dnsblZone}})
@@ -164,7 +164,7 @@ func TestReputationRefusalNamesItsHeuristicAndWhatListedTheClient(
},
{
"abuseipdb",
map[string]string{abuseIPDBKey: accountKey, reputationAction: actionDeny},
map[string]string{abuseIPDBKey: accountKey, dnsblAbuseIPDBAction: actionDeny},
func(_ *testing.T, server *waf.Server) {
loadScores(server, map[string]int64{client: 80})
},
@@ -308,8 +308,8 @@ func TestASNLimitPercentFileCountsAsTheSettingDoesTheLowerWinning(
},
{"none, for an AS number the file does not list", nil, asnKP + ":25", none, none},
{
"SWW_WAF_ASN_BYTES_PERCENT's in place of the file's for the byte limits",
map[string]string{asnBytesPercent: asnDE + ":100"}, asnDEQuarter, fromURL, none,
"SWW_WAF_ASN_BYTES_LIMIT_PERCENT's in place of the file's for the byte limits",
map[string]string{asnBytesLimitPercent: asnDE + ":100"}, asnDEQuarter, fromURL, none,
},
} {
t.Run(tc.name, func(t *testing.T) {
@@ -417,9 +417,9 @@ func TestEachBlocklistThatListsAClientRaisesAnAlertOncePerCooldownAndIsCounted(
// The DNSBL settings.
const (
dnsblZones = "SWW_WAF_DNSBL_ZONES"
dnsblResolver = "SWW_WAF_DNSBL_RESOLVER"
reputationAction = "SWW_WAF_REPUTATION_ACTION"
dnsblZones = "SWW_WAF_DNSBL_ZONES"
dnsblResolver = "SWW_WAF_DNSBL_RESOLVER"
dnsblAbuseIPDBAction = "SWW_WAF_DNSBL_ABUSEIPDB_ACTION"
)
// The DNSBL zones these tests name, which are never asked about the
@@ -432,7 +432,7 @@ const (
noResolver = "127.0.0.1:9"
)
func TestEachReputationActionForAClientADNSBLZoneLists(t *testing.T) {
func TestEachDNSBLAbuseIPDBActionForAClientADNSBLZoneLists(t *testing.T) {
t.Parallel()
forward, denied := requestlog.ActionForward, requestlog.ActionClientDenied
@@ -453,7 +453,7 @@ func TestEachReputationActionForAClientADNSBLZoneLists(t *testing.T) {
// Half of 4 requests a minute: the third breaks the limit.
limitHalf, []int{http.StatusOK, http.StatusOK, http.StatusForbidden},
[]string{forward, forward, requestlog.ActionClientIsRateLimited},
"50 from " + reputationAction,
"50 from " + dnsblAbuseIPDBAction,
},
{
actionLog, []int{http.StatusOK, http.StatusOK, http.StatusOK},
@@ -465,7 +465,7 @@ func TestEachReputationActionForAClientADNSBLZoneLists(t *testing.T) {
s, server, _ := startWithLookups(t, map[string]string{
rateLimitPerMinute: fourAMinute, dnsblZones: dnsblZone + "," + otherZone,
dnsblResolver: noResolver, reputationAction: tc.action,
dnsblResolver: noResolver, dnsblAbuseIPDBAction: tc.action,
})
listedBy := map[string][]string{
fromDE: {dnsblZone, otherZone}, fromKP: {otherZone},
@@ -523,7 +523,7 @@ func TestDNSBLZonesComeAfterTheBlocklistsAndSkipAllowNets(t *testing.T) {
s, server, queue := startWithLookups(t, map[string]string{
blocklistURLs: dropURL, dnsblZones: dnsblZone, dnsblResolver: noResolver,
reputationAction: actionDeny, allowNets: fromDE,
dnsblAbuseIPDBAction: actionDeny, allowNets: fromDE,
})
loadLists(t, server, map[string][]string{dropURL: {fromKP}})
loadVerdicts(
@@ -566,7 +566,7 @@ func TestReputationSourcesTagTheRequestWhateverTheirAction(t *testing.T) {
{
"dnsbl",
map[string]string{
dnsblZones: dnsblZone, dnsblResolver: noResolver, reputationAction: actionLog,
dnsblZones: dnsblZone, dnsblResolver: noResolver, dnsblAbuseIPDBAction: actionLog,
},
func(server *waf.Server) {
loadVerdicts(server, map[string][]string{fromDE: {dnsblZone}})
@@ -575,7 +575,7 @@ func TestReputationSourcesTagTheRequestWhateverTheirAction(t *testing.T) {
},
{
abuseipdb,
map[string]string{abuseIPDBKey: accountKey, reputationAction: actionLog},
map[string]string{abuseIPDBKey: accountKey, dnsblAbuseIPDBAction: actionLog},
func(server *waf.Server) {
loadScores(server, map[string]int64{fromDE: 80})
},
@@ -602,7 +602,7 @@ func TestObserveModeForwardsAClientADNSBLZoneDeniesAndAlertsIt(
t.Parallel()
s, server, queue := startWithLookups(t, map[string]string{
dnsblZones: dnsblZone, dnsblResolver: noResolver, reputationAction: actionDeny,
dnsblZones: dnsblZone, dnsblResolver: noResolver, dnsblAbuseIPDBAction: actionDeny,
mode: observe,
})
loadVerdicts(server, map[string][]string{fromDE: {dnsblZone}})
@@ -617,33 +617,33 @@ func TestObserveModeForwardsAClientADNSBLZoneDeniesAndAlertsIt(
}
}
func TestReputationLimitTakesPartInTheLowestPercentageOfEveryLimit(
func TestDNSBLAbuseIPDBLimitTakesPartInTheLowestPercentageOfEveryLimit(
t *testing.T,
) {
t.Parallel()
for _, tc := range []struct {
blocklistAction, reputationAction string
blocklistAction, dnsblAbuseIPDBAction string
// want is the request's rate_limit_percent and bytes_limit_percent, as
// percentText gives them.
want string
}{
{limitHalf, limitQuarter, "25 from " + reputationAction},
{limitHalf, limitQuarter, "25 from " + dnsblAbuseIPDBAction},
{limitQuarter, limitHalf, "25 from " + blocklistAction},
// The blocklist's, the first of two alike.
{limitQuarter, limitQuarter, "25 from " + blocklistAction},
{actionLog, limitQuarter, "25 from " + reputationAction},
{actionLog, limitQuarter, "25 from " + dnsblAbuseIPDBAction},
{actionLog, actionLog, none},
} {
t.Run(
tc.blocklistAction+" "+tc.reputationAction,
tc.blocklistAction+" "+tc.dnsblAbuseIPDBAction,
func(t *testing.T) {
t.Parallel()
s, server, _ := startWithLookups(t, map[string]string{
blocklistURLs: dropURL, blocklistAction: tc.blocklistAction,
dnsblZones: dnsblZone, dnsblResolver: noResolver,
reputationAction: tc.reputationAction,
dnsblAbuseIPDBAction: tc.dnsblAbuseIPDBAction,
})
loadLists(t, server, map[string][]string{dropURL: {fromDE}})
loadVerdicts(
@@ -676,7 +676,7 @@ func TestEachZoneThatListsAClientRaisesAnAlertOncePerCooldownAndIsCounted(
s, clk, server, queue := startWithLookupsAndClock(t, map[string]string{
dnsblZones: dnsblZone + "," + otherZone, dnsblResolver: noResolver,
reputationAction: actionLog, metricsToken: token,
dnsblAbuseIPDBAction: actionLog, metricsToken: token,
})
loadVerdicts(server, map[string][]string{
fromDE: {dnsblZone, otherZone}, unplaced: nil,
@@ -740,7 +740,7 @@ func TestZoneKeyIsMaskedInTheLogTheAlertAndTheMetrics(t *testing.T) {
)
s, server, queue := startWithLookups(t, map[string]string{
dnsblZones: keyed, dnsblResolver: noResolver, reputationAction: actionLog,
dnsblZones: keyed, dnsblResolver: noResolver, dnsblAbuseIPDBAction: actionLog,
metricsToken: token,
})
loadVerdicts(server, map[string][]string{fromDE: {keyed}, unplaced: nil})
@@ -849,7 +849,7 @@ func TestOnlyAClientThatHasCommittedAnOffenceIsCheckedWithAbuseIPDB(
forward := requestlog.ActionForward
s, clk, server, _ := startWithLookupsAndClock(t, map[string]string{
abuseIPDBKey: accountKey, rateLimitPerMinute: "2", reputationAction: actionLog,
abuseIPDBKey: accountKey, rateLimitPerMinute: "2", dnsblAbuseIPDBAction: actionLog,
})
// Neither fromDE, until it breaks a rate limit, nor fromKP, which never
@@ -892,7 +892,7 @@ func TestIPv6ClientCostsOneAbuseIPDBCheckWhicheverOfItsAddressesSends(
}
s, clk, server := startWithClock(t, "", map[string]string{
abuseIPDBKey: accountKey, reputationAction: actionLog,
abuseIPDBKey: accountKey, dnsblAbuseIPDBAction: actionLog,
rateLimitPerMinute: strconv.Itoa(len(addresses)),
})
@@ -961,9 +961,12 @@ func TestClientRefusedForAnOffenceIsCheckedWithAbuseIPDBAtItsNextRequest(
t.Parallel()
s, clk, server := startWithClock(t, "", map[string]string{
abuseIPDBKey: accountKey, reputationAction: actionLog,
rulesDir: writeRules(t, testRules), attackBanDuration: "1h",
trapPaths: trapPathList, wafMode: block,
abuseIPDBKey: accountKey, dnsblAbuseIPDBAction: actionLog,
ruleFilesDir: writeRules(
t,
testRules,
), attackBanDuration: "1h",
trapPaths: trapPathList, coreRuleSetMode: block,
})
s.request(client, tc.path, tc.status, tc.action)
@@ -986,7 +989,7 @@ func TestClientRefusedForAnOffenceIsCheckedWithAbuseIPDBAtItsNextRequest(
}
}
func TestEachReputationActionForAClientAbuseIPDBScoresAtOrOverTheMinimum(
func TestEachDNSBLAbuseIPDBActionForAClientAbuseIPDBScoresAtOrOverTheMinimum(
t *testing.T,
) {
t.Parallel()
@@ -1009,7 +1012,7 @@ func TestEachReputationActionForAClientAbuseIPDBScoresAtOrOverTheMinimum(
// Half of 4 requests a minute: the third breaks the limit.
limitHalf, []int{http.StatusOK, http.StatusOK, http.StatusForbidden},
[]string{forward, forward, requestlog.ActionClientIsRateLimited},
"50 from " + reputationAction,
"50 from " + dnsblAbuseIPDBAction,
},
{
actionLog, []int{http.StatusOK, http.StatusOK, http.StatusOK},
@@ -1021,7 +1024,7 @@ func TestEachReputationActionForAClientAbuseIPDBScoresAtOrOverTheMinimum(
s, server, _ := startWithLookups(t, map[string]string{
rateLimitPerMinute: fourAMinute, abuseIPDBKey: accountKey,
reputationAction: tc.action,
dnsblAbuseIPDBAction: tc.action,
})
// At SWW_WAF_ABUSEIPDB_MIN_SCORE, 75 by default, and just under it.
loadScores(server, map[string]int64{fromDE: 75, fromKP: 74})
@@ -1068,7 +1071,7 @@ func TestAbuseIPDBHitRaisesAnAlertWithTheScoreOncePerCooldownAndIsCounted(
t.Parallel()
s, server, queue := startWithLookups(t, map[string]string{
abuseIPDBKey: accountKey, reputationAction: actionLog, metricsToken: token,
abuseIPDBKey: accountKey, dnsblAbuseIPDBAction: actionLog, metricsToken: token,
})
loadScores(server, map[string]int64{fromDE: 90})
@@ -1159,10 +1162,10 @@ func TestBanNotesNameEachReputationSourceThatListedTheClient(t *testing.T) {
t.Parallel()
s, _, server, queue := startWithAlerts(t, map[string]string{
rateLimitPerMinute: "1", rulesDir: writeRules(t, testRules),
rateLimitPerMinute: "1", ruleFilesDir: writeRules(t, testRules),
blocklistURLs: dropURL, blocklistAction: actionLog,
dnsblZones: dnsblZone, dnsblResolver: noResolver,
abuseIPDBKey: accountKey, reputationAction: actionLog,
abuseIPDBKey: accountKey, dnsblAbuseIPDBAction: actionLog,
})
// Every source lists client, and none otherClient, whose score is
// under SWW_WAF_ABUSEIPDB_MIN_SCORE, 75 by default.
+3 -3
View File
@@ -44,7 +44,7 @@ type decision struct {
lookedUp bool
lookupAnswer lookup.Answer
// skippedLookupWait is true for a request that did not wait for GeoJS and
// found no answer kept. SWW_WAF_UNKNOWN_LIMIT_PERCENT does not apply to it,
// found no answer kept. SWW_WAF_UNKNOWN_COUNTRY_LIMIT_PERCENT does not apply to it,
// so that not waiting never lowers its client's limits.
skippedLookupWait bool
// counted is true for a request the rate limits counted, whose bytes
@@ -367,13 +367,13 @@ func (rq *request) countAnomalies() {
}
// anomalyThresholdsSet reports whether any anomaly threshold is set.
func anomalyThresholdsSet(cfg *config.Config) bool {
func anomalyThresholdsSet(cfg *config.WAF) bool {
off := anomaly.Thresholds{}
return cfg.AnomalyClient != off || cfg.AnomalyNet != off ||
cfg.AnomalyASN != off ||
cfg.AnomalyTotal != off ||
cfg.AnomalyWatch != off
cfg.AnomalyWatchedNet != off
}
// answerAtTheEnd returns, for a client that was looked up, the lookup's
+1 -1
View File
@@ -1,7 +1,7 @@
package waf
// The heuristic rule_files. It reads the rules of the rule files, which
// SWW_WAF_RULES_DIR and SWW_WAF_RULES_ENABLED name.
// SWW_WAF_RULE_FILES_DIR and SWW_WAF_RULE_FILES_ENABLED name.
import (
"context"
+9 -9
View File
@@ -25,8 +25,8 @@ func TestEachRuleAction(t *testing.T) {
t.Parallel()
s, clk, server := startWithClock(t, "", map[string]string{
rulesDir: writeRules(t, "noted path log ^/\n"+testRules),
banResponse: "429",
ruleFilesDir: writeRules(t, "noted path log ^/\n"+testRules),
banResponse: "429",
})
start := clk.Now()
@@ -112,7 +112,7 @@ func TestEachRuleActionAddsTheExploitSignatureTag(t *testing.T) {
t.Parallel()
s, _, _ := startWithClock(t, "", map[string]string{
rulesDir: writeRules(t, "noted path log ^/noted$\n"+testRules),
ruleFilesDir: writeRules(t, "noted path log ^/noted$\n"+testRules),
})
wantTags(
@@ -129,7 +129,7 @@ func TestRuleRefusalNamesItsHeuristicAndTheRule(t *testing.T) {
t.Parallel()
s, _, _ := startWithClock(t, "", map[string]string{
rulesDir: writeRules(t, "noted path log ^/\n"+testRules),
ruleFilesDir: writeRules(t, "noted path log ^/\n"+testRules),
})
line := s.request(client, "/blocked", http.StatusForbidden,
@@ -155,7 +155,7 @@ func TestNextClearSignOfAttackAfterABanBansPermanently(t *testing.T) {
t.Parallel()
s, clk, _ := startWithClock(t, "", map[string]string{
rulesDir: writeRules(t, testRules),
ruleFilesDir: writeRules(t, testRules),
attackBanDuration: "1h",
})
@@ -193,7 +193,7 @@ func TestRulesComeAfterTheOtherChecks(t *testing.T) {
)
s, _, server := startWithClock(t, "", map[string]string{
rulesDir: writeRules(t, testRules),
ruleFilesDir: writeRules(t, testRules),
allowNets: allowed,
rateLimitExemptNets: exempt,
rateLimitPerMinute: "1",
@@ -234,8 +234,8 @@ func TestObserveModeLogsWhatTheRulesWouldDo(t *testing.T) {
t.Parallel()
s, _, server := startWithClock(t, "", map[string]string{
rulesDir: writeRules(t, testRules),
mode: observe,
ruleFilesDir: writeRules(t, testRules),
mode: observe,
})
line := s.request(
@@ -273,7 +273,7 @@ func TestMetricsCountRuleMatchesAndBansForAnAttack(t *testing.T) {
t.Parallel()
s, _, _ := startWithClock(t, "", map[string]string{
rulesDir: writeRules(t, testRules),
ruleFilesDir: writeRules(t, testRules),
metricsToken: token,
})
+5 -5
View File
@@ -156,11 +156,11 @@ func TestRateLimitExemptNetsAreNeitherCountedNorRefused(t *testing.T) {
app := startApp(t, func(http.ResponseWriter, *http.Request) {})
geojsURL, _ := startGeoJS(t)
addr, out := startProxyWithGeoJS(t, app.URL, geojsURL, map[string]string{
trustedProxies: trustLocalhost,
lookupTimeout: "1h",
rateLimitExemptNets: listedAddr + "," + fromKP,
tagDeny: denyKP,
rateLimitPerMinute: "1",
trustedProxies: trustLocalhost,
asnAndCountryLookupTimeout: "3600000",
rateLimitExemptNets: listedAddr + "," + fromKP,
tagDeny: denyKP,
rateLimitPerMinute: "1",
})
// listedAddr's requests are neither refused nor counted, so the first
+2 -2
View File
@@ -70,7 +70,7 @@ func TestTagDenyRefusesAMixedCaseZoneByItsLowerCaseTag(t *testing.T) {
// The zone's tag is in lower case, however SWW_WAF_DNSBL_ZONES writes it.
s, server, _ := startWithLookups(t, map[string]string{
dnsblZones: mixedCaseZone, dnsblResolver: noResolver,
reputationAction: actionLog, tagDeny: "dnsbl=" + dnsblZone,
dnsblAbuseIPDBAction: actionLog, tagDeny: "dnsbl=" + dnsblZone,
})
loadVerdicts(server, map[string][]string{fromDE: {mixedCaseZone}})
@@ -86,7 +86,7 @@ func TestTagDenyRefusesAnExploitSignatureOfARuleThatOnlyLogs(t *testing.T) {
t.Parallel()
s, _, _ := startWithClock(t, "", map[string]string{
rulesDir: writeRules(
ruleFilesDir: writeRules(
t,
"noted path log ^/\n",
), tagDeny: "exploitsignature",
+6 -6
View File
@@ -25,10 +25,10 @@ func TestTrapPathBansAsABanRuleDoes(t *testing.T) {
// A block rule for the same path: the trap path comes first.
s, clk, server := startWithClock(t, "", map[string]string{
trapPaths: trapPathList,
rulesDir: writeRules(t, `wp path block ^/wp-login\.php$`),
allowNets: allowed,
banResponse: "429",
trapPaths: trapPathList,
ruleFilesDir: writeRules(t, `wp path block ^/wp-login\.php$`),
allowNets: allowed,
banResponse: "429",
})
start := clk.Now()
@@ -153,8 +153,8 @@ func TestTrapPathsNeedNoRuleFiles(t *testing.T) {
t.Parallel()
s, _, _ := startWithClock(t, "", map[string]string{
trapPaths: trapPathList,
"SWW_WAF_RULES_ENABLED": "false",
trapPaths: trapPathList,
"SWW_WAF_RULE_FILES_ENABLED": "false",
})
s.request(client, "/xmlrpc.php", http.StatusForbidden,
+4 -3
View File
@@ -227,9 +227,10 @@ func (h *handler) answerQuestion(w http.ResponseWriter, r *http.Request) {
// readQuestion returns the visitor's address and the body a question carries,
// and whether waf takes the question. It answers a question it does not
// take with 400 and no disposition. rproxy passes that 400 to the visitor,
// and SWW_RPROXY_DEFAULT_DISPOSITION does not apply. The id of a question waf takes
// is held from here on, without a decision yet, so that another question with
// that id is refused while this one is being handled.
// and SWW_RPROXY_WAF_BACKEND_NO_ANSWER_DISPOSITION does not apply. The id of
// a question waf takes is held from here on, without a decision yet, so
// that another question with that id is refused while this one is being
// handled.
//
// The body is read before any check, since Go's server does not send an answer
// until it has read the body. A question whose body stops arriving gets no
+19 -16
View File
@@ -454,7 +454,8 @@ func TestReportNeverReachesAQuestionStillBeingHandled(t *testing.T) {
geojsURL, asked, release := startHeldGeoJS(t)
s := startWAF(t, geojsURL, map[string]string{
lookupTimeout: "1h", tagDeny: denyKP, bytesLimitPerMinute: byteLimit,
asnAndCountryLookupTimeout: "3600000", tagDeny: denyKP,
bytesLimitPerMinute: byteLimit,
})
first := s.question(siteA, client, "/")
@@ -563,7 +564,7 @@ func TestContentLengthUnreadableOrNegativeCountsAsAbsent(t *testing.T) {
t.Parallel()
s := startWAF(t, "", map[string]string{
rulesDir: writeRules(
ruleFilesDir: writeRules(
t,
"any-length header:Content-Length block .\n",
),
@@ -755,8 +756,8 @@ func TestBlockRuleIsDeniedWith403AndABanRuleWithTheBanResponse(
t.Parallel()
s := startWAF(t, "", map[string]string{
rulesDir: writeRules(t, testRules),
banResponse: "429",
ruleFilesDir: writeRules(t, testRules),
banResponse: "429",
})
got := s.ask(client, "/blocked")
@@ -810,13 +811,13 @@ func TestObserveModeAllowsAndLogsWhatEnforceModeWouldHaveDone(t *testing.T) {
wantWouldAction(t, line, requestlog.ActionClientDenied)
}
func TestObserveModeRefusalAnsweredAllowCountsForTheErrorBurst(
func TestObserveModeRefusalAnsweredAllowCountsForTheRefusalLimit(
t *testing.T,
) {
t.Parallel()
s := startWAF(t, "", map[string]string{
mode: observe, rulesDir: writeRules(t, testRules), errorBurstThreshold: "1",
mode: observe, ruleFilesDir: writeRules(t, testRules), refusalsLimitPerMinute: "1",
})
for range 2 {
@@ -836,11 +837,11 @@ func TestObserveModeRefusalAnsweredAllowCountsForTheErrorBurst(
}
}
func TestDeniedQuestionCountsForTheErrorBurst(t *testing.T) {
func TestDeniedQuestionCountsForTheRefusalLimit(t *testing.T) {
t.Parallel()
s := startWAF(t, "", map[string]string{
rulesDir: writeRules(t, testRules), errorBurstThreshold: "1",
ruleFilesDir: writeRules(t, testRules), refusalsLimitPerMinute: "1",
})
for range 2 {
@@ -875,7 +876,9 @@ func TestAllowCarriesTheLookupHeadersWhileTheSettingIsSet(t *testing.T) {
geojsURL, _ := startGeoJS(t)
s := startWAF(t, geojsURL, map[string]string{
addLookupHeaders: tc.setting, lookupTimeout: "1h", tagDeny: denyKP,
addLookupHeaders: tc.setting,
asnAndCountryLookupTimeout: "3600000",
tagDeny: denyKP,
})
got := s.ask(fromDE, "/")
@@ -946,8 +949,8 @@ func TestReportCountsAtThePercentageItsQuestionWasGiven(t *testing.T) {
geojsURL, _ := startGeoJS(t)
// 100 bytes pass half of a limit of 99 and are within a limit of 199.
s := startWAF(t, geojsURL, map[string]string{
bytesLimitPerMinute: "199", countryBytesPercent: countryDEHalf,
lookupTimeout: "1h",
bytesLimitPerMinute: "199", countryBytesLimitPercent: countryDEHalf,
asnAndCountryLookupTimeout: "3600000",
})
s.passedOn(fromKP)
@@ -1421,7 +1424,7 @@ func TestReportWritesTheLastLineWithTheDecisionsAndTheReportsFields(
s := startWAF(t, "", map[string]string{
logRequestHeaders: "accept",
rulesDir: writeRules(t, "seen path log ^/page$\n"),
ruleFilesDir: writeRules(t, "seen path log ^/page$\n"),
})
const (
@@ -1573,7 +1576,7 @@ func TestBanMadeThroughOneHostRefusesTheClientOnAnother(t *testing.T) {
s := startWAF(
t,
"",
map[string]string{rulesDir: writeRules(t, testRules)},
map[string]string{ruleFilesDir: writeRules(t, testRules)},
)
got := do(t, s.question(siteA, client, "/.env"))
@@ -1745,7 +1748,7 @@ func TestChecksSeeTheHeadersTheVisitorSent(t *testing.T) {
t.Parallel()
s := startWAF(t, "", map[string]string{
rulesDir: writeRules(t, `
ruleFilesDir: writeRules(t, `
own-client header:X-Sww-Client block .
own-peer header:X-Sww-Peer block .
own-length header:X-Sww-Content-Length block .
@@ -1779,7 +1782,7 @@ func TestCoreRuleSetReadsTheBodyAQuestionCarries(t *testing.T) {
t.Parallel()
s := startWAF(t, "", map[string]string{
wafMode: block, wafBodyLimit: "64K",
coreRuleSetMode: block, coreRuleSetBodyInspectionSize: "64K",
})
ask := func(contentType, body string) answer {
@@ -1804,7 +1807,7 @@ func TestCoreRuleSetReadsTheBodyAQuestionCarries(t *testing.T) {
func TestCoreRuleSetAlertNamesTheSite(t *testing.T) {
t.Parallel()
s := startWAF(t, "", map[string]string{wafMode: block})
s := startWAF(t, "", map[string]string{coreRuleSetMode: block})
got := s.ask(client, sqlInjection)
wantDisposition(t, got, http.StatusForbidden, waf.DispositionDeny)