package smallwebwaf_test import ( "bytes" "context" "encoding/json" "io" "net" "net/http" "net/http/httptest" "os" "path/filepath" "slices" "strconv" "strings" "sync" "sync/atomic" "testing" "time" "sneak.berlin/go/smallwebwaf/internal/smallwebwaf" ) const ( // waitLimit bounds how long a test waits for what should happen. waitLimit = 10 * time.Second // pollInterval is how often a test looks for a line. pollInterval = 10 * time.Millisecond // testVersion is the version the tests give smallwebwaf. testVersion = "test" // localhost is where the tests listen. localhost = "127.0.0.1" listenAddr = "SWWAF_LISTEN_ADDR" upstreamURL = "SWWAF_UPSTREAM_URL" trustedProxies = "SWWAF_TRUSTED_PROXIES" stateDir = "SWWAF_STATE_DIR" stateWriteDelay = "SWWAF_STATE_WRITE_DELAY" stateCounterInterval = "SWWAF_STATE_COUNTER_INTERVAL" rateLimitPerDay = "SWWAF_RATE_LIMIT_PER_DAY" rulesDir = "SWWAF_RULES_DIR" adminToken = "SWWAF_ADMIN_TOKEN" //nolint:gosec // the setting's name metricsToken = "SWWAF_METRICS_TOKEN" //nolint:gosec // the setting's name // adminSecret is the SWWAF_ADMIN_TOKEN the tests set. adminSecret = "fedcba9876543210fedcba9876543210" // greeting is what the tests' app answers. greeting = "hello from the app" ) // output collects what smallwebwaf writes on stdout. type output struct { mu sync.Mutex buf bytes.Buffer } // Write adds lines smallwebwaf writes. func (o *output) Write(p []byte) (int, error) { o.mu.Lock() defer o.mu.Unlock() return o.buf.Write(p) } // line returns the first line whose field key is value, waiting for it. func (o *output) line(t *testing.T, key, value string) map[string]any { t.Helper() deadline := time.Now().Add(waitLimit) for time.Now().Before(deadline) { o.mu.Lock() text := o.buf.String() o.mu.Unlock() for line := range strings.Lines(text) { var fields map[string]any err := json.Unmarshal([]byte(line), &fields) if err != nil { t.Fatalf("output line %q is not JSON: %v", line, err) } if fields[key] == value { return fields } } time.Sleep(pollInterval) } t.Fatalf("no line with %s %q in the output:\n%s", key, value, o.text()) return nil } // text returns everything written so far. func (o *output) text() string { o.mu.Lock() defer o.mu.Unlock() return o.buf.String() } // run runs smallwebwaf with the settings in env until ctx is done, and // returns its exit status. func run(ctx context.Context, env map[string]string, out *output) int { return smallwebwaf.Run(ctx, smallwebwaf.Params{ Version: testVersion, LookupEnv: func(name string) (string, bool) { value, ok := env[name] return value, ok }, Stdout: out, }) } func TestInvalidSettingStopsTheStart(t *testing.T) { t.Parallel() out := &output{} status := run(t.Context(), map[string]string{"SWWAF_REQUEST_MAX_BYTES": "lots"}, out) if status != 1 { t.Errorf("exit status %d, want 1", status) } line := out.line(t, "msg", "invalid setting") message, _ := line["error"].(string) if line["type"] != "process" || line["level"] != "ERROR" || !strings.HasPrefix(message, "SWWAF_REQUEST_MAX_BYTES: ") { t.Errorf("start refused with %v", line) } } func TestShortTokenStopsTheStartUnshown(t *testing.T) { t.Parallel() const token = "a-token-of-31-characters-at-all" //nolint:gosec // too short to use for _, name := range []string{adminToken, metricsToken} { t.Run(name, func(t *testing.T) { t.Parallel() out := &output{} status := run(t.Context(), map[string]string{name: token}, out) if status != 1 { t.Errorf("exit status %d, want 1", status) } line := out.line(t, "msg", "invalid setting") if line["error"] != name+": is shorter than 32 characters" { t.Errorf("start refused with %v", line) } if strings.Contains(out.text(), token) { t.Errorf("the output shows the token:\n%s", out.text()) } }) } } func TestAddressInUseStopsTheStart(t *testing.T) { t.Parallel() taken, err := (&net.ListenConfig{}).Listen(t.Context(), "tcp", localhost+":0") if err != nil { t.Fatalf("listen: %v", err) } defer func() { _ = taken.Close() }() out := &output{} status := run(t.Context(), map[string]string{ listenAddr: taken.Addr().String(), stateDir: t.TempDir(), rulesDir: t.TempDir(), }, out) if status != 1 { t.Errorf("exit status %d, want 1", status) } out.line(t, "msg", "cannot listen on SWWAF_LISTEN_ADDR") } func TestServesUntilToldToStop(t *testing.T) { t.Parallel() appURL := startApp(t) dir := t.TempDir() ctx, stop := context.WithCancel(t.Context()) out := &output{} exited := make(chan int, 1) go func() { exited <- run(ctx, map[string]string{ listenAddr: localhost + ":0", upstreamURL: appURL, stateDir: dir, rulesDir: filepath.Join("..", "..", "share", "rules.d"), }, out) }() // The default rule file is read. if rules := out.line(t, "msg", "read the rule files")["rules"]; rules != 12.0 { t.Errorf("read %v rules from the default rule file, want 12", rules) } starting := out.line(t, "msg", "starting") wantStartingLine(t, starting, appURL, dir) addr, _ := starting["address"].(string) wantGreeting(t, "http://"+addr+"/") out.line(t, "type", "request") stop() select { case status := <-exited: if status != 0 { t.Errorf("exit status %d, want 0", status) } case <-time.After(waitLimit): t.Fatal("still running after being told to stop") } out.line(t, "msg", "stopped") } func TestStateKeptAcrossRestarts(t *testing.T) { t.Parallel() env := map[string]string{ listenAddr: localhost + ":0", upstreamURL: startApp(t), stateDir: t.TempDir(), rulesDir: t.TempDir(), rateLimitPerDay: "2", // Neither comes due in the test: the files are written as // smallwebwaf stops. stateWriteDelay: "1h", stateCounterInterval: "1h", } // The two requests a day allows, and a stop. runUntilStopped(t, env, func(url string) { wantGreeting(t, url) wantGreeting(t, url) }) // After a restart the client has no fresh allowance: its third // request breaks the day limit, and bans it. out := runUntilStopped(t, env, func(url string) { wantRefused(t, url) }) out.line(t, "action", "rate_limited") // After another, the ban still refuses it. out = runUntilStopped(t, env, func(url string) { wantRefused(t, url) }) out.line(t, "action", "banned") } func TestBanRefusesItsNetblockAfterARestartWithAnotherScope(t *testing.T) { t.Parallel() const scope = "SWWAF_BAN_SCOPE_V4_PREFIX" env := map[string]string{ listenAddr: localhost + ":0", upstreamURL: startApp(t), stateDir: t.TempDir(), rulesDir: t.TempDir(), trustedProxies: localhost + "/32", rateLimitPerDay: "1", scope: "24", } // 203.0.113.9's second request breaks the day limit, and bans // 203.0.113.0/24. runUntilStopped(t, env, func(url string) { wantStatus(t, url, "203.0.113.9", http.StatusOK) wantStatus(t, url, "203.0.113.9", http.StatusForbidden) }) // With each address a netblock of its own after a restart, that ban // still refuses all of 203.0.113.0/24. 198.51.100.7 is banned alone. env[scope] = "32" runUntilStopped(t, env, func(url string) { wantStatus(t, url, "203.0.113.200", http.StatusForbidden) wantStatus(t, url, "203.0.114.1", http.StatusOK) wantStatus(t, url, "198.51.100.7", http.StatusOK) wantStatus(t, url, "198.51.100.7", http.StatusForbidden) }) // With /24 netblocks again, that ban still refuses 198.51.100.7, and // no other address. env[scope] = "24" runUntilStopped(t, env, func(url string) { wantStatus(t, url, "198.51.100.7", http.StatusForbidden) wantStatus(t, url, "198.51.100.8", http.StatusOK) }) } func TestBanAddedAndLiftedByEditingBansJSON(t *testing.T) { t.Parallel() const ( // bans.json as an admin writes it with a ban, permanent, on // 203.0.113.0/24, and with none. oneBan = `{"version": 1, "bans": [{"netblock": "203.0.113.0/24", ` + `"start": "2026-10-06T00:00:00Z", "expires": null}]}` noBan = `{"version": 1, "bans": []}` ) dir := t.TempDir() env := map[string]string{ listenAddr: localhost + ":0", upstreamURL: startApp(t), stateDir: dir, rulesDir: t.TempDir(), trustedProxies: localhost + "/32", // No write comes due in the test, so only the watch on the // directory can take the edits in. stateWriteDelay: "1h", stateCounterInterval: "1h", } runUntilStopped(t, env, func(url string) { path := filepath.Join(dir, "bans.json") 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) }) } func TestBanAddedAndLiftedThroughTheEndpointsKeptInBansJSON(t *testing.T) { t.Parallel() dir := t.TempDir() env := map[string]string{ listenAddr: localhost + ":0", upstreamURL: startApp(t), stateDir: dir, rulesDir: t.TempDir(), trustedProxies: localhost + "/32", adminToken: adminSecret, // Neither comes due in the test: the files are written as // smallwebwaf stops. stateWriteDelay: "1h", stateCounterInterval: "1h", } runUntilStopped(t, env, func(url string) { askAsAdmin(t, http.MethodPost, url+"_smallwebwaf/bans", `{"netblock": "203.0.113.0/24", "duration": "permanent", `+ `"reason": "probes for logins"}`) wantStatus(t, url, "203.0.113.9", http.StatusForbidden) }) ban := onlyBan(t, dir) if ban["netblock"] != "203.0.113.0/24" || ban["cause"] != "admin" || ban["reason"] != "probes for logins" || ban["expires"] != nil || ban["lifted"] != nil { t.Errorf("bans.json holds %v, want the admin's permanent ban", ban) } // After a restart the ban still refuses; once lifted, it refuses no // more, and bans.json keeps it, marked lifted. runUntilStopped(t, env, func(url string) { wantStatus(t, url, "203.0.113.9", http.StatusForbidden) askAsAdmin(t, http.MethodDelete, url+"_smallwebwaf/bans/203.0.113.9", "") wantStatus(t, url, "203.0.113.9", http.StatusOK) }) ban = onlyBan(t, dir) if ban["netblock"] != "203.0.113.0/24" || ban["lifted"] == nil { t.Errorf("bans.json holds %v, want the admin's ban, lifted", ban) } } func TestRuleFileAddedWhileRunningTakesEffect(t *testing.T) { t.Parallel() dir := t.TempDir() env := map[string]string{ listenAddr: localhost + ":0", upstreamURL: startApp(t), stateDir: t.TempDir(), rulesDir: dir, // The requests sent until the rule takes effect must not break a // rate limit, whose ban would refuse them too. "SWWAF_RATE_LIMIT_PER_MINUTE": "off", } out := runUntilStopped(t, env, func(url string) { wantGreeting(t, url) // Written once: each change would start the rule files' wait // again. A file written before smallwebwaf watches the directory is // read once it does. err := os.WriteFile(filepath.Join(dir, "50-app.rules"), []byte("everything path block ^/\n"), 0o600) if err != nil { t.Fatalf("write the rule file: %v", err) } // As long as that takes, so that a slow test process cannot fail // the test. for statusFrom(t, url, "203.0.113.9") != http.StatusForbidden { time.Sleep(pollInterval) } }) out.line(t, "action", "rule_blocked") } func TestRuleFileThatDoesNotParseStopsTheStart(t *testing.T) { t.Parallel() dir := t.TempDir() path := filepath.Join(dir, "00-default.rules") 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`) } func TestRulesDirThatDoesNotExistStopsTheStart(t *testing.T) { t.Parallel() dir := filepath.Join(t.TempDir(), "rules.d") wantRulesRefused(t, dir, "SWWAF_RULES_DIR cannot be read: open "+dir+ ": no such file or directory") } func TestEveryLineIsAlsoSentToTheRemoteLogEndpoint(t *testing.T) { t.Parallel() endpoint, err := (&net.ListenConfig{}).Listen(t.Context(), "tcp", localhost+":0") if err != nil { t.Fatalf("listen: %v", err) } defer func() { _ = endpoint.Close() }() env := map[string]string{ listenAddr: localhost + ":0", upstreamURL: startApp(t), stateDir: t.TempDir(), rulesDir: t.TempDir(), "SWWAF_LOG_REMOTE_URL": "syslog+tcp://" + endpoint.Addr().String(), } out := runUntilStopped(t, env, func(url string) { wantGreeting(t, url) }) out.line(t, "type", "request") // smallwebwaf connected as it started, and closes the connection once // it has sent the lines written as it stopped. conn, err := endpoint.Accept() if err != nil { t.Fatalf("accept: %v", err) } received, err := io.ReadAll(conn) _ = conn.Close() if err != nil { t.Fatalf("read: %v", err) } // Lines written at once by several goroutines may reach stdout and // the endpoint in different orders. sent := messages(t, string(received)) written := slices.Collect(strings.Lines(out.text())) slices.Sort(sent) slices.Sort(written) if !slices.Equal(sent, written) { t.Errorf("sent\n%v\nwrote\n%v", sent, written) } } func TestStalledRemoteLogEndpointHoldsUpNoRequest(t *testing.T) { t.Parallel() const token = "0123456789abcdef0123456789abcdef" // 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") if err != nil { t.Fatalf("listen: %v", err) } defer func() { _ = endpoint.Close() }() env := map[string]string{ listenAddr: localhost + ":0", upstreamURL: startApp(t), stateDir: t.TempDir(), rulesDir: t.TempDir(), "SWWAF_LOG_REMOTE_URL": "syslog+tls://" + endpoint.Addr().String(), "SWWAF_LOG_REMOTE_BUFFER": "1", metricsToken: token, } out := runUntilStopped(t, env, func(url string) { wantGreeting(t, url) // More than one line has been written, and the buffer holds the // last. metrics := metricsText(t, url+"_smallwebwaf/metrics", token) for _, series := range []string{ "smallwebwaf_remote_log_lines_sent_total 0", "smallwebwaf_remote_log_buffer_depth 1", } { if !strings.Contains(metrics, "\n"+series+"\n") { t.Errorf("no %q in the metrics:\n%s", series, metrics) } } if strings.Contains(metrics, "\nsmallwebwaf_remote_log_lines_dropped_total 0\n") || !strings.Contains(metrics, "\nsmallwebwaf_remote_log_lines_dropped_total ") { t.Errorf("no line dropped in the metrics:\n%s", metrics) } // Closed, the endpoint refuses the connection made to send the // lines still waiting at the stop, which then does not wait. _ = endpoint.Close() }) out.line(t, "type", "request") } func TestBanIsAlertedAndAnAlertNotSentIsKeptAcrossARestart(t *testing.T) { t.Parallel() webhook := startWebhook(t) rules := t.TempDir() err := os.WriteFile(filepath.Join(rules, "50-app.rules"), []byte(`probe path ban ^/\.env$`+"\n"), 0o600) if err != nil { t.Fatalf("write the rule file: %v", err) } dir := t.TempDir() env := map[string]string{ listenAddr: localhost + ":0", upstreamURL: startApp(t), stateDir: dir, rulesDir: rules, "SWWAF_ALERT_WEBHOOK_URL": webhook.url, "SWWAF_ALERT_WEBHOOK_HEADERS": "Authorization:Bearer " + adminSecret, } runUntilStopped(t, env, func(url string) { // The probe bans the client, and the webhook is sent the alert. wantRefused(t, url+".env") post := webhook.waitFor(t, "ban", true) 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) } // The webhook fails, so the alert for the ban made permanent by the // client's next request waits. webhook.failing.Store(true) wantRefused(t, url) webhook.waitFor(t, "permanent_ban", false) }) // alerts.json keeps it as smallwebwaf stops, and once started again, // smallwebwaf 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) } // It counts the alert sent in the metrics, read here from a client the // ban does not cover. const token = "0123456789abcdef0123456789abcdef" webhook.failing.Store(false) env["SWWAF_ALLOW_NETS"] = localhost env[metricsToken] = token runUntilStopped(t, env, func(url string) { webhook.waitFor(t, "permanent_ban", true) // As long as that takes, so that a slow test process cannot fail // the test. const sent = "\nsmallwebwaf_alerts_sent_total{destination=\"webhook\"} 1\n" metrics := metricsText(t, url+"_smallwebwaf/metrics", token) for !strings.Contains(metrics, sent) { time.Sleep(pollInterval) metrics = metricsText(t, url+"_smallwebwaf/metrics", token) } for _, series := range []string{"failed", "suppressed", "dropped"} { zero := "\nsmallwebwaf_alerts_" + series + "_total{destination=\"webhook\"} 0\n" if !strings.Contains(metrics, zero) { t.Errorf("no %q in the metrics:\n%s", zero, metrics) } } }) } func TestBanIsAlertedToSlackAndNtfy(t *testing.T) { t.Parallel() const ( ntfyToken = "tk_0123456789abcdefghijklmnopq" token = "abcdef0123456789abcdef0123456789" client = "203.0.113.9" ) slack, ntfy := startDestination(t), startDestination(t) env := map[string]string{ listenAddr: localhost + ":0", upstreamURL: startApp(t), stateDir: t.TempDir(), rulesDir: t.TempDir(), trustedProxies: localhost + "/32", rateLimitPerDay: "1", // The metrics are read from 127.0.0.1, which no limit counts. "SWWAF_ALLOW_NETS": localhost + "/32", metricsToken: token, "SWWAF_INSTANCE_NAME": "fsn1app1/gitea", "SWWAF_ALERT_SLACK_WEBHOOK_URL": slack.url, "SWWAF_ALERT_NTFY_URL": ntfy.url, "SWWAF_ALERT_NTFY_TOKEN": ntfyToken, } runUntilStopped(t, env, func(url string) { // The client's second request breaks the day limit, and bans it; // Slack and ntfy are each sent the alert. wantStatus(t, url, client, http.StatusOK) wantStatus(t, url, client, http.StatusForbidden) var message struct { Text string `json:"text"` } slackPost := slack.firstPost(t) err := json.Unmarshal([]byte(slackPost.body), &message) if err != nil || !strings.HasPrefix(message.Text, "*fsn1app1/gitea: ban*\n") || !strings.Contains(message.Text, "\nclient: "+client+"\n") { t.Errorf("Slack was sent %s", slackPost.body) } ntfyPost := ntfy.firstPost(t) if ntfyPost.header.Get("Title") != "fsn1app1/gitea: ban" || ntfyPost.header.Get("Authorization") != "Bearer "+ntfyToken || !strings.Contains(ntfyPost.body, "\nclient: "+client+"\n") { t.Errorf("ntfy was sent %s, with the headers %v", ntfyPost.body, ntfyPost.header) } // The metrics count it for each, and give no series for the // webhook, which is not set. As long as that takes, so that a slow // test process cannot fail the test. sent := []string{ "\nsmallwebwaf_alerts_sent_total{destination=\"slack\"} 1\n", "\nsmallwebwaf_alerts_sent_total{destination=\"ntfy\"} 1\n", } metrics := metricsText(t, url+"_smallwebwaf/metrics", token) for !strings.Contains(metrics, sent[0]) || !strings.Contains(metrics, sent[1]) { time.Sleep(pollInterval) metrics = metricsText(t, url+"_smallwebwaf/metrics", token) } if strings.Contains(metrics, `destination="webhook"`) { t.Errorf("the metrics give the webhook:\n%s", metrics) } }) } func TestStateFileThatDoesNotParseStopsTheStart(t *testing.T) { t.Parallel() dir := t.TempDir() 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: ") } func TestUnwritableStateDirStopsTheStart(t *testing.T) { t.Parallel() wantStartRefused(t, filepath.Join(t.TempDir(), "missing"), "SWWAF_STATE_DIR cannot be written: ") } // wantStartRefused runs smallwebwaf with its state files in dir, and // checks that it stops at start, with an error that starts with want. If // it starts instead, it is stopped after waitLimit. func wantStartRefused(t *testing.T, dir, want string) { t.Helper() ctx, stop := context.WithTimeout(t.Context(), waitLimit) defer stop() out := &output{} status := run(ctx, map[string]string{ listenAddr: localhost + ":0", stateDir: dir, rulesDir: t.TempDir(), }, out) if status != 1 { t.Fatalf("exit status %d, want 1", status) } line := out.line(t, "msg", "cannot use the state files") message, _ := line["error"].(string) if !strings.HasPrefix(message, want) { t.Errorf("start refused with %q, want an error starting %q", message, want) } } // wantRulesRefused runs smallwebwaf with its rule files in dir, and // checks that it stops at start, with the error want. If it starts // instead, it is stopped after waitLimit. func wantRulesRefused(t *testing.T, dir, want string) { t.Helper() ctx, stop := context.WithTimeout(t.Context(), waitLimit) defer stop() out := &output{} status := run(ctx, map[string]string{ listenAddr: localhost + ":0", stateDir: t.TempDir(), rulesDir: dir, }, out) if status != 1 { t.Fatalf("exit status %d, want 1", status) } line := out.line(t, "msg", "cannot use the rule files") if line["error"] != want || line["level"] != "ERROR" { t.Errorf("start refused with %v, want the error %q", line, want) } } // startApp starts an app that answers every request with greeting, and // returns its URL. func startApp(t *testing.T) string { t.Helper() app := httptest.NewServer(http.HandlerFunc( func(w http.ResponseWriter, _ *http.Request) { _, _ = io.WriteString(w, greeting) })) t.Cleanup(app.Close) return app.URL } // runUntilStopped runs smallwebwaf with the settings in env, has use send // it requests at url, then stops it as SIGTERM does, checks that it // stopped in order, and returns its output. func runUntilStopped( t *testing.T, env map[string]string, use func(url string), ) *output { t.Helper() ctx, stop := context.WithCancel(t.Context()) out := &output{} exited := make(chan int, 1) go func() { exited <- run(ctx, env, out) }() addr, _ := out.line(t, "msg", "starting")["address"].(string) use("http://" + addr + "/") stop() select { case status := <-exited: if status != 0 { 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") } return out } // wantStartingLine checks that the line at start gives the version and // every setting's value. func wantStartingLine(t *testing.T, line map[string]any, appURL, dir string) { t.Helper() settings, _ := line["settings"].(map[string]any) want := map[string]any{ listenAddr: localhost + ":0", upstreamURL: appURL, stateDir: dir, "SWWAF_MODE": "enforce", stateWriteDelay: "10s", stateCounterInterval: "15m", trustedProxies: "10.0.0.0/8,172.16.0.0/12,192.168.0.0/16", "SWWAF_CLIENT_REQUEST_TIMEOUT": "60s", "SWWAF_CLIENT_REQUEST_HEADER_MAX_BYTES": "32K", "SWWAF_CLIENT_IDLE_TIMEOUT": "120s", "SWWAF_CLIENT_RESPONSE_TIMEOUT": "30m", "SWWAF_UPSTREAM_REQUEST_TIMEOUT": "60s", "SWWAF_UPSTREAM_RESPONSE_TIMEOUT": "30m", "SWWAF_REQUEST_MAX_BYTES": "100M", "SWWAF_RESPONSE_MAX_BYTES": "5G", "SWWAF_ALLOW_NETS": "", "SWWAF_RATE_LIMIT_EXEMPT_NETS": "", "SWWAF_DENY_NETS": "", "SWWAF_RATE_LIMIT_PER_MINUTE": "1000", "SWWAF_RATE_LIMIT_PER_HOUR": "10000", rateLimitPerDay: "50000", "SWWAF_RATE_LIMIT_EXEMPT_PATHS": "", "SWWAF_DENIED_COUNTRIES": "", "SWWAF_EXCLUSIVELY_ALLOWED_COUNTRIES": "", "SWWAF_BAN_RESPONSE": "403", "SWWAF_LIMIT_BAN_DURATION": "1h", "SWWAF_LIMIT_BAN_REPEAT_WINDOW": "24h", "SWWAF_MAX_BAN_DURATION": "7d", "SWWAF_ATTACK_BAN_DURATION": "7d", "SWWAF_MAX_BANS": "5000", "SWWAF_BAN_SCOPE_V4_PREFIX": "32", "SWWAF_RULES_ENABLED": "true", } for name, value := range want { if settings[name] != value { t.Errorf("starting line gives %s=%v, want %v", name, settings[name], value) } } if line["version"] != testVersion || line["type"] != "process" { t.Errorf("starting line %v", line) } } // wantGreeting checks that a request to url gets the app's answer. func wantGreeting(t *testing.T, url string) { t.Helper() req, err := http.NewRequestWithContext(t.Context(), http.MethodGet, url, http.NoBody) if err != nil { t.Fatalf("new request: %v", err) } transport := &http.Transport{} defer transport.CloseIdleConnections() res, err := (&http.Client{Transport: transport}).Do(req) if err != nil { t.Fatalf("request: %v", err) } body, err := io.ReadAll(res.Body) _ = res.Body.Close() if err != nil || string(body) != greeting { t.Errorf("got %q (%v), want the app's answer", body, err) } } // messages returns the message of each record in received, octet-counted // frames of RFC 5424 records with the default facility and app name, each // with the newline that ends a line on stdout. func messages(t *testing.T, received string) []string { t.Helper() hostname, _ := os.Hostname() header := " " + hostname + " " + hostname + " - - - " var found []string for received != "" { count, rest, _ := strings.Cut(received, " ") length, err := strconv.Atoi(count) if err != nil || length > len(rest) { t.Fatalf("no frame at %q", received) } record := rest[:length] received = rest[length:] _, 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) } found = append(found, message+"\n") } return found } // metricsText asks for the metrics at url with token, and returns them. func metricsText(t *testing.T, url, token string) string { t.Helper() req, err := http.NewRequestWithContext(t.Context(), http.MethodGet, url, http.NoBody) if err != nil { t.Fatalf("new request: %v", err) } req.Header.Set("Authorization", "Bearer "+token) transport := &http.Transport{} defer transport.CloseIdleConnections() res, err := (&http.Client{Transport: transport}).Do(req) if err != nil { t.Fatalf("request: %v", err) } body, err := io.ReadAll(res.Body) _ = res.Body.Close() if err != nil || res.StatusCode != http.StatusOK { t.Fatalf("metrics answered %d (%v)", res.StatusCode, err) } return string(body) } // askAsAdmin sends a request with method to url, with body and // adminSecret, and checks that it is answered 200. func askAsAdmin(t *testing.T, method, url, body string) { t.Helper() req, err := http.NewRequestWithContext(t.Context(), method, url, strings.NewReader(body)) if err != nil { t.Fatalf("new request: %v", err) } req.Header.Set("Authorization", "Bearer "+adminSecret) transport := &http.Transport{} defer transport.CloseIdleConnections() res, err := (&http.Client{Transport: transport}).Do(req) if err != nil { t.Fatalf("request: %v", err) } _ = res.Body.Close() if res.StatusCode != http.StatusOK { t.Fatalf("%s %s answered %d", method, url, res.StatusCode) } } // onlyBan returns the one ban bans.json in dir holds. func onlyBan(t *testing.T, dir string) map[string]any { t.Helper() path := filepath.Join(dir, "bans.json") data, err := os.ReadFile(path) //nolint:gosec // a file in the test's directory if err != nil { t.Fatalf("read bans.json: %v", err) } var file struct { Bans []map[string]any `json:"bans"` } err = json.Unmarshal(data, &file) if err != nil || len(file.Bans) != 1 { t.Fatalf("bans.json holds\n%s\nwant one ban (%v)", data, err) } return file.Bans[0] } // wantRefused checks that a request to url is refused with 403, the // default SWWAF_BAN_RESPONSE. func wantRefused(t *testing.T, url string) { t.Helper() req, err := http.NewRequestWithContext(t.Context(), http.MethodGet, url, http.NoBody) if err != nil { t.Fatalf("new request: %v", err) } transport := &http.Transport{} defer transport.CloseIdleConnections() res, err := (&http.Client{Transport: transport}).Do(req) if err != nil { t.Fatalf("request: %v", err) } _ = res.Body.Close() if res.StatusCode != http.StatusForbidden { t.Errorf("status %d, want %d", res.StatusCode, http.StatusForbidden) } } // wantStatus checks that a request to url from the client at from, as // X-Forwarded-For names it, is answered with status. func wantStatus(t *testing.T, url, from string, status int) { t.Helper() got := statusFrom(t, url, from) if got != status { t.Errorf("request from %s: status %d, want %d", from, got, status) } } // saveUntilAnswered writes content to the state file at path, as an // admin saves an edit of it, until a request to url from the client at // from is answered with status. The file is written again before each // request, since smallwebwaf 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) { t.Helper() for { err := os.WriteFile(path, []byte(content), 0o600) if err != nil { t.Fatalf("write %s: %v", path, err) } if statusFrom(t, url, from) == status { return } time.Sleep(pollInterval) } } // destination is a stand-in for SWWAF_ALERT_SLACK_WEBHOOK_URL or // SWWAF_ALERT_NTFY_URL. It notes each request it is sent, and answers // 200. type destination struct { url string mu sync.Mutex posts []destinationPost } // destinationPost is a request a destination was sent: its headers and // its body. type destinationPost struct { header http.Header body string } // startDestination starts a destination that takes every alert. func startDestination(t *testing.T) *destination { t.Helper() d := &destination{} server := httptest.NewServer(http.HandlerFunc( func(_ http.ResponseWriter, r *http.Request) { body, _ := io.ReadAll(r.Body) d.mu.Lock() d.posts = append(d.posts, destinationPost{ header: r.Header.Clone(), body: string(body), }) d.mu.Unlock() })) t.Cleanup(server.Close) d.url = server.URL + "/alerts" return d } // firstPost waits until the destination has been sent a request, and // returns the first. It waits as long as that takes, so that a slow test // process cannot fail the test. func (d *destination) firstPost(t *testing.T) destinationPost { t.Helper() for { d.mu.Lock() if len(d.posts) > 0 { post := d.posts[0] d.mu.Unlock() return post } d.mu.Unlock() time.Sleep(pollInterval) } } // webhook is a stand-in for SWWAF_ALERT_WEBHOOK_URL. It notes each alert // it is sent, and answers 204, or 503 while failing. type webhook struct { url string failing atomic.Bool mu sync.Mutex posts []webhookPost } // webhookPost is an alert the webhook was sent, with the Authorization // header sent with it, and whether the webhook took it. type webhookPost struct { alert map[string]any authorization string answered bool } // startWebhook starts a webhook that takes every alert. func startWebhook(t *testing.T) *webhook { t.Helper() w := &webhook{} server := httptest.NewServer(http.HandlerFunc( func(rw http.ResponseWriter, r *http.Request) { var alert map[string]any _ = json.NewDecoder(r.Body).Decode(&alert) failing := w.failing.Load() w.mu.Lock() w.posts = append(w.posts, webhookPost{ alert: alert, authorization: r.Header.Get("Authorization"), answered: !failing, }) w.mu.Unlock() if failing { rw.WriteHeader(http.StatusServiceUnavailable) return } rw.WriteHeader(http.StatusNoContent) })) t.Cleanup(server.Close) w.url = server.URL + "/alerts" return w } // 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 { t.Helper() for { w.mu.Lock() for _, post := range w.posts { if post.alert["event"] == event && post.answered == answered { w.mu.Unlock() return post } } w.mu.Unlock() time.Sleep(pollInterval) } } // statusFrom returns the status a request to url from the client at // from, as X-Forwarded-For names it, is answered with. func statusFrom(t *testing.T, url, from string) int { t.Helper() req, err := http.NewRequestWithContext(t.Context(), http.MethodGet, url, http.NoBody) if err != nil { t.Fatalf("new request: %v", err) } req.Header.Set("X-Forwarded-For", from) transport := &http.Transport{} defer transport.CloseIdleConnections() res, err := (&http.Client{Transport: transport}).Do(req) if err != nil { t.Fatalf("request: %v", err) } _ = res.Body.Close() return res.StatusCode }