check / check (push) Waiting to run
SWWAF_CROWDSEC_LAPI_URL and SWWAF_CROWDSEC_LAPI_KEY name an engine whose decision list, <url>/v1/decisions, is fetched every minute with the key in X-Api-Key and kept as a blocklist is: used while a fetch fails, and across restarts through reputation.json. Ban decisions on an Ip or a Range end at the fetch time plus their duration. A listed client's request is refused and bans its netblock with the cause crowdsec until the decision ends; bans.json, ban notes and metrics take the cause. Judgement call: fetched every minute, not a setting. Judgement call: a crowdsec ban never lengthens a limit ban. Judgement call: a lifted crowdsec ban is remade while its decision lasts. Model: opus-5-5
182 lines
6.6 KiB
Go
182 lines
6.6 KiB
Go
package proxy_test
|
|
|
|
import (
|
|
"net/http"
|
|
"net/netip"
|
|
"reflect"
|
|
"testing"
|
|
"time"
|
|
|
|
"sneak.berlin/go/smallwebwaf/internal/alerts"
|
|
"sneak.berlin/go/smallwebwaf/internal/bans"
|
|
"sneak.berlin/go/smallwebwaf/internal/proxy"
|
|
"sneak.berlin/go/smallwebwaf/internal/reputation"
|
|
"sneak.berlin/go/smallwebwaf/internal/requestlog"
|
|
)
|
|
|
|
// The CrowdSec settings, and the tests' engine, which is never asked: each
|
|
// test puts in the copy of its decision list, at decisionsURL, that it
|
|
// needs, as reputation.json would at start.
|
|
const (
|
|
crowdSecURL = "SWWAF_CROWDSEC_LAPI_URL"
|
|
crowdSecKey = "SWWAF_CROWDSEC_LAPI_KEY"
|
|
lapi = "http://crowdsec.example:8080"
|
|
decisionsURL = lapi + "/v1/decisions"
|
|
bouncerKey = "crowdsec-key-0123456789abcdef"
|
|
)
|
|
|
|
func TestClientTheCrowdSecDecisionListListsIsBannedUntilTheDecisionEnds(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
s, clk, server, queue := startWithAlerts(t, map[string]string{
|
|
crowdSecURL: lapi, crowdSecKey: bouncerKey, metricsToken: token,
|
|
})
|
|
// client had four hours left on its decision as the engine answered.
|
|
fetched := clk.Now()
|
|
loadDecisions(t, server, fetched, `[{"duration": "4h0m0s", `+
|
|
`"scenario": "crowdsecurity/ssh-bf", "scope": "Ip", "type": "ban", `+
|
|
`"value": "`+client+`"}]`)
|
|
expires := requestlog.FormatTime(fetched.Add(4 * time.Hour))
|
|
|
|
// Its first request is refused, and bans it until the decision ends.
|
|
line := s.get(client, http.StatusForbidden, requestlog.ActionBanned)
|
|
wantReputation(t, line, decisionsURL)
|
|
|
|
if line.BanExpires != expires {
|
|
t.Errorf("log line has ban_expires %q, want %s", line.BanExpires, expires)
|
|
}
|
|
|
|
listed := []bans.ReputationHit{{Source: decisionsURL}}
|
|
|
|
held := server.Ledger.Bans(netip.MustParsePrefix(client + "/32"))
|
|
if len(held) != 1 || held[0].Cause != bans.CauseCrowdSec ||
|
|
!held[0].Start.Equal(fetched) || !held[0].Expires.Equal(fetched.Add(4*time.Hour)) ||
|
|
held[0].Reason != "CrowdSec's decision for crowdsecurity/ssh-bf" ||
|
|
!reflect.DeepEqual(held[0].Notes.Reputation, listed) ||
|
|
held[0].Notes.Request.Path != "/" || held[0].Notes.Requests != 1 {
|
|
t.Fatalf("bans %+v, want one for crowdsec of four hours, with the list and "+
|
|
"the request in its notes", held)
|
|
}
|
|
|
|
// The listing raises a reputation_hit alert, and the ban its own.
|
|
waiting := queue.Snapshot().Waiting[alerts.DestinationWebhook]
|
|
if len(waiting) != 2 || waiting[0].Event != alerts.EventReputationHit ||
|
|
waiting[0].Reason != "listed by the CrowdSec decision list" ||
|
|
!reflect.DeepEqual(waiting[1], banAlert(alerts.EventBan, fetched, client, held[0],
|
|
expires)) {
|
|
t.Errorf("alerts waiting %+v, want a reputation_hit alert, then the ban's",
|
|
waiting)
|
|
}
|
|
|
|
// Each request while the ban lasts is refused under it, as under any
|
|
// ban, and once it has ended the client is let through.
|
|
clk.advance(4*time.Hour - time.Second)
|
|
|
|
line = s.get(client, http.StatusForbidden, requestlog.ActionBanned)
|
|
wantReputation(t, line)
|
|
|
|
if line.BanExpires != expires {
|
|
t.Errorf("log line has ban_expires %q, want %s", line.BanExpires, expires)
|
|
}
|
|
|
|
clk.advance(time.Second)
|
|
s.get(client, http.StatusOK, requestlog.ActionForward)
|
|
|
|
// The ban and the hit are counted, and the list has the metrics of any
|
|
// list fetched from a URL.
|
|
metrics := s.scrape(unplaced)
|
|
labels := `{instance="` + alertInstance + `",source="` + decisionsURL + `"}`
|
|
|
|
wantMetric(t, metrics, `smallwebwaf_bans_made_total{cause="crowdsec",`+
|
|
`instance="`+alertInstance+`"}`, 1)
|
|
wantMetric(t, metrics, "smallwebwaf_reputation_hits_total"+labels, 1)
|
|
wantMetric(t, metrics, "smallwebwaf_reputation_failures_total"+labels, 0)
|
|
wantMetric(t, metrics, "smallwebwaf_reputation_last_fetch_timestamp_seconds"+labels,
|
|
float64(fetched.Unix()))
|
|
}
|
|
|
|
func TestEndedCrowdSecDecisionNoLongerBansThoughTheCopyStillHoldsIt(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
s, clk, server := startWithClock(t, "", map[string]string{
|
|
crowdSecURL: lapi, crowdSecKey: bouncerKey,
|
|
})
|
|
// 198.51.100.0/24 and 2001:db8::9 had a minute left as the engine
|
|
// answered.
|
|
fetched := clk.Now()
|
|
loadDecisions(t, server, fetched, `[{"duration": "1m0s", `+
|
|
`"scenario": "crowdsecurity/http-probing", "scope": "Range", "type": "ban", `+
|
|
`"value": "198.51.100.0/24"}, {"duration": "1m0s", `+
|
|
`"scenario": "crowdsecurity/http-probing", "scope": "Ip", "type": "ban", `+
|
|
`"value": "2001:db8::9"}]`)
|
|
|
|
// Just before its end, the decision bans a client in the netblock, and
|
|
// one on an IPv6 address bans the address's group, the /64.
|
|
clk.advance(time.Minute - time.Nanosecond)
|
|
s.get("198.51.100.7", http.StatusForbidden, requestlog.ActionBanned)
|
|
s.get("2001:db8::9", http.StatusForbidden, requestlog.ActionBanned)
|
|
s.get("2001:db8::5", http.StatusForbidden, requestlog.ActionBanned)
|
|
|
|
// Once it has ended, it bans no other client, and the bans it made end
|
|
// with it.
|
|
clk.advance(time.Nanosecond)
|
|
|
|
for _, from := range []string{"198.51.100.8", "198.51.100.7", "2001:db8::5"} {
|
|
wantReputation(t, s.get(from, http.StatusOK, requestlog.ActionForward))
|
|
}
|
|
|
|
if made := server.Ledger.Made(bans.CauseCrowdSec); made != 2 {
|
|
t.Errorf("%d bans made for crowdsec, want 2, on 198.51.100.7/32 and "+
|
|
"2001:db8::/64", made)
|
|
}
|
|
|
|
if held := server.Ledger.Bans(netip.MustParsePrefix("2001:db8::/64")); len(held) != 1 {
|
|
t.Errorf("bans of 2001:db8::/64 %+v, want one", held)
|
|
}
|
|
}
|
|
|
|
func TestObserveModeForwardsAClientTheCrowdSecDecisionListListsAndAlertsTheBan(
|
|
t *testing.T,
|
|
) {
|
|
t.Parallel()
|
|
|
|
s, clk, server, queue := startWithAlerts(t, map[string]string{
|
|
crowdSecURL: lapi, crowdSecKey: bouncerKey, mode: observe,
|
|
})
|
|
loadDecisions(t, server, clk.Now(), `[{"duration": "4h0m0s", `+
|
|
`"scenario": "crowdsecurity/ssh-bf", "scope": "Ip", "type": "ban", `+
|
|
`"value": "`+client+`"}]`)
|
|
|
|
line := s.get(client, http.StatusOK, requestlog.ActionForward)
|
|
wantWouldAction(t, line, requestlog.ActionBanned)
|
|
wantReputation(t, line, decisionsURL)
|
|
|
|
if held := server.Ledger.Snapshot(); len(held) != 0 {
|
|
t.Errorf("bans %+v, want none", held)
|
|
}
|
|
|
|
waiting := queue.Snapshot().Waiting[alerts.DestinationWebhook]
|
|
if len(waiting) != 2 || waiting[1].Event != alerts.EventBan ||
|
|
waiting[1].Detail["cause"] != bans.CauseCrowdSec ||
|
|
waiting[1].Detail["mode"] != observe {
|
|
t.Errorf("alerts waiting %+v, want a reputation_hit alert, then the ban alert "+
|
|
"marked observe", waiting)
|
|
}
|
|
}
|
|
|
|
// loadDecisions puts into server's lists the copy of the decision list at
|
|
// decisionsURL, answer, the engine's answer, fetched at fetched, as
|
|
// reputation.json would at start.
|
|
func loadDecisions(
|
|
t *testing.T, server *proxy.Server, fetched time.Time, answer string,
|
|
) {
|
|
t.Helper()
|
|
|
|
err := server.Lists.Load([]reputation.List{{
|
|
URL: decisionsURL, Tried: fetched, Fetched: fetched, Lines: []string{answer},
|
|
}})
|
|
if err != nil {
|
|
t.Fatalf("load the decision list: %v", err)
|
|
}
|
|
}
|