check / check (push) Waiting to run
Each alert is posted as a message to the Slack incoming webhook SWWAF_ALERT_SLACK_WEBHOOK_URL names, and published to the ntfy topic SWWAF_ALERT_NTFY_URL names, with SWWAF_ALERT_NTFY_TOKEN as a bearer token and a priority and tag by event. The cooldown and the hourly limit stay shared; past them, each destination has its own bounded queue and backoff, and its own sent, failed and dropped counts. alerts.json keeps the alerts waiting by destination. Judgement call: messages also give the detail's file, source, error and mode. Judgement call: alerts_suppressed_total is the same for every destination. Judgement call: an alerts.json with waiting as a list stops the start. Model: opus-5-5
1247 lines
34 KiB
Go
1247 lines
34 KiB
Go
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
|
|
}
|