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) } }