Compare commits

..
1 Commits
Author SHA1 Message Date
clawbot 8378f4b52c Rule files, and bans for a clear sign of attack (closes #24)
check / check (push) Successful in 3m20s
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
2026-10-06 17:54:30 +00:00
4 changed files with 21 additions and 78 deletions
+14 -18
View File
@@ -493,10 +493,8 @@ 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 file whose name starts with `.`, such as an
editor's lock file `.#50-app.rules`, is not a rule file, as a shell's `*.rules` editor's lock file `.#50-app.rules`, is not a rule file, as a shell's `*.rules`
would not match it. A rule is a line of four fields separated by spaces or tabs: would not match it. A rule is a line of four fields separated by spaces or tabs:
an id, a target, an action and a regex, which runs to the end of the line. an id, a target, an action and a regex, which runs to the end of the line. Blank
Spaces and tabs at the end of a line are not part of its regex, so a line with lines and lines that start with `#` are ignored.
only those after its action has no regex, and is not a rule. Blank lines and
lines that start with `#` are ignored.
``` ```
# id target action regex # id target action regex
@@ -522,20 +520,18 @@ scanner-agent user_agent ban (?i)\b(sqlmap|nikto|nuclei|wpscan)\b
and takes time linear in the text it reads. It matches anywhere in the target and takes time linear in the text it reads. It matches anywhere in the target
unless anchored with `^` and `$`; `(?i)` at its front makes it ignore case. unless anchored with `^` and `$`; `(?i)` at its front makes it ignore case.
A line that is not a rule, a `header:` name with a character no header name can A line that is not a rule, a rule for the `Host` or the `Transfer-Encoding`
have, such as `header:User-Agent:`, a rule for the `Host` or the header, a regex that does not compile or an id used twice stops the start with a
`Transfer-Encoding` header, a regex that does not compile or an id used twice message naming the file and the line, and so does a `SWWAF_RULES_DIR` that does
stops the start with a message naming the file and the line, and so does a not exist. An empty directory is no error, and the log says that it holds no
`SWWAF_RULES_DIR` that does not exist. An empty directory is no error, and the rules. While it runs, `smallwebwaf` watches the directory, and reads the rule
log says that it holds no rules. While it runs, `smallwebwaf` watches the files again once the directory has had no change for 2 seconds after one is
directory, and reads the rule files again once the directory has had no change edited, added or removed, so that a file saved in place, appended to or copied
for 2 seconds after one is edited, added or removed, so that a file saved in in with `scp` is read only once whole, unless its writing stops for longer. It
place, appended to or copied in with `scp` is read only once whole, unless its also reads them 2 seconds after it starts watching, so that an edit saved while
writing stops for longer. It also reads them 2 seconds after it starts watching, it started is not missed. If they then hold one of those errors, the rules stay
so that an edit saved while it started is not missed. If they then hold one of as they were, the earlier version of the edited file included, the log names the
those errors, the rules stay as they were, the earlier version of the edited file and the line, and the files are read again after the next change.
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,
+3 -17
View File
@@ -752,22 +752,6 @@ func parseCountries(value string) ([]string, error) {
const headerNameChars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz" + const headerNameChars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz" +
"0123456789!#$%&'*+-.^_`|~" "0123456789!#$%&'*+-.^_`|~"
// IsHeaderName reports whether name can be a header name: one or more of
// the characters RFC 9110 allows in one.
func IsHeaderName(name string) bool {
if name == "" {
return false
}
for _, char := range name {
if !strings.ContainsRune(headerNameChars, char) {
return false
}
}
return true
}
// parseHeaderNames reads a comma-separated list of header names in either // parseHeaderNames reads a comma-separated list of header names in either
// case, and returns them in lower case. Host and Transfer-Encoding are // case, and returns them in lower case. Host and Transfer-Encoding are
// refused: Go's HTTP server takes them out of the request's headers. // refused: Go's HTTP server takes them out of the request's headers.
@@ -780,9 +764,11 @@ func parseHeaderNames(value string) ([]string, error) {
headers := make([]string, 0, len(items)) headers := make([]string, 0, len(items))
for _, item := range items { for _, item := range items {
if !IsHeaderName(item) { for _, char := range item {
if !strings.ContainsRune(headerNameChars, char) {
return nil, fmt.Errorf("%q %w", item, errNotHeaderName) return nil, fmt.Errorf("%q %w", item, errNotHeaderName)
} }
}
header := strings.ToLower(item) header := strings.ToLower(item)
switch header { switch header {
+3 -13
View File
@@ -21,8 +21,6 @@ import (
"time" "time"
"github.com/fsnotify/fsnotify" "github.com/fsnotify/fsnotify"
"sneak.berlin/go/smallwebwaf/internal/config"
) )
// The actions a rule takes when it matches. // The actions a rule takes when it matches.
@@ -67,8 +65,6 @@ var (
errNotID = errors.New("is not an id of letters, digits, - and _") errNotID = errors.New("is not an id of letters, digits, - and _")
errNotTarget = errors.New( errNotTarget = errors.New(
"is not path, query, uri, method, host, user_agent, referer or header:<Name>") "is not path, query, uri, method, host, user_agent, referer or header:<Name>")
errNotHeaderName = errors.New(
"has a character after header: that no header name can have")
errHeaderTakenOut = errors.New( errHeaderTakenOut = errors.New(
"names a header that Go's HTTP server takes out of every request, " + "names a header that Go's HTTP server takes out of every request, " +
"so a rule never sees it") "so a rule never sees it")
@@ -113,8 +109,7 @@ type Files struct {
// Load reads the rules of every *.rules file in Dir, in the order of the // Load reads the rules of every *.rules file in Dir, in the order of the
// files' names, unless Enabled is false. A Dir that cannot be read is an // files' names, unless Enabled is false. A Dir that cannot be read is an
// error, and so is a line that is not a rule, a header name with a // error, and so is a line that is not a rule, a rule for the Host or the
// character no header name can have, a rule for the Host or the
// Transfer-Encoding header, which Go's HTTP server takes out of every // Transfer-Encoding header, which Go's HTTP server takes out of every
// request, a regex that does not compile and an id used twice, each named // request, a regex that does not compile and an id used twice, each named
// with its file and line. // with its file and line.
@@ -313,11 +308,9 @@ func readFile(path string, rules []Rule, places map[string]string) ([]Rule, erro
} }
// parse reads a line of a rule file. It returns false for a blank line // parse reads a line of a rule file. It returns false for a blank line
// and for a comment, a line that starts with #. Spaces and tabs at the // and for a comment, a line that starts with #.
// end of the line are not part of its regex, so a line with only those
// after its action has no regex, and is not a rule.
func parse(line string) (Rule, bool, error) { func parse(line string) (Rule, bool, error) {
line = strings.Trim(line, " \t") line = strings.TrimLeft(line, " \t")
if line == "" || strings.HasPrefix(line, "#") { if line == "" || strings.HasPrefix(line, "#") {
return Rule{}, false, nil return Rule{}, false, nil
} }
@@ -328,15 +321,12 @@ func parse(line string) (Rule, bool, error) {
} }
rule := Rule{ID: fields[1], Target: fields[2], Action: fields[3]} rule := Rule{ID: fields[1], Target: fields[2], Action: fields[3]}
headerName, isHeader := strings.CutPrefix(rule.Target, headerTarget)
switch { switch {
case !idChars.MatchString(rule.ID): case !idChars.MatchString(rule.ID):
return Rule{}, false, fmt.Errorf("the id %q %w", rule.ID, errNotID) return Rule{}, false, fmt.Errorf("the id %q %w", rule.ID, errNotID)
case !isTarget(rule.Target): case !isTarget(rule.Target):
return Rule{}, false, fmt.Errorf("the target %q %w", rule.Target, errNotTarget) return Rule{}, false, fmt.Errorf("the target %q %w", rule.Target, errNotTarget)
case isHeader && !config.IsHeaderName(headerName):
return Rule{}, false, fmt.Errorf("the target %q %w", rule.Target, errNotHeaderName)
case strings.EqualFold(rule.Target, headerTarget+"Host"): case strings.EqualFold(rule.Target, headerTarget+"Host"):
return Rule{}, false, fmt.Errorf( return Rule{}, false, fmt.Errorf(
"the target %q %w; the request's host is the target host", "the target %q %w; the request's host is the target host",
-29
View File
@@ -145,14 +145,6 @@ after path log ^/
wantMatched(t, files, get(t, "/"), "ban") wantMatched(t, files, get(t, "/"), "ban")
} }
func TestSpacesAndTabsEndingALineAreNotPartOfItsRegex(t *testing.T) {
t.Parallel()
files := load(t, ruleFiles{testFile: "env-file path block ^/\\.env$ \t \n"})
wantMatched(t, files, get(t, "/.env"), "env-file")
}
func TestFilesReadInNameOrderThenLineOrder(t *testing.T) { func TestFilesReadInNameOrderThenLineOrder(t *testing.T) {
t.Parallel() t.Parallel()
@@ -208,12 +200,6 @@ func TestFaultStopsTheStartNamingTheFileAndLine(t *testing.T) {
"is not a rule: an id, a target, an action and a regex, " + "is not a rule: an id, a target, an action and a regex, " +
"separated by spaces or tabs", "separated by spaces or tabs",
}, },
{
// Else its regex would be a space, found in nearly every user agent.
"a regex of only spaces and tabs", "scanner user_agent ban\t \n", 1,
"is not a rule: an id, a target, an action and a regex, " +
"separated by spaces or tabs",
},
{ {
"an id of other characters", "# ids\n\nenv.file path ban ^/\n", 3, "an id of other characters", "# ids\n\nenv.file path ban ^/\n", 3,
`the id "env.file" is not an id of letters, digits, - and _`, `the id "env.file" is not an id of letters, digits, - and _`,
@@ -228,21 +214,6 @@ func TestFaultStopsTheStartNamingTheFileAndLine(t *testing.T) {
`the target "header:" is not path, query, uri, method, host, ` + `the target "header:" is not path, query, uri, method, host, ` +
"user_agent, referer or header:<Name>", "user_agent, referer or header:<Name>",
}, },
{
"a header name written with its colon", "sqlmap header:User-Agent: ban sqlmap\n", 1,
`the target "header:User-Agent:" has a character after header: ` +
"that no header name can have",
},
{
"a header name with a semicolon", "accept header:Accept;q log ^$\n", 1,
`the target "header:Accept;q" has a character after header: ` +
"that no header name can have",
},
{
"a header name with brackets", "x-header header:X(y) log ^$\n", 1,
`the target "header:X(y)" has a character after header: ` +
"that no header name can have",
},
{ {
"the Host header", "host-header header:host block ^$\n", 1, "the Host header", "host-header header:host block ^$\n", 1,
`the target "header:host" names a header that Go's HTTP server ` + `the target "header:host" names a header that Go's HTTP server ` +