Compare commits

..
1 Commits
Author SHA1 Message Date
clawbot 2c01d7f98f Rule files, and bans for a clear sign of attack (closes #24)
check / check (push) Successful in 3m28s
Every *.rules file in SWWAF_RULES_DIR is read at start and on each
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
2026-10-06 17:04:23 +00:00
5 changed files with 33 additions and 260 deletions
+7 -12
View File
@@ -490,11 +490,9 @@ not make the netblock's next ban longer.
`smallwebwaf` reads every `*.rules` file in `SWWAF_RULES_DIR`, `smallwebwaf` reads every `*.rules` file in `SWWAF_RULES_DIR`,
`/etc/smallwebwaf/rules.d` by default, in the order of their names, and checks `/etc/smallwebwaf/rules.d` by default, in the order of their names, and checks
each request against their rules in that order, as "Rule files" in each request against their rules in that order, as "Rule files" in
[`SPEC.md`](SPEC.md) describes. A file whose name starts with `.`, such as an [`SPEC.md`](SPEC.md) describes. A rule is a line of four fields separated by
editor's lock file `.#50-app.rules`, is not a rule file, as a shell's `*.rules` spaces or tabs: an id, a target, an action and a regex, which runs to the end of
would not match it. A rule is a line of four fields separated by spaces or tabs: the line. Blank lines and lines that start with `#` are ignored.
an id, a target, an action and a regex, which runs to the end of the line. Blank
lines and lines that start with `#` are ignored.
``` ```
# id target action regex # id target action regex
@@ -525,13 +523,10 @@ header, a regex that does not compile or an id used twice stops the start with a
message naming the file and the line, and so does a `SWWAF_RULES_DIR` that does message naming the file and the line, and so does a `SWWAF_RULES_DIR` that does
not exist. An empty directory is no error, and the log says that it holds no not exist. An empty directory is no error, and the log says that it holds no
rules. While it runs, `smallwebwaf` watches the directory, and reads the rule rules. While it runs, `smallwebwaf` watches the directory, and reads the rule
files again once the directory has had no change for 2 seconds after one is files again whenever one is edited, added or removed. If they then hold one of
edited, added or removed, so that a file saved in place, appended to or copied those errors, the rules stay as they were, the earlier version of the edited
in with `scp` is read only once whole, unless its writing stops for longer. It file included, the log names the file and the line, and the files are read again
also reads them 2 seconds after it starts watching, so that an edit saved while at the next change.
it started is not missed. If they then hold one of those errors, the rules stay
as they were, the earlier version of the edited file included, the log names the
file and the line, and the files are read again after the next change.
The image ships one rule file, `share/rules.d/00-default.rules` here: rules that The image ships one rule file, `share/rules.d/00-default.rules` here: rules that
ban probes no real visitor sends, for secrets, version control directories, ban probes no real visitor sends, for secrets, version control directories,
+21 -46
View File
@@ -1,8 +1,7 @@
// Package rules reads the rule files: the plain text files in // Package rules reads the rule files: the plain text files in
// SWWAF_RULES_DIR, one rule to a line, that each request is checked // SWWAF_RULES_DIR, one rule to a line, that each request is checked
// against, as the "Rule files" section of SPEC.md describes. They are read // against, as the "Rule files" section of SPEC.md describes. They are read
// at start, and again once the directory has had no change for a short // at start, and again whenever one is edited, added or removed.
// time after one is edited, added or removed.
package rules package rules
import ( import (
@@ -18,7 +17,6 @@ import (
"slices" "slices"
"strings" "strings"
"sync/atomic" "sync/atomic"
"time"
"github.com/fsnotify/fsnotify" "github.com/fsnotify/fsnotify"
) )
@@ -37,12 +35,6 @@ const (
// extension ends the name of every rule file. // extension ends the name of every rule file.
const extension = ".rules" const extension = ".rules"
// quietTime is how long SWWAF_RULES_DIR must go without a change before
// the rule files are read again, so that a file still being written, such
// as one saved in place, appended to or copied in with scp, is read only
// once whole.
const quietTime = 2 * time.Second
// headerTarget starts the target that is one request header, // headerTarget starts the target that is one request header,
// header:<Name>. // header:<Name>.
const headerTarget = "header:" const headerTarget = "header:"
@@ -158,12 +150,11 @@ func (f *Files) Len() int {
} }
// Watch watches Dir until ctx is done, and reads the rule files again // Watch watches Dir until ctx is done, and reads the rule files again
// once Dir has had no change for quietTime, after one is edited, added or // whenever one is edited, added or removed. If they then hold an error,
// removed, and after Watch starts watching. If they then hold an error,
// the rules stay as they were, the error is logged with its file and // the rules stay as they were, the error is logged with its file and
// line, and the files are read again after the next change. If Dir cannot // line, and the files are read again at the next change. If Dir cannot be
// be watched, that is logged, and the rules stay as they were loaded. // watched, that is logged, and the rules stay as they were loaded. While
// While Enabled is false, Watch returns at once. // Enabled is false, Watch returns at once.
func (f *Files) Watch(ctx context.Context) { func (f *Files) Watch(ctx context.Context) {
if !f.params.Enabled { if !f.params.Enabled {
return return
@@ -188,29 +179,15 @@ func (f *Files) Watch(ctx context.Context) {
f.params.ProcessLog.Info("watching the rule files for edits", f.params.ProcessLog.Info("watching the rule files for edits",
"directory", f.params.Dir) "directory", f.params.Dir)
f.readAfterChanges(ctx, watcher.Events, watcher.Errors)
}
// readAfterChanges reads the rule files again once quietTime has passed
// without a change from events, until ctx is done, and logs the errors
// from errs. The wait starts at once, as if for a change, so that an edit
// saved after Load read the files, and before Dir was watched, is taken
// in too.
func (f *Files) readAfterChanges(
ctx context.Context, events <-chan fsnotify.Event, errs <-chan error,
) {
quiet := time.NewTimer(quietTime)
defer quiet.Stop()
for { for {
select { select {
case <-ctx.Done(): case <-ctx.Done():
return return
case <-events: case event := <-watcher.Events:
quiet.Reset(quietTime) if filepath.Ext(event.Name) == extension {
case <-quiet.C:
f.readAgain() f.readAgain()
case err := <-errs: }
case err = <-watcher.Errors:
f.params.ProcessLog.Warn("watching the rule files failed", f.params.ProcessLog.Warn("watching the rule files failed",
"error", err.Error()) "error", err.Error())
} }
@@ -241,9 +218,7 @@ func (f *Files) logRead(count int) {
} }
// read returns the rules of every rule file in dir, in the order of the // read returns the rules of every rule file in dir, in the order of the
// files' names, and then of their lines. A file whose name starts with a // files' names, and then of their lines.
// dot, such as an editor's lock file .#50-app.rules, is not a rule file,
// as a shell's *.rules would not match it.
func read(dir string) ([]Rule, error) { func read(dir string) ([]Rule, error) {
entries, err := os.ReadDir(dir) entries, err := os.ReadDir(dir)
if err != nil { if err != nil {
@@ -256,12 +231,11 @@ func read(dir string) ([]Rule, error) {
places := map[string]string{} places := map[string]string{}
for _, entry := range entries { for _, entry := range entries {
name := entry.Name() if entry.IsDir() || filepath.Ext(entry.Name()) != extension {
if entry.IsDir() || strings.HasPrefix(name, ".") || filepath.Ext(name) != extension {
continue continue
} }
rules, err = readFile(filepath.Join(dir, name), rules, places) rules, err = readFile(filepath.Join(dir, entry.Name()), rules, places)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -399,14 +373,15 @@ func value(target string, r *http.Request) string {
} }
} }
// pathAndQuery returns the target of r's request line, r.RequestURI, as // pathAndQuery returns the path and the query of r as the client sent
// the client sent it, less any scheme and host: a target with a scheme // them, as the app is sent them: the target of its request line,
// gives what follows the scheme and its :, and the host when // follows. // r.RequestURI, of which a target with a scheme gives what follows the
// So http://host/path, as a client sends it to a proxy, gives /path, and // scheme and its :, and the host when // follows. So http://host/path, as
// so does http:/path, which Go reads as a target with a scheme and no // a client sends it to a proxy, gives /path, and so does http:/path,
// host. r.URL is not used: when the path holds a character it escapes, // which Go reads as a target with a scheme and no host. r.URL is not
// such as \ or a non-ASCII byte, it decodes the whole path and escapes it // used: when the path holds a character it escapes, such as \ or a
// again, so that \ becomes %5C and %2e a dot. // non-ASCII byte, it decodes the whole path and escapes it again, so that
// \ becomes %5C and %2e a dot.
func pathAndQuery(r *http.Request) string { func pathAndQuery(r *http.Request) string {
if !r.URL.IsAbs() { if !r.URL.IsAbs() {
return r.RequestURI return r.RequestURI
+2 -24
View File
@@ -164,28 +164,6 @@ func TestFilesReadInNameOrderThenLineOrder(t *testing.T) {
} }
} }
func TestFileWhoseNameStartsWithADotIsNotARuleFile(t *testing.T) {
t.Parallel()
dir := writeFiles(t, ruleFiles{firstFile: "probe path block ^/probe\n"})
// 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))
if err != nil {
t.Fatalf("symlink: %v", err)
}
params, _ := newParams(dir)
files, err := rules.Load(params)
if err != nil {
t.Fatalf("load: %v", err)
}
wantMatched(t, files, get(t, "/probe"), "probe")
}
func TestFaultStopsTheStartNamingTheFileAndLine(t *testing.T) { func TestFaultStopsTheStartNamingTheFileAndLine(t *testing.T) {
t.Parallel() t.Parallel()
@@ -622,8 +600,8 @@ func (l processLog) waitFor(t *testing.T, msg string) map[string]any {
} }
// waitUntil waits for the rule files to be read until done reports true, // waitUntil waits for the rule files to be read until done reports true,
// as it does once they have been read after the test's last change. They // as it does once they have been read after the test's last change. One
// can be read before then too, as they are once Watch starts watching. // change can be seen more than once, and so read more than once.
func (l processLog) waitUntil(t *testing.T, done func() bool) { func (l processLog) waitUntil(t *testing.T, done func() bool) {
t.Helper() t.Helper()
-162
View File
@@ -1,162 +0,0 @@
package rules
import (
"context"
"log/slog"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"slices"
"testing"
"testing/synctest"
"time"
"github.com/fsnotify/fsnotify"
)
// The tests below run readAfterChanges in a synctest bubble, where time is
// a clock of the test's own: time.Sleep moves it on at once, and
// synctest.Wait returns once readAfterChanges waits again, so that every
// reading due by then is done. The test sends the changes itself, as the
// watch of a directory cannot run in a bubble.
func TestFileWrittenInTwoPartsTakenInOnlyWhole(t *testing.T) {
t.Parallel()
synctest.Test(t, func(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "50-app.rules")
writeFile(t, path, "first path block ^/first\n")
files := load(t, dir)
changes := run(t, files)
file, err := os.Create(path) //nolint:gosec // a file the test wrote
if err != nil {
t.Fatalf("create: %v", err)
}
defer func() {
_ = file.Close()
}()
// The first part ends in the middle of a ban rule's regex, which,
// read then, would ban every request.
write(t, file, "first path block ^/first\nprobe path ban ^/")
changes <- fsnotify.Event{Name: path, Op: fsnotify.Write}
time.Sleep(quietTime - time.Nanosecond)
synctest.Wait()
wantMatched(t, files, "/anything")
// The second part starts the wait again.
write(t, file, `\.env$`+"\n")
changes <- fsnotify.Event{Name: path, Op: fsnotify.Write}
time.Sleep(quietTime - time.Nanosecond)
synctest.Wait()
wantMatched(t, files, "/.env")
time.Sleep(time.Nanosecond)
synctest.Wait()
wantMatched(t, files, "/.env", "probe")
wantMatched(t, files, "/anything")
})
}
func TestEditSavedBeforeTheWatchStartsTakenIn(t *testing.T) {
t.Parallel()
synctest.Test(t, func(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "50-app.rules")
writeFile(t, path, "first path block ^/first\n")
files := load(t, dir)
// Saved after Load read the files, and before the directory was
// watched, so that no change is seen for it.
writeFile(t, path, "first path block ^/edited\n")
run(t, files)
time.Sleep(quietTime)
synctest.Wait()
wantMatched(t, files, "/edited", "first")
})
}
// load loads the rules in dir.
func load(t *testing.T, dir string) *Files {
t.Helper()
files, err := Load(Params{
Dir: dir, Enabled: true, ProcessLog: slog.New(slog.DiscardHandler),
})
if err != nil {
t.Fatalf("load: %v", err)
}
return files
}
// run runs files' readAfterChanges until the test ends, and returns the
// channel that sends it changes.
func run(t *testing.T, files *Files) chan<- fsnotify.Event {
t.Helper()
changes := make(chan fsnotify.Event)
ctx, stop := context.WithCancel(t.Context())
stopped := make(chan struct{})
go func() {
files.readAfterChanges(ctx, changes, nil)
close(stopped)
}()
t.Cleanup(func() {
stop()
<-stopped
})
return changes
}
// writeFile writes content to the file at path.
func writeFile(t *testing.T, path, content string) {
t.Helper()
err := os.WriteFile(path, []byte(content), 0o600)
if err != nil {
t.Fatalf("write %s: %v", path, err)
}
}
// write writes text to the end of file.
func write(t *testing.T, file *os.File, text string) {
t.Helper()
_, err := file.WriteString(text)
if err != nil {
t.Fatalf("write: %v", err)
}
}
// wantMatched checks the ids of the rules that a GET request for path
// matches, in order.
func wantMatched(t *testing.T, files *Files, path string, want ...string) {
t.Helper()
r := httptest.NewRequestWithContext(t.Context(), http.MethodGet,
"http://app.example"+path, nil)
matched := files.Match(r)
got := make([]string, 0, len(matched))
for _, rule := range matched {
got = append(got, rule.ID)
}
if !slices.Equal(got, want) {
t.Errorf("%s matched %v, want %v", path, got, want)
}
}
+2 -15
View File
@@ -343,21 +343,8 @@ func TestRuleFileAddedWhileRunningTakesEffect(t *testing.T) {
out := runUntilStopped(t, env, func(url string) { out := runUntilStopped(t, env, func(url string) {
wantGreeting(t, url) wantGreeting(t, url)
saveUntilAnswered(t, filepath.Join(dir, "50-app.rules"),
// Written once: each change would start the rule files' wait "everything path block ^/\n", url, "203.0.113.9", http.StatusForbidden)
// 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") out.line(t, "action", "rule_blocked")
} }