Rename the settings whose names do not say what they control (closes #234)
check / check (push) Waiting to run
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:
1 parent
101cc0aba5
commit
4be0b6e7fc
97 files changed
+2030
-1810
No files matched your search
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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",
|
||||
})
|
||||
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
)
|
||||
|
||||
@@ -29,12 +29,12 @@ 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 is the default of SWW_WAF_DNSBL_ABUSEIPDB_TIMEOUT_MS.
|
||||
defaultReputationWait = 2 * time.Second
|
||||
// The header limits SPEC.md gives.
|
||||
cookieLimit = 50
|
||||
@@ -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,
|
||||
},
|
||||
@@ -759,7 +769,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",
|
||||
|
||||
@@ -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
@@ -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()
|
||||
|
||||
|
||||
+24
-23
@@ -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
|
||||
// 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.Config) bool {
|
||||
return len(cfg.ASNBytesPercent) > 0 ||
|
||||
len(cfg.CountryBytesPercent) > 0 ||
|
||||
cfg.UnknownLimitPercent < whole ||
|
||||
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",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -71,10 +72,10 @@ func (rq *request) limitPercentages() (percentage, percentage) {
|
||||
|
||||
reputationListed := percentage{percent: whole}
|
||||
if (rq.dnsblListed || rq.abuseIPDBHit) &&
|
||||
cfg.ReputationAction == "limit" {
|
||||
cfg.DNSBLAbuseIPDBAction == "limit" {
|
||||
reputationListed = percentage{
|
||||
cfg.ReputationLimitPercent,
|
||||
"SWW_WAF_REPUTATION_ACTION",
|
||||
cfg.DNSBLAbuseIPDBLimitPercent,
|
||||
"SWW_WAF_DNSBL_ABUSEIPDB_ACTION",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -102,19 +103,19 @@ 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",
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
+45
-33
@@ -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,16 @@ 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.
|
||||
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 +355,7 @@ func TestClientWithoutAnAnswerInTimeHasTheUnknownLimitPercent(t *testing.T) {
|
||||
)
|
||||
wantPercent(t, "rate_limit_percent",
|
||||
line.RateLimitPercent, line.RateLimitPercentSetting,
|
||||
"0 from "+unknownLimitPercent)
|
||||
"0 from "+unknownCountryLimitPercent)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -365,12 +374,12 @@ 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()
|
||||
@@ -380,7 +389,10 @@ func TestRequestWaitsForItsLookupWhileABiasedThresholdIsSet(t *testing.T) {
|
||||
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 +461,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,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 (
|
||||
|
||||
@@ -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
@@ -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,
|
||||
})
|
||||
}
|
||||
@@ -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,
|
||||
})
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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}
|
||||
@@ -452,7 +453,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,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)
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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)
|
||||
|
||||
@@ -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,7 +14,7 @@ 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.
|
||||
// Its figures are the window, the count and the limit.
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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,24 +48,24 @@ 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(
|
||||
// startForRefusalLimit is startWithClock with testRules, the metrics token and
|
||||
// SWW_WAF_REFUSALS_LIMIT_PER_MINUTE at threshold, and the settings in env.
|
||||
func startForRefusalLimit(
|
||||
t *testing.T, threshold string, env map[string]string,
|
||||
) (*sender, *clock, *waf.Server) {
|
||||
t.Helper()
|
||||
|
||||
settings := map[string]string{
|
||||
errorBurstThreshold: threshold,
|
||||
rulesDir: writeRules(t, testRules),
|
||||
metricsToken: token,
|
||||
refusalsLimitPerMinute: threshold,
|
||||
ruleFilesDir: writeRules(t, testRules),
|
||||
metricsToken: token,
|
||||
}
|
||||
maps.Copy(settings, env)
|
||||
|
||||
return startWithClock(t, "", settings)
|
||||
}
|
||||
|
||||
func TestErrorBurstBreaksAtOneOverTheThreshold(t *testing.T) {
|
||||
func TestRefusalLimitBreaksAtOneOverTheThreshold(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
for _, tc := range []struct {
|
||||
@@ -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,15 +221,15 @@ 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.
|
||||
@@ -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),
|
||||
},
|
||||
)
|
||||
|
||||
@@ -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.
|
||||
// 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.threshold, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
env := map[string]string{rulesDir: writeRules(t, testRules)}
|
||||
env := map[string]string{ruleFilesDir: writeRules(t, testRules)}
|
||||
if tc.threshold != "" {
|
||||
env[errorBurstThreshold] = tc.threshold
|
||||
env[refusalsLimitPerMinute] = tc.threshold
|
||||
}
|
||||
|
||||
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{
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
@@ -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}})
|
||||
@@ -623,27 +623,27 @@ func TestReputationLimitTakesPartInTheLowestPercentageOfEveryLimit(
|
||||
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.
|
||||
|
||||
@@ -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,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"
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
@@ -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
@@ -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)
|
||||
|
||||
Reference in new issue
Block a user