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