Rule files, and bans for a clear sign of attack (closes #24)
check / check (push) Successful in 3m29s
check / check (push) Successful in 3m29s
Every *.rules file in SWWAF_RULES_DIR not named with a leading dot is read at start, and again 2 seconds after the directory's last change. Each request is checked against the rules after the rate limits: log notes a match, block refuses with 403, ban refuses and bans the netblock for SWWAF_ATTACK_BAN_DURATION, made permanent by its next request or attack. path, query and uri are matched as the request line sent them; header:Host and header:Transfer-Encoding are refused. Bans gain a cause. The image ships 00-default.rules. Judgement call: a header sent twice is matched with its values joined by ", ". Judgement call: SWWAF_MAX_BAN_DURATION does not cap a ban for an attack. Not in this unit: offences for rule matches, with the error burst. Model: opus-5-5
This commit was merged in pull request #83.
This commit is contained in:
@@ -28,6 +28,7 @@ func TestHealthCheck(t *testing.T) {
|
||||
listenAddr: localhost + ":0",
|
||||
upstreamURL: app.URL,
|
||||
stateDir: t.TempDir(),
|
||||
rulesDir: t.TempDir(),
|
||||
}
|
||||
|
||||
go func() {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// Package smallwebwaf runs the smallwebwaf process: it reads the settings
|
||||
// and the state files, serves requests until it is told to stop, and then
|
||||
// stops in an orderly way, writing the state files.
|
||||
// Package smallwebwaf runs the smallwebwaf process: it reads the settings,
|
||||
// the rule files and the state files, serves requests until it is told to
|
||||
// stop, and then stops in an orderly way, writing the state files.
|
||||
package smallwebwaf
|
||||
|
||||
import (
|
||||
@@ -19,6 +19,7 @@ import (
|
||||
"sneak.berlin/go/smallwebwaf/internal/lookup"
|
||||
"sneak.berlin/go/smallwebwaf/internal/proxy"
|
||||
"sneak.berlin/go/smallwebwaf/internal/requestlog"
|
||||
"sneak.berlin/go/smallwebwaf/internal/rules"
|
||||
"sneak.berlin/go/smallwebwaf/internal/state"
|
||||
)
|
||||
|
||||
@@ -56,9 +57,9 @@ func Main(version string) int {
|
||||
})
|
||||
}
|
||||
|
||||
// Run reads the settings and the state files, then serves requests until
|
||||
// ctx is done. It returns the process's exit status, 1 when smallwebwaf
|
||||
// cannot start.
|
||||
// Run reads the settings, the rule files and the state files, then serves
|
||||
// requests until ctx is done. It returns the process's exit status, 1
|
||||
// when smallwebwaf cannot start.
|
||||
func Run(ctx context.Context, params Params) int {
|
||||
processLog := requestlog.NewProcessLogger(params.Stdout)
|
||||
|
||||
@@ -69,6 +70,17 @@ func Run(ctx context.Context, params Params) int {
|
||||
return 1
|
||||
}
|
||||
|
||||
ruleFiles, err := rules.Load(rules.Params{
|
||||
Dir: cfg.RulesDir,
|
||||
Enabled: cfg.RulesEnabled,
|
||||
ProcessLog: processLog,
|
||||
})
|
||||
if err != nil {
|
||||
processLog.Error("cannot use the rule files", "error", err.Error())
|
||||
|
||||
return 1
|
||||
}
|
||||
|
||||
// The state files give times in UTC.
|
||||
now := func() time.Time { return time.Now().UTC() }
|
||||
|
||||
@@ -78,6 +90,7 @@ func Run(ctx context.Context, params Params) int {
|
||||
ProcessLog: processLog,
|
||||
GeoJSURL: lookup.URL,
|
||||
Now: now,
|
||||
Rules: ruleFiles,
|
||||
})
|
||||
|
||||
files, err := state.Load(state.Params{
|
||||
@@ -110,16 +123,16 @@ func Run(ctx context.Context, params Params) int {
|
||||
"address", listener.Addr().String(),
|
||||
"settings", cfg)
|
||||
|
||||
return serve(ctx, server.Server, listener, files, processLog)
|
||||
return serve(ctx, server.Server, listener, files, ruleFiles, processLog)
|
||||
}
|
||||
|
||||
// serve serves requests on listener, writes the state files as they are
|
||||
// due, and takes in an admin's edits of them, until ctx is done. Then it
|
||||
// gives the requests in progress shutdownTimeout to finish, and writes
|
||||
// every state file.
|
||||
// due, takes in an admin's edits of them, and reads the rule files again
|
||||
// as they change, until ctx is done. Then it gives the requests in
|
||||
// progress shutdownTimeout to finish, and writes every state file.
|
||||
func serve(
|
||||
ctx context.Context, server *http.Server, listener net.Listener,
|
||||
files *state.Files, processLog *slog.Logger,
|
||||
files *state.Files, ruleFiles *rules.Files, processLog *slog.Logger,
|
||||
) int {
|
||||
served := make(chan error, 1)
|
||||
|
||||
@@ -132,6 +145,7 @@ func serve(
|
||||
|
||||
written := make(chan struct{})
|
||||
watched := make(chan struct{})
|
||||
rulesWatched := make(chan struct{})
|
||||
|
||||
go func() {
|
||||
files.Run(writing)
|
||||
@@ -143,6 +157,11 @@ func serve(
|
||||
close(watched)
|
||||
}()
|
||||
|
||||
go func() {
|
||||
ruleFiles.Watch(writing)
|
||||
close(rulesWatched)
|
||||
}()
|
||||
|
||||
select {
|
||||
case err := <-served:
|
||||
processLog.Error("serving failed", "error", err.Error())
|
||||
@@ -181,6 +200,7 @@ func serve(
|
||||
// missing from clients.json.
|
||||
<-written
|
||||
<-watched
|
||||
<-rulesWatched
|
||||
|
||||
err = files.WriteAll()
|
||||
if err != nil {
|
||||
|
||||
@@ -34,6 +34,7 @@ const (
|
||||
stateWriteDelay = "SWWAF_STATE_WRITE_DELAY"
|
||||
stateCounterInterval = "SWWAF_STATE_COUNTER_INTERVAL"
|
||||
rateLimitPerDay = "SWWAF_RATE_LIMIT_PER_DAY"
|
||||
rulesDir = "SWWAF_RULES_DIR"
|
||||
// greeting is what the tests' app answers.
|
||||
greeting = "hello from the app"
|
||||
)
|
||||
@@ -163,6 +164,7 @@ func TestAddressInUseStopsTheStart(t *testing.T) {
|
||||
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)
|
||||
@@ -186,9 +188,15 @@ func TestServesUntilToldToStop(t *testing.T) {
|
||||
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)
|
||||
|
||||
@@ -217,6 +225,7 @@ func TestStateKeptAcrossRestarts(t *testing.T) {
|
||||
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.
|
||||
@@ -253,6 +262,7 @@ func TestBanRefusesItsNetblockAfterARestartWithAnotherScope(t *testing.T) {
|
||||
listenAddr: localhost + ":0",
|
||||
upstreamURL: startApp(t),
|
||||
stateDir: t.TempDir(),
|
||||
rulesDir: t.TempDir(),
|
||||
trustedProxies: localhost + "/32",
|
||||
rateLimitPerDay: "1",
|
||||
scope: "24",
|
||||
@@ -300,6 +310,7 @@ func TestBanAddedAndLiftedByEditingBansJSON(t *testing.T) {
|
||||
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.
|
||||
@@ -316,6 +327,64 @@ func TestBanAddedAndLiftedByEditingBansJSON(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
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 TestStateFileThatDoesNotParseStopsTheStart(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
@@ -349,7 +418,9 @@ func wantStartRefused(t *testing.T, dir, want string) {
|
||||
|
||||
out := &output{}
|
||||
|
||||
status := run(ctx, map[string]string{listenAddr: localhost + ":0", stateDir: dir}, out)
|
||||
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)
|
||||
}
|
||||
@@ -362,6 +433,30 @@ func wantStartRefused(t *testing.T, dir, want string) {
|
||||
}
|
||||
}
|
||||
|
||||
// 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 {
|
||||
@@ -443,8 +538,10 @@ func wantStartingLine(t *testing.T, line map[string]any, appURL, dir string) {
|
||||
"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 {
|
||||
|
||||
Reference in New Issue
Block a user