Blocklists and an AS percentage file fetched by URL (closes #29)
check / check (push) Waiting to run
check / check (push) Waiting to run
SWWAF_BLOCKLIST_URLS names lists of addresses and netblocks, fetched every SWWAF_BLOCKLIST_REFRESH (24h, never under 1h); an IPv4-mapped line stands for its IPv4 address or netblock. reputation.json keeps each list's last try, failed or not, even one cut off by a stop, which a restart waits on as a running instance does, and its last good copy, whole, used while a fetch fails. SWWAF_BLOCKLIST_ACTION denies, limits or only logs a listed client; the log line names the lists, each raises reputation_hit, and a failed fetch raises source_failure. SWWAF_ASN_LIMIT_PERCENT_URL is fetched the same way and counts as SWWAF_ASN_LIMIT_PERCENT does, the lower winning. Judgement call: a failed fetch is retried after the refresh, not sooner. Not done: ban notes do not name the lists yet. Model: opus-5-5
This commit was merged in pull request #108.
This commit is contained in:
+33
-18
@@ -17,33 +17,48 @@ type percentage struct {
|
||||
}
|
||||
|
||||
// biasedThresholdsSet reports whether a biased threshold can lower a
|
||||
// client's limits: one of its lists is not empty, or
|
||||
// SWWAF_UNKNOWN_LIMIT_PERCENT is below 100. The client's lookup is then
|
||||
// needed before its request goes on.
|
||||
// client's limits: one of its lists is not empty,
|
||||
// SWWAF_UNKNOWN_LIMIT_PERCENT is below 100, or SWWAF_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.ASNLimitPercent) > 0 || len(cfg.CountryLimitPercent) > 0 ||
|
||||
len(cfg.ASNBytesPercent) > 0 || len(cfg.CountryBytesPercent) > 0 ||
|
||||
cfg.UnknownLimitPercent < whole
|
||||
cfg.UnknownLimitPercent < whole || cfg.ASNLimitPercentURL != ""
|
||||
}
|
||||
|
||||
// limitPercentages returns a client's limit percentages, for the rate
|
||||
// limitPercentages returns the client's limit percentages, for the rate
|
||||
// limits and for the byte limits, by its AS number and country as looked
|
||||
// up, each "" when unknown. Each is the lowest of those the settings give
|
||||
// it, the first of them in the order below when several are lowest: the
|
||||
// percentage SWWAF_ASN_LIMIT_PERCENT gives its AS number, the one
|
||||
// SWWAF_COUNTRY_LIMIT_PERCENT gives its country, and, for a client
|
||||
// without a country, SWWAF_UNKNOWN_LIMIT_PERCENT. For the byte limits,
|
||||
// SWWAF_ASN_BYTES_PERCENT and SWWAF_COUNTRY_BYTES_PERCENT take the place
|
||||
// of the first two for an AS number or a country they list.
|
||||
func limitPercentages(
|
||||
cfg *config.Config, asn, country string,
|
||||
) (percentage, percentage) {
|
||||
// up, each "" when unknown, and the blocklists that list it. Each is the
|
||||
// lowest of those the settings give it, the first of them in the order
|
||||
// below when several are lowest: the percentage SWWAF_ASN_LIMIT_PERCENT
|
||||
// gives its AS number, the one the file SWWAF_ASN_LIMIT_PERCENT_URL names
|
||||
// gives it, the one SWWAF_COUNTRY_LIMIT_PERCENT gives its country, for a
|
||||
// client without a country, SWWAF_UNKNOWN_LIMIT_PERCENT, and for a client
|
||||
// a blocklist lists, the percentage of SWWAF_BLOCKLIST_ACTION while it is
|
||||
// limit. For the byte limits, SWWAF_ASN_BYTES_PERCENT and
|
||||
// SWWAF_COUNTRY_BYTES_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
|
||||
|
||||
unknown := percentage{percent: whole}
|
||||
if country == "" {
|
||||
unknown = percentage{cfg.UnknownLimitPercent, "SWWAF_UNKNOWN_LIMIT_PERCENT"}
|
||||
}
|
||||
|
||||
asnRequests := given(cfg.ASNLimitPercent, asn, "SWWAF_ASN_LIMIT_PERCENT")
|
||||
fetched := percentage{percent: whole}
|
||||
if percent, listed := rq.h.lists.ASNLimitPercent(asn); listed {
|
||||
fetched = percentage{percent, "SWWAF_ASN_LIMIT_PERCENT_URL"}
|
||||
}
|
||||
|
||||
listed := percentage{percent: whole}
|
||||
if len(rq.line.Reputation) > 0 && cfg.BlocklistAction == "limit" {
|
||||
listed = percentage{cfg.BlocklistLimitPercent, "SWWAF_BLOCKLIST_ACTION"}
|
||||
}
|
||||
|
||||
asnRequests := lowest(given(cfg.ASNLimitPercent, asn, "SWWAF_ASN_LIMIT_PERCENT"),
|
||||
fetched)
|
||||
countryRequests := given(cfg.CountryLimitPercent, country,
|
||||
"SWWAF_COUNTRY_LIMIT_PERCENT")
|
||||
|
||||
@@ -56,8 +71,8 @@ func limitPercentages(
|
||||
countryBytes = given(cfg.CountryBytesPercent, country, "SWWAF_COUNTRY_BYTES_PERCENT")
|
||||
}
|
||||
|
||||
return lowest(asnRequests, countryRequests, unknown),
|
||||
lowest(asnBytes, countryBytes, unknown)
|
||||
return lowest(asnRequests, countryRequests, unknown, listed),
|
||||
lowest(asnBytes, countryBytes, unknown, listed)
|
||||
}
|
||||
|
||||
// given returns the percentage percents, the setting named setting, gives
|
||||
|
||||
@@ -310,6 +310,7 @@ func TestRequestWaitsForItsLookupWhileABiasedThresholdIsSet(t *testing.T) {
|
||||
{asnBytesPercent, asnDEHalf, true},
|
||||
{countryBytesPercent, countryDEHalf, true},
|
||||
{unknownLimitPercent, "99", true},
|
||||
{asnLimitPercentURL, asnURL, true},
|
||||
// At 100, its default, it lowers no limit.
|
||||
{unknownLimitPercent, "100", false},
|
||||
} {
|
||||
|
||||
+17
-5
@@ -18,6 +18,7 @@ import (
|
||||
"sneak.berlin/go/smallwebwaf/internal/lookup"
|
||||
"sneak.berlin/go/smallwebwaf/internal/metrics"
|
||||
"sneak.berlin/go/smallwebwaf/internal/ratelimit"
|
||||
"sneak.berlin/go/smallwebwaf/internal/reputation"
|
||||
"sneak.berlin/go/smallwebwaf/internal/requestlog"
|
||||
"sneak.berlin/go/smallwebwaf/internal/rules"
|
||||
)
|
||||
@@ -69,14 +70,16 @@ type Params struct {
|
||||
// against.
|
||||
Rules *rules.Files
|
||||
// Alerts receive the alert for each ban the proxy makes or makes
|
||||
// permanent, for each count over an anomaly threshold, and for GeoJS
|
||||
// failing.
|
||||
// permanent, for each count over an anomaly threshold, for each request
|
||||
// whose client a blocklist lists, and for GeoJS failing or a fetch of a
|
||||
// list failing.
|
||||
Alerts *alerts.Queue
|
||||
}
|
||||
|
||||
// Server is the server smallwebwaf runs, with the parts of the proxy
|
||||
// whose state the state files keep, the lookup database, nil unless
|
||||
// SWWAF_LOOKUP_SOURCE is file, and the metrics.
|
||||
// SWWAF_LOOKUP_SOURCE is file, the lists fetched from URLs, which its Run
|
||||
// fetches, and the metrics.
|
||||
type Server struct {
|
||||
*http.Server
|
||||
|
||||
@@ -85,6 +88,7 @@ type Server struct {
|
||||
GeoJS *lookup.GeoJS
|
||||
Anomalies *anomaly.Counters
|
||||
LookupFile *lookup.File
|
||||
Lists *reputation.Lists
|
||||
Metrics *metrics.Metrics
|
||||
}
|
||||
|
||||
@@ -132,8 +136,13 @@ func New(params Params) *Server {
|
||||
Alerts: params.Alerts,
|
||||
}),
|
||||
lookupFile: params.LookupFile,
|
||||
rules: params.Rules,
|
||||
alerts: params.Alerts,
|
||||
lists: reputation.New(reputation.Params{
|
||||
BlocklistURLs: params.Config.BlocklistURLs, Refresh: params.Config.BlocklistRefresh,
|
||||
ASNLimitPercentURL: params.Config.ASNLimitPercentURL, Now: params.Now,
|
||||
ProcessLog: params.ProcessLog, Alerts: params.Alerts,
|
||||
}),
|
||||
rules: params.Rules,
|
||||
alerts: params.Alerts,
|
||||
}
|
||||
h.geojs = lookup.New(lookup.Params{
|
||||
URL: params.GeoJSURL,
|
||||
@@ -151,6 +160,7 @@ func New(params Params) *Server {
|
||||
})
|
||||
m.AddBansAndClients(h.ledger, h.limiter, params.Now)
|
||||
m.AddRules(params.Rules)
|
||||
m.AddReputation(h.lists)
|
||||
|
||||
return &Server{
|
||||
Server: &http.Server{
|
||||
@@ -170,6 +180,7 @@ func New(params Params) *Server {
|
||||
GeoJS: h.geojs,
|
||||
Anomalies: h.anomalies,
|
||||
LookupFile: h.lookupFile,
|
||||
Lists: h.lists,
|
||||
Metrics: m,
|
||||
}
|
||||
}
|
||||
@@ -189,6 +200,7 @@ type handler struct {
|
||||
geojs *lookup.GeoJS
|
||||
anomalies *anomaly.Counters
|
||||
lookupFile *lookup.File
|
||||
lists *reputation.Lists
|
||||
rules *rules.Files
|
||||
alerts *alerts.Queue
|
||||
}
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
package proxy
|
||||
|
||||
import (
|
||||
"sneak.berlin/go/smallwebwaf/internal/alerts"
|
||||
)
|
||||
|
||||
// blocklistDenied notes in the log line the URLs of the blocklists that
|
||||
// list the client, counts each of them in the metrics and raises a
|
||||
// reputation_hit alert for it, and reports whether SWWAF_BLOCKLIST_ACTION,
|
||||
// being deny, refuses the request. Being limit, it lowers the client's
|
||||
// limits instead (see limitPercentages), and being log, it does nothing
|
||||
// more.
|
||||
func (rq *request) blocklistDenied() bool {
|
||||
listedBy := rq.h.lists.ListedBy(rq.client)
|
||||
rq.line.Reputation = listedBy
|
||||
|
||||
for _, listURL := range listedBy {
|
||||
rq.h.metrics.ReputationHit(listURL)
|
||||
rq.h.alerts.Raise(alerts.Alert{
|
||||
Event: alerts.EventReputationHit,
|
||||
Client: rq.client,
|
||||
Netblock: clientGroup(rq.client),
|
||||
ASN: rq.line.ASN,
|
||||
ASName: rq.line.ASName,
|
||||
Country: rq.line.Country,
|
||||
Reason: "listed by a blocklist",
|
||||
Detail: map[string]any{"source": listURL},
|
||||
})
|
||||
}
|
||||
|
||||
return len(listedBy) > 0 && rq.h.config.BlocklistAction == "deny"
|
||||
}
|
||||
@@ -0,0 +1,333 @@
|
||||
package proxy_test
|
||||
|
||||
import (
|
||||
"maps"
|
||||
"net/http"
|
||||
"net/netip"
|
||||
"slices"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"sneak.berlin/go/smallwebwaf/internal/alerts"
|
||||
"sneak.berlin/go/smallwebwaf/internal/proxy"
|
||||
"sneak.berlin/go/smallwebwaf/internal/reputation"
|
||||
"sneak.berlin/go/smallwebwaf/internal/requestlog"
|
||||
)
|
||||
|
||||
// The reputation settings.
|
||||
const (
|
||||
blocklistURLs = "SWWAF_BLOCKLIST_URLS"
|
||||
blocklistAction = "SWWAF_BLOCKLIST_ACTION"
|
||||
asnLimitPercentURL = "SWWAF_ASN_LIMIT_PERCENT_URL"
|
||||
)
|
||||
|
||||
// The actions of SWWAF_BLOCKLIST_ACTION but limit, which has a
|
||||
// percentage.
|
||||
const (
|
||||
actionDeny = "deny"
|
||||
actionLog = "log"
|
||||
)
|
||||
|
||||
// The lists these tests name, which are never fetched: each test puts in
|
||||
// the copies it needs, as reputation.json would at start.
|
||||
const (
|
||||
dropURL = "https://lists.example/drop.txt"
|
||||
torURL = "https://lists.example/tor.txt"
|
||||
asnURL = "https://lists.example/asn.txt"
|
||||
)
|
||||
|
||||
func TestEachBlocklistActionForAListedAddressAndAListedNetblock(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
forward, denied := requestlog.ActionForward, requestlog.ActionDenied
|
||||
|
||||
for _, tc := range []struct {
|
||||
action string
|
||||
// statuses and actions are those of a listed client's three
|
||||
// requests, and percent their limit_percent, as percentText gives it.
|
||||
statuses []int
|
||||
actions []string
|
||||
percent string
|
||||
}{
|
||||
{
|
||||
actionDeny, []int{http.StatusForbidden, http.StatusForbidden, http.StatusForbidden},
|
||||
[]string{denied, denied, denied}, none,
|
||||
},
|
||||
{
|
||||
// Half of 4 requests a minute: the third breaks the limit.
|
||||
"limit:50", []int{http.StatusOK, http.StatusOK, http.StatusForbidden},
|
||||
[]string{forward, forward, requestlog.ActionRateLimited},
|
||||
"50 from " + blocklistAction,
|
||||
},
|
||||
{
|
||||
actionLog, []int{http.StatusOK, http.StatusOK, http.StatusOK},
|
||||
[]string{forward, forward, forward}, none,
|
||||
},
|
||||
} {
|
||||
t.Run(tc.action, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
s, server, _ := startWithLookups(t, map[string]string{
|
||||
rateLimitPerMinute: fourAMinute, blocklistURLs: dropURL,
|
||||
blocklistAction: tc.action,
|
||||
})
|
||||
// fromDE is listed as an address, and fromKP in a netblock.
|
||||
loadLists(t, server, map[string][]string{
|
||||
dropURL: {"; DROP", fromDE, "198.51.100.0/24 ; SBL1"},
|
||||
})
|
||||
|
||||
for _, from := range []string{fromDE, fromKP} {
|
||||
for i := range 3 {
|
||||
line := s.get(from, tc.statuses[i], tc.actions[i])
|
||||
wantReputation(t, line, dropURL)
|
||||
wantPercent(t, "limit_percent", line.LimitPercent,
|
||||
line.LimitPercentSetting, tc.percent)
|
||||
|
||||
// A request refused for the list is not counted.
|
||||
counted := line.fields["counts"] != nil
|
||||
if counted != (tc.actions[i] != denied) {
|
||||
t.Errorf("request from %s counted %t, logged %s", from, counted,
|
||||
tc.actions[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A client no list lists has the whole limit.
|
||||
for range 3 {
|
||||
line := s.get(unplaced, http.StatusOK, forward)
|
||||
wantReputation(t, line)
|
||||
wantPercent(t, "limit_percent", line.LimitPercent, line.LimitPercentSetting,
|
||||
none)
|
||||
}
|
||||
|
||||
// A refusal for the list makes no ban.
|
||||
if held := server.Ledger.Snapshot(); tc.action == actionDeny && len(held) != 0 {
|
||||
t.Errorf("bans %+v, want none", held)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestBlocklistsComeAfterTheCountryListsAndSkipAllowNets(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
s, server, queue := startWithLookups(t, map[string]string{
|
||||
blocklistURLs: dropURL, deniedCountries: "kp", allowNets: fromDE,
|
||||
})
|
||||
loadLists(t, server, map[string][]string{dropURL: {fromDE, fromKP}})
|
||||
|
||||
// fromKP's country refuses it before the list is looked at, and fromDE,
|
||||
// in SWWAF_ALLOW_NETS, is not checked at all: neither is noted, nor
|
||||
// alerted.
|
||||
wantReputation(t, s.get(fromKP, http.StatusForbidden, requestlog.ActionCountryDenied))
|
||||
wantReputation(t, s.get(fromDE, http.StatusOK, requestlog.ActionForward))
|
||||
wantAlerts(t, queue)
|
||||
}
|
||||
|
||||
func TestObserveModeForwardsAClientABlocklistDeniesAndAlertsIt(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
s, server, queue := startWithLookups(t, map[string]string{
|
||||
blocklistURLs: dropURL, mode: observe,
|
||||
})
|
||||
loadLists(t, server, map[string][]string{dropURL: {fromDE}})
|
||||
|
||||
line := s.get(fromDE, http.StatusOK, requestlog.ActionForward)
|
||||
wantWouldAction(t, line, requestlog.ActionDenied)
|
||||
wantReputation(t, line, dropURL)
|
||||
|
||||
waiting := queue.Snapshot().Waiting[alerts.DestinationWebhook]
|
||||
if len(waiting) != 1 || waiting[0].Event != alerts.EventReputationHit {
|
||||
t.Errorf("alerts waiting %+v, want a reputation_hit alert", waiting)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBlocklistLimitTakesPartInTheLowestPercentageOfEveryLimit(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
for _, tc := range []struct {
|
||||
action, asnPercent string
|
||||
// want is the upload's limit_percent and bytes_percent, as
|
||||
// percentText gives them, and limitHit its limit_hit.
|
||||
want, limitHit string
|
||||
}{
|
||||
{"limit:50", asnDEQuarter, "25 from " + asnLimitPercent, minuteBytes},
|
||||
{"limit:25", asnDEHalf, "25 from " + blocklistAction, minuteBytes},
|
||||
// The AS number's, the first of two alike.
|
||||
{"limit:25", asnDEQuarter, "25 from " + asnLimitPercent, minuteBytes},
|
||||
{actionLog, asnDE + ":100", none, ""},
|
||||
} {
|
||||
t.Run(tc.action+" "+tc.asnPercent, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
s, server, _ := startWithLookups(t, map[string]string{
|
||||
bytesLimitPerMinute: twoUploads, blocklistURLs: dropURL,
|
||||
blocklistAction: tc.action, asnLimitPercent: tc.asnPercent,
|
||||
})
|
||||
loadLists(t, server, map[string][]string{dropURL: {fromDE}})
|
||||
|
||||
// The upload's 100 bytes are over 49, a quarter of 199, and 99,
|
||||
// half of it, and within 199.
|
||||
line := s.uploadFrom(fromDE)
|
||||
wantPercent(t, "limit_percent", line.LimitPercent, line.LimitPercentSetting,
|
||||
tc.want)
|
||||
wantPercent(t, "bytes_percent", line.BytesPercent, line.BytesPercentSetting,
|
||||
tc.want)
|
||||
|
||||
if line.LimitHit != tc.limitHit {
|
||||
t.Errorf("log line has limit_hit %q, want %q", line.LimitHit, tc.limitHit)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestASNLimitPercentFileCountsAsTheSettingDoesTheLowerWinning(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
const (
|
||||
fromURL = "25 from " + asnLimitPercentURL
|
||||
fromSetting = "25 from " + asnLimitPercent
|
||||
)
|
||||
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
env map[string]string
|
||||
file string
|
||||
// limitPercent and bytesPercent are the upload's, as percentText
|
||||
// gives them.
|
||||
limitPercent, bytesPercent string
|
||||
}{
|
||||
{"the file's alone", nil, asnDEQuarter, fromURL, fromURL},
|
||||
{
|
||||
"the file's, lower than the setting's",
|
||||
map[string]string{asnLimitPercent: asnDEHalf}, asnDEQuarter, fromURL, fromURL,
|
||||
},
|
||||
{
|
||||
"the setting's, lower than the file's",
|
||||
map[string]string{asnLimitPercent: asnDEQuarter}, asnDEHalf,
|
||||
fromSetting, fromSetting,
|
||||
},
|
||||
{
|
||||
"the setting's, the first of two alike",
|
||||
map[string]string{asnLimitPercent: asnDEQuarter}, asnDEQuarter,
|
||||
fromSetting, fromSetting,
|
||||
},
|
||||
{"none, for an AS number the file does not list", nil, asnKP + ":25", none, none},
|
||||
{
|
||||
"SWWAF_ASN_BYTES_PERCENT's in place of the file's for the byte limits",
|
||||
map[string]string{asnBytesPercent: asnDE + ":100"}, asnDEQuarter, fromURL, none,
|
||||
},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
env := map[string]string{asnLimitPercentURL: asnURL}
|
||||
maps.Copy(env, tc.env)
|
||||
|
||||
s, server, _ := startWithLookups(t, env)
|
||||
loadLists(t, server, map[string][]string{asnURL: {"# by AS number", tc.file}})
|
||||
|
||||
line := s.uploadFrom(fromDE)
|
||||
wantPercent(t, "limit_percent", line.LimitPercent, line.LimitPercentSetting,
|
||||
tc.limitPercent)
|
||||
wantPercent(t, "bytes_percent", line.BytesPercent, line.BytesPercentSetting,
|
||||
tc.bytesPercent)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestEachBlocklistThatListsAClientRaisesAnAlertOncePerCooldownAndIsCounted(
|
||||
t *testing.T,
|
||||
) {
|
||||
t.Parallel()
|
||||
|
||||
const emptyURL = "https://lists.example/empty.txt"
|
||||
|
||||
s, clk, server, queue := startWithLookupsAndClock(t, map[string]string{
|
||||
blocklistURLs: dropURL + "," + torURL + "," + emptyURL,
|
||||
blocklistAction: actionLog,
|
||||
metricsToken: token,
|
||||
})
|
||||
loadLists(t, server, map[string][]string{dropURL: {fromDE}, torURL: {fromDE}})
|
||||
|
||||
// The second request's alerts are repeats, which the cooldown holds
|
||||
// back.
|
||||
for range 2 {
|
||||
wantReputation(t, s.get(fromDE, http.StatusOK, requestlog.ActionForward),
|
||||
dropURL, torURL)
|
||||
}
|
||||
|
||||
hit := func(source string) alerts.Alert {
|
||||
return alerts.Alert{
|
||||
Instance: alertInstance,
|
||||
Time: clk.Now(),
|
||||
Event: alerts.EventReputationHit,
|
||||
Client: netip.MustParseAddr(fromDE),
|
||||
Netblock: netip.MustParsePrefix(fromDE + "/32"),
|
||||
ASN: asnDE,
|
||||
ASName: asNameDE,
|
||||
Country: "DE",
|
||||
Reason: "listed by a blocklist",
|
||||
Detail: map[string]any{"source": source},
|
||||
}
|
||||
}
|
||||
wantAlerts(t, queue, hit(dropURL), hit(torURL))
|
||||
|
||||
if queue.Suppressed() != 2 {
|
||||
t.Errorf("%d alerts held back, want the second request's 2", queue.Suppressed())
|
||||
}
|
||||
|
||||
// Each list's hits, none of its fetches failed, and when its copy was
|
||||
// fetched, 0 for the one without.
|
||||
metrics := s.scrape(unplaced)
|
||||
fetched := float64(listsFetched().Unix())
|
||||
|
||||
for listURL, want := range map[string]struct{ hits, fetched float64 }{
|
||||
dropURL: {2, fetched}, torURL: {2, fetched}, emptyURL: {0, 0},
|
||||
} {
|
||||
labels := `{instance="` + alertInstance + `",source="` + listURL + `"}`
|
||||
|
||||
if want.hits == 0 {
|
||||
wantNoSeries(t, metrics, "smallwebwaf_reputation_hits_total"+labels)
|
||||
} else {
|
||||
wantMetric(t, metrics, "smallwebwaf_reputation_hits_total"+labels, want.hits)
|
||||
}
|
||||
|
||||
wantMetric(t, metrics, "smallwebwaf_reputation_failures_total"+labels, 0)
|
||||
wantMetric(t, metrics, "smallwebwaf_reputation_last_fetch_timestamp_seconds"+labels,
|
||||
want.fetched)
|
||||
}
|
||||
}
|
||||
|
||||
// listsFetched is when loadLists has the copies fetched.
|
||||
func listsFetched() time.Time {
|
||||
return time.Date(2026, 10, 5, 0, 0, 0, 0, time.UTC)
|
||||
}
|
||||
|
||||
// loadLists puts copies of lists into server's lists, by URL, each with
|
||||
// its lines, fetched at listsFetched, as reputation.json would at start.
|
||||
func loadLists(t *testing.T, server *proxy.Server, copies map[string][]string) {
|
||||
t.Helper()
|
||||
|
||||
lists := make([]reputation.List, 0, len(copies))
|
||||
for listURL, lines := range copies {
|
||||
lists = append(lists, reputation.List{
|
||||
URL: listURL, Fetched: listsFetched(), Lines: lines,
|
||||
})
|
||||
}
|
||||
|
||||
err := server.Lists.Load(lists)
|
||||
if err != nil {
|
||||
t.Fatalf("load the lists: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// wantReputation checks the URLs of the blocklists the log line names in
|
||||
// its reputation.
|
||||
func wantReputation(t *testing.T, line logLine, want ...string) {
|
||||
t.Helper()
|
||||
|
||||
if !slices.Equal(line.Reputation, want) {
|
||||
t.Errorf("log line has reputation %v, want %v", line.Reputation, want)
|
||||
}
|
||||
}
|
||||
@@ -213,14 +213,14 @@ func (rq *request) check(ctx context.Context) *refusal {
|
||||
// client in SWWAF_ALLOW_NETS skips them, and is not looked up. For any
|
||||
// other client, SWWAF_DENY_NETS comes first, then a ban on its netblock,
|
||||
// so that a client either refuses is not looked up, then the lookup of
|
||||
// its AS number and country, and then the country lists; a request any of
|
||||
// them refuses is not counted for the rate limits. Then come the rate
|
||||
// limits, unless the client is in SWWAF_RATE_LIMIT_EXEMPT_NETS or the
|
||||
// request's path is exempt under SWWAF_RATE_LIMIT_EXEMPT_PATHS, so that
|
||||
// every other request is counted, each of them by the client's limit
|
||||
// percentages, and last the rule files. A request exempt from the rate
|
||||
// limits is exempt from the byte limits too. ctx is the request's own
|
||||
// context.
|
||||
// its AS number and country, then the country lists, and then the
|
||||
// blocklists; a request any of them refuses is not counted for the rate
|
||||
// limits. Then come the rate limits, unless the client is in
|
||||
// SWWAF_RATE_LIMIT_EXEMPT_NETS or the request's path is exempt under
|
||||
// SWWAF_RATE_LIMIT_EXEMPT_PATHS, so that every other request is counted,
|
||||
// each of them by the client's limit percentages, and last the rule
|
||||
// files. A request exempt from the rate limits is exempt from the byte
|
||||
// limits too. ctx is the request's own context.
|
||||
func (rq *request) checkClient(ctx context.Context) string {
|
||||
cfg := rq.h.config
|
||||
if isInside(rq.client, cfg.AllowNets) {
|
||||
@@ -243,10 +243,14 @@ func (rq *request) checkClient(ctx context.Context) string {
|
||||
return requestlog.ActionCountryDenied
|
||||
}
|
||||
|
||||
if rq.blocklistDenied() {
|
||||
return requestlog.ActionDenied
|
||||
}
|
||||
|
||||
rq.counted = !isInside(rq.client, cfg.RateLimitExemptNets) &&
|
||||
!pathExempt(rq.in.URL, cfg.RateLimitExemptPaths)
|
||||
if rq.counted {
|
||||
rq.limitPercent, rq.bytesPercent = limitPercentages(cfg, rq.line.ASN, rq.line.Country)
|
||||
rq.limitPercent, rq.bytesPercent = rq.limitPercentages()
|
||||
rq.line.LimitPercent, rq.line.LimitPercentSetting = rq.limitPercent.logged()
|
||||
rq.line.BytesPercent, rq.line.BytesPercentSetting = rq.bytesPercent.logged()
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user