check / check (push) Waiting to run
With SWWAF_ABUSEIPDB_KEY set, a client whose history counts an offence (a broken limit, a ban rule's match or a block rule's refusal, counted by kind) is checked in the background, at most SWWAF_ABUSEIPDB_DAILY_BUDGET checks a day, the count kept in reputation.json. A client, an IPv4 address or an IPv6 /64, is checked by the address it sent from, and its score serves all its addresses. A score at or over SWWAF_ABUSEIPDB_MIN_SCORE is a hit for SWWAF_REPUTATION_ACTION, logged as abuseipdb and alerted with its score. A failure or the used-up budget gives no score and raises source_failure. The key goes only in the Key header. Judgement call: the budget's day is UTC; AbuseIPDB documents no reset time. Judgement call: each check sent spends budget; a minute's pause after a failure. Model: opus-5-5
961 lines
31 KiB
Go
961 lines
31 KiB
Go
package proxy_test
|
|
|
|
import (
|
|
"fmt"
|
|
"io"
|
|
"maps"
|
|
"net/http"
|
|
"net/netip"
|
|
"slices"
|
|
"strconv"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"sneak.berlin/go/smallwebwaf/internal/alerts"
|
|
"sneak.berlin/go/smallwebwaf/internal/proxy"
|
|
"sneak.berlin/go/smallwebwaf/internal/ratelimit"
|
|
"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 and SWWAF_REPUTATION_ACTION:
|
|
// limitHalf gives a listed client half of every limit, and limitQuarter a
|
|
// quarter.
|
|
const (
|
|
actionDeny = "deny"
|
|
actionLog = "log"
|
|
limitHalf = "limit:50"
|
|
limitQuarter = "limit:25"
|
|
)
|
|
|
|
// 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.
|
|
limitHalf, []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
|
|
}{
|
|
{limitHalf, asnDEQuarter, "25 from " + asnLimitPercent, minuteBytes},
|
|
{limitQuarter, asnDEHalf, "25 from " + blocklistAction, minuteBytes},
|
|
// The AS number's, the first of two alike.
|
|
{limitQuarter, 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)
|
|
}
|
|
}
|
|
|
|
// The DNSBL settings.
|
|
const (
|
|
dnsblZones = "SWWAF_DNSBL_ZONES"
|
|
dnsblResolver = "SWWAF_DNSBL_RESOLVER"
|
|
reputationAction = "SWWAF_REPUTATION_ACTION"
|
|
)
|
|
|
|
// The DNSBL zones these tests name, which are never asked about the
|
|
// clients the tests send requests from: each test puts in the verdicts it
|
|
// needs, as reputation.json would at start. A query a test does start is
|
|
// sent to noResolver, where nothing listens, so that none leaves the host.
|
|
const (
|
|
dnsblZone = "dnsbl.example"
|
|
otherZone = "other.example"
|
|
noResolver = "127.0.0.1:9"
|
|
)
|
|
|
|
func TestEachReputationActionForAClientADNSBLZoneLists(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.
|
|
limitHalf, []int{http.StatusOK, http.StatusOK, http.StatusForbidden},
|
|
[]string{forward, forward, requestlog.ActionRateLimited},
|
|
"50 from " + reputationAction,
|
|
},
|
|
{
|
|
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, dnsblZones: dnsblZone + "," + otherZone,
|
|
dnsblResolver: noResolver, reputationAction: tc.action,
|
|
})
|
|
listedBy := map[string][]string{
|
|
fromDE: {dnsblZone, otherZone}, fromKP: {otherZone}, unplaced: nil,
|
|
}
|
|
loadVerdicts(server, listedBy)
|
|
|
|
for _, from := range []string{fromDE, fromKP} {
|
|
for i := range 3 {
|
|
line := s.get(from, tc.statuses[i], tc.actions[i])
|
|
// In the order SWWAF_DNSBL_ZONES names them.
|
|
wantReputation(t, line, listedBy[from]...)
|
|
wantPercent(t, "limit_percent", line.LimitPercent,
|
|
line.LimitPercentSetting, tc.percent)
|
|
|
|
// A request refused for the verdict 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 zone 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 verdict makes no ban, and every client had its
|
|
// verdicts, so no zone was asked.
|
|
if held := server.Ledger.Snapshot(); tc.action == actionDeny && len(held) != 0 {
|
|
t.Errorf("bans %+v, want none", held)
|
|
}
|
|
|
|
if queries := server.DNSBL.Queries(dnsblZone); queries != 0 {
|
|
t.Errorf("%d queries, want none", queries)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestDNSBLZonesComeAfterTheBlocklistsAndSkipAllowNets(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
s, server, queue := startWithLookups(t, map[string]string{
|
|
blocklistURLs: dropURL, dnsblZones: dnsblZone, dnsblResolver: noResolver,
|
|
reputationAction: actionDeny, allowNets: fromDE,
|
|
})
|
|
loadLists(t, server, map[string][]string{dropURL: {fromKP}})
|
|
loadVerdicts(server, map[string][]string{fromKP: {dnsblZone}, fromDE: {dnsblZone}})
|
|
|
|
// The blocklist refuses fromKP before its verdict is looked at, and
|
|
// fromDE, in SWWAF_ALLOW_NETS, is not checked at all: neither is noted
|
|
// for the zone, nor alerted, nor asked about.
|
|
wantReputation(t, s.get(fromKP, http.StatusForbidden, requestlog.ActionDenied),
|
|
dropURL)
|
|
wantReputation(t, s.get(fromDE, http.StatusOK, requestlog.ActionForward))
|
|
|
|
waiting := queue.Snapshot().Waiting[alerts.DestinationWebhook]
|
|
if len(waiting) != 1 || waiting[0].Detail["source"] != dropURL {
|
|
t.Errorf("alerts waiting %+v, want the blocklist's reputation_hit alone", waiting)
|
|
}
|
|
|
|
if queries := server.DNSBL.Queries(dnsblZone); queries != 0 {
|
|
t.Errorf("%d queries, want none", queries)
|
|
}
|
|
}
|
|
|
|
func TestObserveModeForwardsAClientADNSBLZoneDeniesAndAlertsIt(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
s, server, queue := startWithLookups(t, map[string]string{
|
|
dnsblZones: dnsblZone, dnsblResolver: noResolver, reputationAction: actionDeny,
|
|
mode: observe,
|
|
})
|
|
loadVerdicts(server, map[string][]string{fromDE: {dnsblZone}})
|
|
|
|
line := s.get(fromDE, http.StatusOK, requestlog.ActionForward)
|
|
wantWouldAction(t, line, requestlog.ActionDenied)
|
|
wantReputation(t, line, dnsblZone)
|
|
|
|
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 TestReputationLimitTakesPartInTheLowestPercentageOfEveryLimit(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
for _, tc := range []struct {
|
|
blocklistAction, reputationAction string
|
|
// want is the request's limit_percent and bytes_percent, as
|
|
// percentText gives them.
|
|
want string
|
|
}{
|
|
{limitHalf, limitQuarter, "25 from " + reputationAction},
|
|
{limitQuarter, limitHalf, "25 from " + blocklistAction},
|
|
// The blocklist's, the first of two alike.
|
|
{limitQuarter, limitQuarter, "25 from " + blocklistAction},
|
|
{actionLog, limitQuarter, "25 from " + reputationAction},
|
|
{actionLog, actionLog, none},
|
|
} {
|
|
t.Run(tc.blocklistAction+" "+tc.reputationAction, 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,
|
|
})
|
|
loadLists(t, server, map[string][]string{dropURL: {fromDE}})
|
|
loadVerdicts(server, map[string][]string{fromDE: {dnsblZone}})
|
|
|
|
// Named by the blocklist, then by the zone.
|
|
line := s.get(fromDE, http.StatusOK, requestlog.ActionForward)
|
|
wantReputation(t, line, dropURL, dnsblZone)
|
|
wantPercent(t, "limit_percent", line.LimitPercent, line.LimitPercentSetting,
|
|
tc.want)
|
|
wantPercent(t, "bytes_percent", line.BytesPercent, line.BytesPercentSetting,
|
|
tc.want)
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestEachZoneThatListsAClientRaisesAnAlertOncePerCooldownAndIsCounted(
|
|
t *testing.T,
|
|
) {
|
|
t.Parallel()
|
|
|
|
s, clk, server, queue := startWithLookupsAndClock(t, map[string]string{
|
|
dnsblZones: dnsblZone + "," + otherZone, dnsblResolver: noResolver,
|
|
reputationAction: actionLog, metricsToken: token,
|
|
})
|
|
loadVerdicts(server, map[string][]string{
|
|
fromDE: {dnsblZone, otherZone}, unplaced: nil,
|
|
})
|
|
|
|
// The second request's alerts are repeats, which the cooldown holds
|
|
// back.
|
|
for range 2 {
|
|
wantReputation(t, s.get(fromDE, http.StatusOK, requestlog.ActionForward),
|
|
dnsblZone, otherZone)
|
|
}
|
|
|
|
hit := func(zone 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 DNSBL zone",
|
|
Detail: map[string]any{"source": zone},
|
|
}
|
|
}
|
|
wantAlerts(t, queue, hit(dnsblZone), hit(otherZone))
|
|
|
|
if queue.Suppressed() != 2 {
|
|
t.Errorf("%d alerts held back, want the second request's 2", queue.Suppressed())
|
|
}
|
|
|
|
// Each zone's hits, and its queries and their failures, none, since
|
|
// every client had its verdicts.
|
|
metrics := s.scrape(unplaced)
|
|
|
|
for _, zone := range []string{dnsblZone, otherZone} {
|
|
labels := `{instance="` + alertInstance + `",source="` + zone + `"}`
|
|
|
|
wantMetric(t, metrics, "smallwebwaf_reputation_hits_total"+labels, 2)
|
|
wantMetric(t, metrics, "smallwebwaf_reputation_queries_total"+labels, 0)
|
|
wantMetric(t, metrics, "smallwebwaf_reputation_failures_total"+labels, 0)
|
|
}
|
|
}
|
|
|
|
func TestZoneKeyIsMaskedInTheLogTheAlertAndTheMetrics(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
const (
|
|
key = "abcdefghijklmnopqrstuvwxyz"
|
|
keyed = key + ".xbl.dq.spamhaus.net"
|
|
masked = "********.xbl.dq.spamhaus.net"
|
|
)
|
|
|
|
s, server, queue := startWithLookups(t, map[string]string{
|
|
dnsblZones: keyed, dnsblResolver: noResolver, reputationAction: actionLog,
|
|
metricsToken: token,
|
|
})
|
|
loadVerdicts(server, map[string][]string{fromDE: {keyed}, unplaced: nil})
|
|
|
|
wantReputation(t, s.get(fromDE, http.StatusOK, requestlog.ActionForward), masked)
|
|
|
|
waiting := queue.Snapshot().Waiting[alerts.DestinationWebhook]
|
|
if len(waiting) != 1 || waiting[0].Detail["source"] != masked {
|
|
t.Errorf("alerts waiting %+v, want a reputation_hit alert from %s", waiting,
|
|
masked)
|
|
}
|
|
|
|
metrics := s.scrape(unplaced)
|
|
wantMetric(t, metrics, `smallwebwaf_reputation_hits_total{instance="`+
|
|
alertInstance+`",source="`+masked+`"}`, 1)
|
|
|
|
for name, shown := range map[string]string{
|
|
"the log": s.out.text(), "the metrics": metrics,
|
|
} {
|
|
if strings.Contains(shown, key) {
|
|
t.Errorf("%s shows the key:\n%s", name, shown)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestRequestFromAClientWithoutAVerdictHasTheZoneAskedAboutIt(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
s, server, _ := startWithLookups(t, map[string]string{
|
|
dnsblZones: dnsblZone + "," + otherZone, dnsblResolver: noResolver,
|
|
})
|
|
server.DNSBL.Load([]reputation.Verdict{{
|
|
Zone: otherZone, Client: netip.MustParseAddr(fromDE), Listed: true,
|
|
Fetched: verdictsFetched(),
|
|
}})
|
|
|
|
// The verdict of the other zone is used, and dnsbl.example, which has
|
|
// none, is asked about the client in the background, once: the second
|
|
// request finds the query under way, or the zone left alone after it
|
|
// failed, since nothing answers at noResolver.
|
|
for range 2 {
|
|
wantReputation(t, s.get(fromDE, http.StatusOK, requestlog.ActionForward), otherZone)
|
|
}
|
|
|
|
if queries := server.DNSBL.Queries(dnsblZone); queries != 1 {
|
|
t.Errorf("%d queries to %s, want 1", queries, dnsblZone)
|
|
}
|
|
|
|
if queries := server.DNSBL.Queries(otherZone); queries != 0 {
|
|
t.Errorf("%d queries to %s, want none", queries, otherZone)
|
|
}
|
|
}
|
|
|
|
// The AbuseIPDB settings, and accountKey, the key the tests set.
|
|
const (
|
|
abuseIPDBKey = "SWWAF_ABUSEIPDB_KEY"
|
|
accountKey = "abuseipdb-key-0123456789abcdef"
|
|
)
|
|
|
|
// abuseipdb is how the request log, the alerts and the metrics name
|
|
// AbuseIPDB.
|
|
const abuseipdb = reputation.AbuseIPDBSource
|
|
|
|
// abuseIPDBURL is where newProxy has clients checked with AbuseIPDB: at
|
|
// abuseIPDBStandIn, which TestMain registers with Go's default transport,
|
|
// through which AbuseIPDB is asked.
|
|
const abuseIPDBURL = "abuseipdb://stand-in/api/v2/check"
|
|
|
|
// abuseIPDBStandIn is a stand-in for AbuseIPDB that gives every client the
|
|
// score 100, at once and without the network.
|
|
type abuseIPDBStandIn struct{}
|
|
|
|
// RoundTrip answers req with the score 100.
|
|
func (abuseIPDBStandIn) RoundTrip(req *http.Request) (*http.Response, error) {
|
|
return &http.Response{
|
|
StatusCode: http.StatusOK,
|
|
Status: "200 OK",
|
|
Header: http.Header{},
|
|
Body: io.NopCloser(strings.NewReader(`{"data":{"abuseConfidenceScore":100}}`)),
|
|
Request: req,
|
|
}, nil
|
|
}
|
|
|
|
func TestOnlyAClientThatHasCommittedAnOffenceIsCheckedWithAbuseIPDB(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
forward := requestlog.ActionForward
|
|
|
|
s, clk, server, _ := startWithLookupsAndClock(t, map[string]string{
|
|
abuseIPDBKey: accountKey, rateLimitPerMinute: "2", reputationAction: actionLog,
|
|
})
|
|
|
|
// Neither fromDE, until it breaks a rate limit, nor fromKP, which never
|
|
// does, is checked, nor fromDE under the ban that makes.
|
|
s.get(fromDE, http.StatusOK, forward)
|
|
s.get(fromDE, http.StatusOK, forward)
|
|
s.get(fromKP, http.StatusOK, forward)
|
|
s.get(fromDE, http.StatusForbidden, requestlog.ActionRateLimited)
|
|
s.get(fromDE, http.StatusForbidden, requestlog.ActionBanned)
|
|
wantAbuseIPDBChecks(t, server, 0)
|
|
|
|
// Once the ban has ended, fromDE's first request has it checked in the
|
|
// background, and goes on without its score, which its next request
|
|
// finds.
|
|
clk.advance(time.Hour)
|
|
wantReputation(t, s.get(fromDE, http.StatusOK, forward))
|
|
wantAbuseIPDBChecks(t, server, 1)
|
|
waitUntil(func() bool { return len(server.AbuseIPDB.Snapshot().Scores) == 1 })
|
|
wantReputation(t, s.get(fromDE, http.StatusOK, forward), abuseipdb)
|
|
|
|
s.get(fromKP, http.StatusOK, forward)
|
|
wantAbuseIPDBChecks(t, server, 1)
|
|
}
|
|
|
|
func TestIPv6ClientCostsOneAbuseIPDBCheckWhicheverOfItsAddressesSends(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
forward := requestlog.ActionForward
|
|
|
|
// 15 addresses of 2001:db8:1:2::/64, one client, each in a part of it
|
|
// of its own.
|
|
var addresses []string
|
|
for i := 1; i < 16; i++ {
|
|
addresses = append(addresses, fmt.Sprintf("2001:db8:1:2:%x::9", i<<12))
|
|
}
|
|
|
|
s, clk, server := startWithClock(t, "", map[string]string{
|
|
abuseIPDBKey: accountKey, reputationAction: actionLog,
|
|
rateLimitPerMinute: strconv.Itoa(len(addresses)),
|
|
})
|
|
|
|
// The client breaks the rate limit from its first address, which bans
|
|
// it for an hour.
|
|
for range addresses {
|
|
s.get(addresses[0], http.StatusOK, forward)
|
|
}
|
|
|
|
s.get(addresses[0], http.StatusForbidden, requestlog.ActionRateLimited)
|
|
clk.advance(time.Hour)
|
|
|
|
// Once the ban has ended, which set its counters back to zero, a
|
|
// request from each of its addresses has it checked once.
|
|
for _, address := range addresses {
|
|
s.get(address, http.StatusOK, forward)
|
|
}
|
|
|
|
wantAbuseIPDBChecks(t, server, 1)
|
|
}
|
|
|
|
// probePath is the path the ban rule of testRules, probe, matches.
|
|
const probePath = "/.env"
|
|
|
|
func TestClientARuleRefusedIsCheckedWithAbuseIPDBAtItsNextRequest(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
for _, tc := range []struct {
|
|
name string
|
|
// path is what the client asks for, status and action what that
|
|
// request is answered and logged with, and want the offences its
|
|
// history then counts.
|
|
path string
|
|
status int
|
|
action string
|
|
want ratelimit.Offences
|
|
}{
|
|
{
|
|
"a block rule", "/blocked", http.StatusForbidden, requestlog.ActionRuleBlocked,
|
|
ratelimit.Offences{RuleBlocked: 1},
|
|
},
|
|
{
|
|
"a ban rule", probePath, http.StatusForbidden, requestlog.ActionBanned,
|
|
ratelimit.Offences{Attack: 1},
|
|
},
|
|
} {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
s, clk, server := startWithClock(t, "", map[string]string{
|
|
abuseIPDBKey: accountKey, reputationAction: actionLog,
|
|
rulesDir: writeRules(t, testRules), attackBanDuration: "1h",
|
|
})
|
|
|
|
s.request(client, tc.path, tc.status, tc.action)
|
|
wantAbuseIPDBChecks(t, server, 0)
|
|
|
|
if got := historyOf(t, server, client).Offences; got != tc.want {
|
|
t.Errorf("history counts the offences %+v, want %+v", got, tc.want)
|
|
}
|
|
|
|
// Its next request, once a ban rule's ban has ended, has it
|
|
// checked.
|
|
clk.advance(time.Hour)
|
|
s.get(client, http.StatusOK, requestlog.ActionForward)
|
|
wantAbuseIPDBChecks(t, server, 1)
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestEachReputationActionForAClientAbuseIPDBScoresAtOrOverTheMinimum(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
forward, denied := requestlog.ActionForward, requestlog.ActionDenied
|
|
|
|
for _, tc := range []struct {
|
|
action string
|
|
// statuses and actions are those of fromDE'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.
|
|
limitHalf, []int{http.StatusOK, http.StatusOK, http.StatusForbidden},
|
|
[]string{forward, forward, requestlog.ActionRateLimited},
|
|
"50 from " + reputationAction,
|
|
},
|
|
{
|
|
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, abuseIPDBKey: accountKey,
|
|
reputationAction: tc.action,
|
|
})
|
|
// At SWWAF_ABUSEIPDB_MIN_SCORE, 75 by default, and just under it.
|
|
loadScores(server, map[string]int64{fromDE: 75, fromKP: 74})
|
|
|
|
for i := range 3 {
|
|
line := s.get(fromDE, tc.statuses[i], tc.actions[i])
|
|
wantReputation(t, line, abuseipdb)
|
|
wantPercent(t, "limit_percent", line.LimitPercent, line.LimitPercentSetting,
|
|
tc.percent)
|
|
|
|
// A request refused for the score is not counted.
|
|
counted := line.fields["counts"] != nil
|
|
if counted != (tc.actions[i] != denied) {
|
|
t.Errorf("request counted %t, logged %s", counted, tc.actions[i])
|
|
}
|
|
}
|
|
|
|
// fromKP's score is no hit, and it has the whole limit.
|
|
for range 3 {
|
|
line := s.get(fromKP, http.StatusOK, forward)
|
|
wantReputation(t, line)
|
|
wantPercent(t, "limit_percent", line.LimitPercent, line.LimitPercentSetting,
|
|
none)
|
|
}
|
|
|
|
// A refusal for the score makes no ban.
|
|
if held := server.Ledger.Snapshot(); tc.action == actionDeny && len(held) != 0 {
|
|
t.Errorf("bans %+v, want none", held)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestAbuseIPDBHitRaisesAnAlertWithTheScoreOncePerCooldownAndIsCounted(
|
|
t *testing.T,
|
|
) {
|
|
t.Parallel()
|
|
|
|
s, server, queue := startWithLookups(t, map[string]string{
|
|
abuseIPDBKey: accountKey, reputationAction: actionLog, metricsToken: token,
|
|
})
|
|
loadScores(server, map[string]int64{fromDE: 90})
|
|
|
|
// The second request's alert is a repeat, which the cooldown holds back.
|
|
for range 2 {
|
|
wantReputation(t, s.get(fromDE, http.StatusOK, requestlog.ActionForward),
|
|
abuseipdb)
|
|
}
|
|
|
|
// The alert is made as a DNSBL zone's is, with the score besides.
|
|
waiting := queue.Snapshot().Waiting[alerts.DestinationWebhook]
|
|
if len(waiting) != 1 || waiting[0].Event != alerts.EventReputationHit ||
|
|
waiting[0].Reason != "scored by AbuseIPDB at or over SWWAF_ABUSEIPDB_MIN_SCORE" ||
|
|
waiting[0].Detail["source"] != abuseipdb || waiting[0].Detail["score"] != int64(90) ||
|
|
queue.Suppressed() != 1 {
|
|
t.Errorf("alerts waiting %+v, %d held back, want AbuseIPDB's reputation_hit "+
|
|
"with the score 90, and 1", waiting, queue.Suppressed())
|
|
}
|
|
|
|
// The hits, and the checks, none, since no client committed an
|
|
// offence, so that the whole budget is left.
|
|
metrics := s.scrape(unplaced)
|
|
labels := `{instance="` + alertInstance + `",source="` + abuseipdb + `"}`
|
|
|
|
wantMetric(t, metrics, "smallwebwaf_reputation_hits_total"+labels, 2)
|
|
wantMetric(t, metrics, "smallwebwaf_reputation_queries_total"+labels, 0)
|
|
wantMetric(t, metrics, "smallwebwaf_reputation_failures_total"+labels, 0)
|
|
wantMetric(t, metrics, "smallwebwaf_reputation_daily_budget_remaining"+labels, 900)
|
|
}
|
|
|
|
func TestWithoutAnAbuseIPDBKeyNoClientIsCheckedNorAScoreUsed(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
forward := requestlog.ActionForward
|
|
|
|
s, clk, server, _ := startWithLookupsAndClock(t, map[string]string{
|
|
rateLimitPerMinute: "1", metricsToken: token,
|
|
})
|
|
loadScores(server, map[string]int64{fromDE: 100})
|
|
|
|
// fromDE's score is not used, and once it has committed an offence it
|
|
// is not checked either.
|
|
wantReputation(t, s.get(fromDE, http.StatusOK, forward))
|
|
s.get(fromDE, http.StatusForbidden, requestlog.ActionRateLimited)
|
|
clk.advance(time.Hour)
|
|
wantReputation(t, s.get(fromDE, http.StatusOK, forward))
|
|
wantAbuseIPDBChecks(t, server, 0)
|
|
|
|
wantNoSeries(t, s.scrape(unplaced), `smallwebwaf_reputation_daily_budget_remaining{`+
|
|
`instance="`+alertInstance+`",source="`+abuseipdb+`"}`)
|
|
}
|
|
|
|
// listsFetched is when loadLists has the copies fetched.
|
|
func listsFetched() time.Time {
|
|
return time.Date(2026, 10, 5, 0, 0, 0, 0, time.UTC)
|
|
}
|
|
|
|
// verdictsFetched is when loadVerdicts has the verdicts fetched: half a
|
|
// day before the time the tests' clock is set to, so that they are in use
|
|
// until half a day later.
|
|
func verdictsFetched() time.Time {
|
|
return time.Date(2026, 10, 5, 12, 0, 0, 0, time.UTC)
|
|
}
|
|
|
|
// loadVerdicts puts into server's DNSBL, for each client listedBy names,
|
|
// a verdict of each zone SWWAF_DNSBL_ZONES names, fetched at
|
|
// verdictsFetched, as reputation.json would at start: one that lists the
|
|
// client from each zone listedBy gives for it, and one that does not from
|
|
// each other zone.
|
|
func loadVerdicts(server *proxy.Server, listedBy map[string][]string) {
|
|
verdicts := make([]reputation.Verdict, 0, len(listedBy)*len(server.DNSBL.Zones()))
|
|
|
|
for client, zones := range listedBy {
|
|
for _, zone := range server.DNSBL.Zones() {
|
|
verdicts = append(verdicts, reputation.Verdict{
|
|
Zone: zone, Client: netip.MustParseAddr(client),
|
|
Listed: slices.Contains(zones, zone), Fetched: verdictsFetched(),
|
|
})
|
|
}
|
|
}
|
|
|
|
server.DNSBL.Load(verdicts)
|
|
}
|
|
|
|
// loadScores puts into server's AbuseIPDB the score scores gives each
|
|
// client, an IPv4 address, fetched at verdictsFetched, as reputation.json
|
|
// would at start.
|
|
func loadScores(server *proxy.Server, scores map[string]int64) {
|
|
kept := make([]reputation.Score, 0, len(scores))
|
|
for client, score := range scores {
|
|
kept = append(kept, reputation.Score{
|
|
Client: netip.MustParsePrefix(client + "/32"), Score: score,
|
|
Fetched: verdictsFetched(),
|
|
})
|
|
}
|
|
|
|
server.AbuseIPDB.Load(reputation.Checks{Scores: kept})
|
|
}
|
|
|
|
// wantAbuseIPDBChecks checks how many clients server has checked with
|
|
// AbuseIPDB.
|
|
func wantAbuseIPDBChecks(t *testing.T, server *proxy.Server, want int) {
|
|
t.Helper()
|
|
|
|
if got := server.AbuseIPDB.Checked(); got != want {
|
|
t.Errorf("%d clients checked with AbuseIPDB, want %d", got, want)
|
|
}
|
|
}
|
|
|
|
// 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)
|
|
}
|
|
}
|