check / check (push) Waiting to run
The package passes nothing to an app since rproxy took that over, so it is named after the command it serves. The rproxy tests call their stand-in for servewaf standIn, since a local servewaf would hide the package. No change of behaviour. Model: opus-5-5
182 lines
6.6 KiB
Go
182 lines
6.6 KiB
Go
package servewaf_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/reputation"
|
|
"sneak.berlin/go/smallwebwaf/internal/requestlog"
|
|
"sneak.berlin/go/smallwebwaf/internal/servewaf"
|
|
)
|
|
|
|
// 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()
|
|
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 *servewaf.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)
|
|
}
|
|
}
|