Rule files, and bans for a clear sign of attack (closes #24)
check / check (push) Successful in 3m28s
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
This commit is contained in:
@@ -0,0 +1,434 @@
|
||||
// Package rules reads the rule files: the plain text files in
|
||||
// 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
|
||||
// at start, and again whenever one is edited, added or removed.
|
||||
package rules
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"slices"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
|
||||
"github.com/fsnotify/fsnotify"
|
||||
)
|
||||
|
||||
// The actions a rule takes when it matches.
|
||||
const (
|
||||
// ActionLog notes the match in the request log, and does nothing else.
|
||||
ActionLog = "log"
|
||||
// ActionBlock refuses the request with 403.
|
||||
ActionBlock = "block"
|
||||
// ActionBan refuses the request and bans the client's netblock: the
|
||||
// request is a clear sign of attack.
|
||||
ActionBan = "ban"
|
||||
)
|
||||
|
||||
// extension ends the name of every rule file.
|
||||
const extension = ".rules"
|
||||
|
||||
// headerTarget starts the target that is one request header,
|
||||
// header:<Name>.
|
||||
const headerTarget = "header:"
|
||||
|
||||
// escapeLength is the length of a percent escape, such as %2e.
|
||||
const escapeLength = 3
|
||||
|
||||
var (
|
||||
// ruleLine is a rule: four fields separated by spaces or tabs, of
|
||||
// which the fourth, the regex, runs to the end of the line.
|
||||
ruleLine = regexp.MustCompile(`^([^ \t]+)[ \t]+([^ \t]+)[ \t]+([^ \t]+)[ \t]+(.+)$`)
|
||||
// idChars are the characters of a rule's id.
|
||||
idChars = regexp.MustCompile(`^[A-Za-z0-9_-]+$`)
|
||||
)
|
||||
|
||||
var (
|
||||
errNotRule = errors.New(
|
||||
"is not a rule: an id, a target, an action and a regex, " +
|
||||
"separated by spaces or tabs")
|
||||
errNotID = errors.New("is not an id of letters, digits, - and _")
|
||||
errNotTarget = errors.New(
|
||||
"is not path, query, uri, method, host, user_agent, referer or header:<Name>")
|
||||
errHeaderTakenOut = errors.New(
|
||||
"names a header that Go's HTTP server takes out of every request, " +
|
||||
"so a rule never sees it")
|
||||
errNotAction = errors.New("is not log, block or ban")
|
||||
errNotRegex = errors.New("does not compile")
|
||||
errUsedTwice = errors.New("is already the id of the rule at")
|
||||
)
|
||||
|
||||
// Rule is one rule of a rule file.
|
||||
type Rule struct {
|
||||
// ID names the rule in the request log, the metrics and ban notes.
|
||||
ID string
|
||||
// Target is what the regex is matched against, such as path or
|
||||
// header:Accept.
|
||||
Target string
|
||||
// Action is ActionLog, ActionBlock or ActionBan.
|
||||
Action string
|
||||
|
||||
regex *regexp.Regexp
|
||||
}
|
||||
|
||||
// Params are what Load needs.
|
||||
type Params struct {
|
||||
// Dir is the directory of the rule files (SWWAF_RULES_DIR).
|
||||
Dir string
|
||||
// Enabled is SWWAF_RULES_ENABLED: while it is false, no file is read
|
||||
// and no rule loaded.
|
||||
Enabled bool
|
||||
// ProcessLog receives how many rules were read, and the error in a
|
||||
// rule file edited while smallwebwaf runs.
|
||||
ProcessLog *slog.Logger
|
||||
}
|
||||
|
||||
// Files are the rule files of a running smallwebwaf, and the rules read
|
||||
// from them. They are safe for concurrent use.
|
||||
type Files struct {
|
||||
params Params
|
||||
// rules are the rules loaded, in the order of their files' names, and
|
||||
// then of their lines.
|
||||
rules atomic.Pointer[[]Rule]
|
||||
}
|
||||
|
||||
// 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
|
||||
// error, and so is a line that is not a rule, a rule for the Host or the
|
||||
// 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
|
||||
// with its file and line.
|
||||
func Load(params Params) (*Files, error) {
|
||||
f := &Files{params: params}
|
||||
f.rules.Store(&[]Rule{})
|
||||
|
||||
if !params.Enabled {
|
||||
return f, nil
|
||||
}
|
||||
|
||||
rules, err := read(params.Dir)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
f.rules.Store(&rules)
|
||||
f.logRead(len(rules))
|
||||
|
||||
return f, nil
|
||||
}
|
||||
|
||||
// Match checks r against the rules, in order, and returns those it
|
||||
// matches, up to the first whose action refuses it, block or ban, which
|
||||
// is then the last one returned.
|
||||
func (f *Files) Match(r *http.Request) []Rule {
|
||||
var matched []Rule
|
||||
|
||||
for _, rule := range *f.rules.Load() {
|
||||
if !rule.matches(r) {
|
||||
continue
|
||||
}
|
||||
|
||||
matched = append(matched, rule)
|
||||
if rule.Action != ActionLog {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return matched
|
||||
}
|
||||
|
||||
// Len returns how many rules are loaded.
|
||||
func (f *Files) Len() int {
|
||||
return len(*f.rules.Load())
|
||||
}
|
||||
|
||||
// Watch watches Dir until ctx is done, and reads the rule files again
|
||||
// whenever one is edited, added or removed. If they then hold an error,
|
||||
// the rules stay as they were, the error is logged with its file and
|
||||
// line, and the files are read again at the next change. If Dir cannot be
|
||||
// watched, that is logged, and the rules stay as they were loaded. While
|
||||
// Enabled is false, Watch returns at once.
|
||||
func (f *Files) Watch(ctx context.Context) {
|
||||
if !f.params.Enabled {
|
||||
return
|
||||
}
|
||||
|
||||
watcher, err := fsnotify.NewWatcher()
|
||||
if err == nil {
|
||||
defer func() {
|
||||
_ = watcher.Close()
|
||||
}()
|
||||
|
||||
err = watcher.Add(f.params.Dir)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
f.params.ProcessLog.Error("cannot watch the rule files for edits",
|
||||
"error", err.Error())
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
f.params.ProcessLog.Info("watching the rule files for edits",
|
||||
"directory", f.params.Dir)
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case event := <-watcher.Events:
|
||||
if filepath.Ext(event.Name) == extension {
|
||||
f.readAgain()
|
||||
}
|
||||
case err = <-watcher.Errors:
|
||||
f.params.ProcessLog.Warn("watching the rule files failed",
|
||||
"error", err.Error())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// readAgain reads the rule files again, in place of the rules loaded, or
|
||||
// logs the error that keeps the rules as they were.
|
||||
func (f *Files) readAgain() {
|
||||
rules, err := read(f.params.Dir)
|
||||
if err != nil {
|
||||
f.params.ProcessLog.Error(
|
||||
"a rule file has an error, and the rules stay as they were",
|
||||
"error", err.Error())
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
f.rules.Store(&rules)
|
||||
f.logRead(len(rules))
|
||||
}
|
||||
|
||||
// logRead logs that the rule files were read, and how many rules they
|
||||
// hold, which can be none.
|
||||
func (f *Files) logRead(count int) {
|
||||
f.params.ProcessLog.Info("read the rule files",
|
||||
"directory", f.params.Dir, "rules", count)
|
||||
}
|
||||
|
||||
// read returns the rules of every rule file in dir, in the order of the
|
||||
// files' names, and then of their lines.
|
||||
func read(dir string) ([]Rule, error) {
|
||||
entries, err := os.ReadDir(dir)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("SWWAF_RULES_DIR cannot be read: %w", err)
|
||||
}
|
||||
|
||||
var rules []Rule
|
||||
|
||||
// places are where each id is, as "<file>, line <n>".
|
||||
places := map[string]string{}
|
||||
|
||||
for _, entry := range entries {
|
||||
if entry.IsDir() || filepath.Ext(entry.Name()) != extension {
|
||||
continue
|
||||
}
|
||||
|
||||
rules, err = readFile(filepath.Join(dir, entry.Name()), rules, places)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
return rules, nil
|
||||
}
|
||||
|
||||
// readFile appends the rules of the rule file at path to rules. places
|
||||
// are where each id read so far is, and gain those of the file.
|
||||
func readFile(path string, rules []Rule, places map[string]string) ([]Rule, error) {
|
||||
data, err := os.ReadFile(path) //nolint:gosec // a rule file, in SWWAF_RULES_DIR
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
number := 0
|
||||
|
||||
for line := range strings.Lines(string(data)) {
|
||||
number++
|
||||
place := fmt.Sprintf("%s, line %d", path, number)
|
||||
|
||||
text := strings.TrimSuffix(strings.TrimSuffix(line, "\n"), "\r")
|
||||
|
||||
rule, isRule, err := parse(text)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%s: %w", place, err)
|
||||
}
|
||||
|
||||
if !isRule {
|
||||
continue
|
||||
}
|
||||
|
||||
first, used := places[rule.ID]
|
||||
if used {
|
||||
return nil, fmt.Errorf("%s: the id %q %w %s", place, rule.ID, errUsedTwice, first)
|
||||
}
|
||||
|
||||
places[rule.ID] = place
|
||||
rules = append(rules, rule)
|
||||
}
|
||||
|
||||
return rules, nil
|
||||
}
|
||||
|
||||
// parse reads a line of a rule file. It returns false for a blank line
|
||||
// and for a comment, a line that starts with #.
|
||||
func parse(line string) (Rule, bool, error) {
|
||||
line = strings.TrimLeft(line, " \t")
|
||||
if line == "" || strings.HasPrefix(line, "#") {
|
||||
return Rule{}, false, nil
|
||||
}
|
||||
|
||||
fields := ruleLine.FindStringSubmatch(line)
|
||||
if fields == nil {
|
||||
return Rule{}, false, errNotRule
|
||||
}
|
||||
|
||||
rule := Rule{ID: fields[1], Target: fields[2], Action: fields[3]}
|
||||
|
||||
switch {
|
||||
case !idChars.MatchString(rule.ID):
|
||||
return Rule{}, false, fmt.Errorf("the id %q %w", rule.ID, errNotID)
|
||||
case !isTarget(rule.Target):
|
||||
return Rule{}, false, fmt.Errorf("the target %q %w", rule.Target, errNotTarget)
|
||||
case strings.EqualFold(rule.Target, headerTarget+"Host"):
|
||||
return Rule{}, false, fmt.Errorf(
|
||||
"the target %q %w; the request's host is the target host",
|
||||
rule.Target, errHeaderTakenOut)
|
||||
case strings.EqualFold(rule.Target, headerTarget+"Transfer-Encoding"):
|
||||
return Rule{}, false, fmt.Errorf("the target %q %w", rule.Target, errHeaderTakenOut)
|
||||
case !slices.Contains([]string{ActionLog, ActionBlock, ActionBan}, rule.Action):
|
||||
return Rule{}, false, fmt.Errorf("the action %q %w", rule.Action, errNotAction)
|
||||
}
|
||||
|
||||
regex, err := regexp.Compile(fields[4])
|
||||
if err != nil {
|
||||
return Rule{}, false, fmt.Errorf("the regex %w: %w", errNotRegex, err)
|
||||
}
|
||||
|
||||
rule.regex = regex
|
||||
|
||||
return rule, true, nil
|
||||
}
|
||||
|
||||
// isTarget reports whether target is one a rule may have.
|
||||
func isTarget(target string) bool {
|
||||
switch target {
|
||||
case "path", "query", "uri", "method", "host", "user_agent", "referer":
|
||||
return true
|
||||
}
|
||||
|
||||
name, isHeader := strings.CutPrefix(target, headerTarget)
|
||||
|
||||
return isHeader && name != ""
|
||||
}
|
||||
|
||||
// matches reports whether the rule's regex matches its target in r. For
|
||||
// uri it is matched against the path and query as received, and against
|
||||
// them once percent-decoded, so that an encoded probe cannot slip past.
|
||||
func (rule Rule) matches(r *http.Request) bool {
|
||||
if rule.Target == "uri" {
|
||||
uri := pathAndQuery(r)
|
||||
|
||||
return rule.regex.MatchString(uri) || rule.regex.MatchString(decodeOnce(uri))
|
||||
}
|
||||
|
||||
return rule.regex.MatchString(value(rule.Target, r))
|
||||
}
|
||||
|
||||
// value returns what a rule with target, other than uri, is matched
|
||||
// against in r: the path and the query as the client sent them, before
|
||||
// any decoding or re-encoding, split at the first ?, and a header's values
|
||||
// joined by ", ", as HTTP joins those of a header sent more than once.
|
||||
func value(target string, r *http.Request) string {
|
||||
switch target {
|
||||
case "path":
|
||||
path, _, _ := strings.Cut(pathAndQuery(r), "?")
|
||||
|
||||
return path
|
||||
case "query":
|
||||
_, query, _ := strings.Cut(pathAndQuery(r), "?")
|
||||
|
||||
return query
|
||||
case "method":
|
||||
return r.Method
|
||||
case "host":
|
||||
return r.Host
|
||||
case "user_agent":
|
||||
return header(r, "User-Agent")
|
||||
case "referer":
|
||||
return header(r, "Referer")
|
||||
default:
|
||||
return header(r, strings.TrimPrefix(target, headerTarget))
|
||||
}
|
||||
}
|
||||
|
||||
// pathAndQuery returns the path and the query of r as the client sent
|
||||
// them, as the app is sent them: the target of its request line,
|
||||
// r.RequestURI, of which a target with a scheme gives what follows the
|
||||
// scheme and its :, and the host when // follows. So http://host/path, as
|
||||
// a client sends it to a proxy, gives /path, and so does http:/path,
|
||||
// which Go reads as a target with a scheme and no host. r.URL is not
|
||||
// used: when the path holds a character it escapes, such as \ or a
|
||||
// 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 {
|
||||
if !r.URL.IsAbs() {
|
||||
return r.RequestURI
|
||||
}
|
||||
|
||||
_, afterScheme, _ := strings.Cut(r.RequestURI, ":")
|
||||
|
||||
hostAndRest, hasHost := strings.CutPrefix(afterScheme, "//")
|
||||
if !hasHost {
|
||||
return afterScheme
|
||||
}
|
||||
|
||||
start := strings.IndexAny(hostAndRest, "/?")
|
||||
if start < 0 {
|
||||
return ""
|
||||
}
|
||||
|
||||
return hostAndRest[start:]
|
||||
}
|
||||
|
||||
// header returns the values of r's header name joined by ", ", or "" if
|
||||
// r has no such header.
|
||||
func header(r *http.Request, name string) string {
|
||||
return strings.Join(r.Header.Values(name), ", ")
|
||||
}
|
||||
|
||||
// decodeOnce returns s with each percent escape, such as %2e, replaced by
|
||||
// the byte it stands for. A % that is not followed by two hex digits is
|
||||
// left as it is, so that a malformed escape cannot keep the rest of s
|
||||
// from being decoded.
|
||||
func decodeOnce(s string) string {
|
||||
var decoded strings.Builder
|
||||
|
||||
for i := 0; i < len(s); i++ {
|
||||
if s[i] == '%' && i+escapeLength <= len(s) {
|
||||
b, err := hex.DecodeString(s[i+1 : i+escapeLength])
|
||||
if err == nil {
|
||||
decoded.Write(b)
|
||||
|
||||
i += escapeLength - 1
|
||||
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
decoded.WriteByte(s[i])
|
||||
}
|
||||
|
||||
return decoded.String()
|
||||
}
|
||||
@@ -0,0 +1,611 @@
|
||||
package rules_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"log/slog"
|
||||
"maps"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"strconv"
|
||||
"testing"
|
||||
|
||||
"sneak.berlin/go/smallwebwaf/internal/rules"
|
||||
)
|
||||
|
||||
const (
|
||||
// What the process log says once Watch watches the directory, after
|
||||
// each reading of the rule files, and for one that has an error.
|
||||
watching = "watching the rule files for edits"
|
||||
read = "read the rule files"
|
||||
hasError = "a rule file has an error, and the rules stay as they were"
|
||||
// maxLogLines is how many lines of the process log wait for a test to
|
||||
// read them.
|
||||
maxLogLines = 64
|
||||
// browser is the user agent of an ordinary visitor.
|
||||
browser = "Mozilla/5.0 (X11; Linux x86_64; rv:140.0) Gecko/20100101 Firefox/140.0"
|
||||
// testFile is the rule file of a test that needs only one, and
|
||||
// firstFile the first of a test's rule files.
|
||||
testFile = "test.rules"
|
||||
firstFile = "00-a.rules"
|
||||
// userAgent is the header that carries the user agent.
|
||||
userAgent = "User-Agent"
|
||||
)
|
||||
|
||||
func TestEachTargetMatchesWhatItNames(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
rule string // its target, action and regex
|
||||
uri string // the request's path and query
|
||||
header http.Header
|
||||
want bool
|
||||
}{
|
||||
{"path as received", `path log ^/%2eenv$`, "/%2eenv", nil, true},
|
||||
{"path not decoded", `path log ^/\.env$`, "/%2eenv", nil, false},
|
||||
{"path without the query", `path log ^/a$`, "/a?b=c", nil, true},
|
||||
{"query as received", `query log ^b=%2e$`, "/a?b=%2e", nil, true},
|
||||
{"uri as received", `uri log ^/a\?b=%2e$`, "/a?b=%2e", nil, true},
|
||||
{"uri decoded", `uri log (\.\./){2}`, "/a?f=%2e%2e%2f%2e%2e%2f", nil, true},
|
||||
{
|
||||
"uri decoded past malformed escapes", `uri log (\.\./){2}&h=%$`,
|
||||
"/a?g=%zz&f=%2e%2e%2f%2e%2e%2f&h=%", nil, true,
|
||||
},
|
||||
{"uri decoded only once", `uri log ^/a\.b$`, "/a%252eb", nil, false},
|
||||
{"method", `method log ^PUT$`, "/", nil, true},
|
||||
{"host", `host log ^app\.example$`, "/", nil, true},
|
||||
{
|
||||
"user_agent", `user_agent log ^sqlmap/`, "/",
|
||||
http.Header{userAgent: {"sqlmap/1.8"}}, true,
|
||||
},
|
||||
{
|
||||
"user_agent sent twice", `user_agent log ^curl/8, sqlmap/`, "/",
|
||||
http.Header{userAgent: {"curl/8", "sqlmap/1.8"}}, true,
|
||||
},
|
||||
{"user_agent missing", `user_agent log ^$`, "/", nil, true},
|
||||
{
|
||||
"referer", `referer log ^https://spam\.example/`, "/",
|
||||
http.Header{"Referer": {"https://spam.example/buy"}}, true,
|
||||
},
|
||||
{
|
||||
"a header sent twice", `header:x-api-version log ^2, 3$`, "/",
|
||||
http.Header{"X-Api-Version": {"2", "3"}}, true,
|
||||
},
|
||||
{"a header missing", `header:X-Api-Version log ^$`, "/", nil, true},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
files := load(t, ruleFiles{testFile: "a-rule " + tc.rule + "\n"})
|
||||
|
||||
// Every request is a PUT, which the method rule looks for.
|
||||
r := httptest.NewRequestWithContext(t.Context(), http.MethodPut,
|
||||
"http://app.example"+tc.uri, nil)
|
||||
maps.Copy(r.Header, tc.header)
|
||||
|
||||
got := len(files.Match(r)) == 1
|
||||
if got != tc.want {
|
||||
t.Errorf("%s matches %s: %t, want %t", tc.rule, tc.uri, got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestPathMatchedAsTheClientSentIt(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// Each path holds a character Go's URL type would escape again, \ or
|
||||
// a non-ASCII byte, and each rule is written for the path as sent.
|
||||
for _, tc := range []struct {
|
||||
rule string // its target, action and regex
|
||||
sent string // the path and query the client sent
|
||||
}{
|
||||
{`path log ^/\.\.\\\.\.\\windows\\win\.ini$`, `/..\..\windows\win.ini`},
|
||||
{`path log ^/%2e%2e\\%2e%2e\\windows\\win\.ini$`, `/%2e%2e\%2e%2e\windows\win.ini`},
|
||||
{`path log ^/café$`, "/café?x=1"},
|
||||
{`uri log ^/%2e%2e\\%2e%2e\\boot\.ini\?x=1$`, `/%2e%2e\%2e%2e\boot.ini?x=1`},
|
||||
} {
|
||||
files := load(t, ruleFiles{testFile: "as-sent " + tc.rule + "\n"})
|
||||
|
||||
// The target in origin form, as traefik sends it, in absolute form,
|
||||
// as a client sends it to a proxy, and with a scheme but no host,
|
||||
// which Go reads as absolute form with no host, sending the app
|
||||
// the path.
|
||||
for _, target := range []string{
|
||||
tc.sent, "http://app.example" + tc.sent, "http:" + tc.sent, "foo:" + tc.sent,
|
||||
} {
|
||||
r := httptest.NewRequestWithContext(t.Context(), http.MethodGet, target, nil)
|
||||
wantMatched(t, files, r, "as-sent")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestMatchingStopsAtTheFirstRuleThatRefuses(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
files := load(t, ruleFiles{testFile: `
|
||||
every-path path log ^/
|
||||
no-path path log ^$
|
||||
first-refusal path block ^/probe
|
||||
later-ban path ban ^/probe
|
||||
after path log ^/
|
||||
`})
|
||||
|
||||
// Every log rule that matches is noted, and the block rule ends the
|
||||
// matching.
|
||||
wantMatched(t, files, get(t, "/probe"), "every-path", "first-refusal")
|
||||
wantMatched(t, files, get(t, "/page"), "every-path", "after")
|
||||
|
||||
// A ban rule ends it too.
|
||||
files = load(t, ruleFiles{testFile: "ban path ban ^/\nlater path block ^/\n"})
|
||||
wantMatched(t, files, get(t, "/"), "ban")
|
||||
}
|
||||
|
||||
func TestFilesReadInNameOrderThenLineOrder(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
files := load(t, ruleFiles{
|
||||
"50-b.rules": "b1 path log ^/\n\n# a comment\n # an indented one\nb2 path log ^/\n",
|
||||
firstFile: "a1 path log ^/\r\n",
|
||||
// None is a rule file.
|
||||
"notes.txt": "notes, not rules\n",
|
||||
"10-c.rules.bak": "an old copy\n",
|
||||
"20-d.rules/keep": "a file in a directory\n",
|
||||
})
|
||||
|
||||
wantMatched(t, files, get(t, "/"), "a1", "b1", "b2")
|
||||
|
||||
if files.Len() != 3 {
|
||||
t.Errorf("%d rules loaded, want 3", files.Len())
|
||||
}
|
||||
}
|
||||
|
||||
func TestFaultStopsTheStartNamingTheFileAndLine(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
content string
|
||||
line int
|
||||
want string
|
||||
}{
|
||||
{
|
||||
"too few fields", "env-file path ban\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,
|
||||
`the id "env.file" is not an id of letters, digits, - and _`,
|
||||
},
|
||||
{
|
||||
"an unknown target", "env-file paths ban ^/\n", 1,
|
||||
`the target "paths" is not path, query, uri, method, host, ` +
|
||||
"user_agent, referer or header:<Name>",
|
||||
},
|
||||
{
|
||||
"a header without a name", "env-file header: ban ^/\n", 1,
|
||||
`the target "header:" is not path, query, uri, method, host, ` +
|
||||
"user_agent, referer or header:<Name>",
|
||||
},
|
||||
{
|
||||
"the Host header", "host-header header:host block ^$\n", 1,
|
||||
`the target "header:host" names a header that Go's HTTP server ` +
|
||||
"takes out of every request, so a rule never sees it; " +
|
||||
"the request's host is the target host",
|
||||
},
|
||||
{
|
||||
"the Transfer-Encoding header",
|
||||
"# bodies sent in chunks\nchunked header:Transfer-Encoding block ^chunked$\n", 2,
|
||||
`the target "header:Transfer-Encoding" names a header that Go's ` +
|
||||
"HTTP server takes out of every request, so a rule never sees it",
|
||||
},
|
||||
{
|
||||
"an unknown action", "env-file path deny ^/\n", 1,
|
||||
`the action "deny" is not log, block or ban`,
|
||||
},
|
||||
{
|
||||
"a regex that does not compile", "env-file path ban ^/(\n", 1,
|
||||
"the regex does not compile: error parsing regexp: " +
|
||||
"missing closing ): `^/(`",
|
||||
},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
dir := writeFiles(t, ruleFiles{"00-default.rules": tc.content})
|
||||
path := filepath.Join(dir, "00-default.rules")
|
||||
|
||||
wantRefused(t, dir, path+", line "+strconv.Itoa(tc.line)+": "+tc.want)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestIDUsedTwiceStopsTheStartNamingBothPlaces(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
dir := writeFiles(t, ruleFiles{
|
||||
"00-a.rules": "probe path log ^/a\n",
|
||||
"50-b.rules": "other path log ^/b\nprobe path ban ^/c\n",
|
||||
})
|
||||
|
||||
wantRefused(t, dir, filepath.Join(dir, "50-b.rules")+`, line 2: the id "probe" `+
|
||||
"is already the id of the rule at "+filepath.Join(dir, "00-a.rules")+", line 1")
|
||||
}
|
||||
|
||||
func TestDirectoryThatDoesNotExistStopsTheStart(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
dir := filepath.Join(t.TempDir(), "rules.d")
|
||||
|
||||
wantRefused(t, dir, "SWWAF_RULES_DIR cannot be read: open "+dir+
|
||||
": no such file or directory")
|
||||
}
|
||||
|
||||
func TestEmptyDirectoryLoadsNoRulesAndSaysSo(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
params, lines := newParams(writeFiles(t, ruleFiles{"00-default.rules": "# none\n"}))
|
||||
|
||||
files, err := rules.Load(params)
|
||||
if err != nil {
|
||||
t.Fatalf("load: %v", err)
|
||||
}
|
||||
|
||||
line := lines.waitFor(t, read)
|
||||
if files.Len() != 0 || line["rules"] != 0.0 {
|
||||
t.Errorf("%d rules loaded, and the log says %v, want none", files.Len(), line)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRuleFilesOffReadNothing(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// SWWAF_RULES_DIR does not exist, which would stop the start.
|
||||
params, _ := newParams(filepath.Join(t.TempDir(), "rules.d"))
|
||||
params.Enabled = false
|
||||
|
||||
files, err := rules.Load(params)
|
||||
if err != nil {
|
||||
t.Fatalf("load: %v", err)
|
||||
}
|
||||
|
||||
if files.Len() != 0 || files.Match(get(t, "/")) != nil {
|
||||
t.Errorf("%d rules loaded with the rule files off", files.Len())
|
||||
}
|
||||
|
||||
// It would watch until the test ends.
|
||||
files.Watch(t.Context())
|
||||
}
|
||||
|
||||
func TestEditsTakenInWhileRunning(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
dir := writeFiles(t, ruleFiles{firstFile: "first path block ^/first\n"})
|
||||
files, lines := watch(t, dir)
|
||||
|
||||
// matches reports whether path matches a rule.
|
||||
matches := func(path string) bool { return len(files.Match(get(t, path))) == 1 }
|
||||
|
||||
// A file added.
|
||||
save(t, dir, "50-b.rules", "second path block ^/second\n")
|
||||
lines.waitUntil(t, func() bool { return matches("/second") })
|
||||
wantMatched(t, files, get(t, "/first"), "first")
|
||||
|
||||
// A file edited.
|
||||
save(t, dir, firstFile, "first path block ^/edited\n")
|
||||
lines.waitUntil(t, func() bool { return !matches("/first") })
|
||||
wantMatched(t, files, get(t, "/edited"), "first")
|
||||
|
||||
// A file removed.
|
||||
err := os.Remove(filepath.Join(dir, "50-b.rules"))
|
||||
if err != nil {
|
||||
t.Fatalf("remove: %v", err)
|
||||
}
|
||||
|
||||
lines.waitUntil(t, func() bool { return !matches("/second") })
|
||||
wantMatched(t, files, get(t, "/edited"), "first")
|
||||
}
|
||||
|
||||
func TestBrokenEditKeepsTheRulesAsTheyWere(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
dir := writeFiles(t, ruleFiles{firstFile: "first path block ^/first\n"})
|
||||
files, lines := watch(t, dir)
|
||||
|
||||
// The edit's second line has an unknown action, so the rules stay as
|
||||
// they were, the first line's earlier version included.
|
||||
save(t, dir, firstFile, "first path block ^/edited\nsecond path bann ^/second\n")
|
||||
|
||||
line := lines.waitFor(t, hasError)
|
||||
want := filepath.Join(dir, firstFile) +
|
||||
`, line 2: the action "bann" is not log, block or ban`
|
||||
|
||||
if line["error"] != want || line["level"] != "ERROR" {
|
||||
t.Errorf("logged %v, want an error %q", line, want)
|
||||
}
|
||||
|
||||
wantMatched(t, files, get(t, "/first"), "first")
|
||||
wantMatched(t, files, get(t, "/second"))
|
||||
|
||||
// Once mended, the file is read again.
|
||||
save(t, dir, firstFile, "first path block ^/edited\nsecond path ban ^/second\n")
|
||||
lines.waitUntil(t, func() bool { return len(files.Match(get(t, "/second"))) == 1 })
|
||||
wantMatched(t, files, get(t, "/edited"), "first")
|
||||
}
|
||||
|
||||
func TestDefaultFileBansProbesAtTheSiteRootAlone(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
params, _ := newParams(filepath.Join("..", "..", "share", "rules.d"))
|
||||
|
||||
files, err := rules.Load(params)
|
||||
if err != nil {
|
||||
t.Fatalf("load the default file: %v", err)
|
||||
}
|
||||
|
||||
// Probes sent by a browser, by the rule that refuses them.
|
||||
for rule, targets := range map[string][]string{
|
||||
"env-file": {"/.env", "/.env.production", "/.ENV"},
|
||||
"vcs-dir": {"/.git/config", "/.git", "/.svn/entries"},
|
||||
"secrets-dir": {"/.aws/credentials", "/.ssh/id_rsa"},
|
||||
"secret-file": {"/.htpasswd", "/.DS_Store", "/.git-credentials"},
|
||||
"editor-dir": {"/.vscode/sftp.json"},
|
||||
"backup-file": {
|
||||
"/wp-config.php.bak", "/index.php~", "/dump.sql", "/backup.sql.gz",
|
||||
},
|
||||
"log-file": {"/debug.log"},
|
||||
"compose-file": {"/docker-compose.yml", "/compose.yaml"},
|
||||
"php-shell": {"/shell.php"},
|
||||
"path-traversal": {
|
||||
"/static/../../etc/passwd", "/f?f=%2e%2e%2f%2e%2e%2fetc%2fpasswd",
|
||||
},
|
||||
} {
|
||||
for _, target := range targets {
|
||||
wantRefusedBy(t, files, target, browser, rule)
|
||||
}
|
||||
}
|
||||
|
||||
// Scanners, by their user agents.
|
||||
for _, scanner := range []string{
|
||||
"sqlmap/1.8.4#stable (https://sqlmap.org)",
|
||||
"Mozilla/5.0 (compatible; Nuclei - Open-source project)",
|
||||
} {
|
||||
wantRefusedBy(t, files, "/", scanner, "scanner-agent")
|
||||
}
|
||||
|
||||
// Ordinary requests to a code forge for files of those names deeper
|
||||
// in its paths, and for other files at its root.
|
||||
for _, target := range []string{
|
||||
"/owner/repo/src/branch/main/.env.example",
|
||||
"/owner/repo/src/branch/main/.env",
|
||||
"/owner/repo/src/branch/main/.github/workflows/ci.yml",
|
||||
"/owner/repo/src/branch/main/.vscode/settings.json",
|
||||
"/owner/repo/src/branch/main/.htaccess",
|
||||
"/owner/repo/src/branch/main/docker-compose.yml",
|
||||
"/owner/repo/src/branch/main/db/schema.sql",
|
||||
"/owner/repo/raw/branch/main/debug.log",
|
||||
"/owner/repo.git/info/refs?service=git-upload-pack",
|
||||
"/owner/repo/src/branch/main/docs/../README.md",
|
||||
"/user/login?redirect_to=%2fowner%2frepo",
|
||||
"/index.php",
|
||||
"/.well-known/security.txt",
|
||||
} {
|
||||
r := get(t, target)
|
||||
r.Header.Set(userAgent, browser)
|
||||
|
||||
matched := files.Match(r)
|
||||
if len(matched) != 0 {
|
||||
t.Errorf("%s matched %v, want no rule", target, ids(matched))
|
||||
}
|
||||
}
|
||||
|
||||
// A request without a user agent is only noted.
|
||||
wantMatched(t, files, get(t, "/"), "empty-agent")
|
||||
}
|
||||
|
||||
// ruleFiles are files to write into a directory of rule files, by name.
|
||||
type ruleFiles map[string]string
|
||||
|
||||
// writeFiles writes files into a new directory, and returns it.
|
||||
func writeFiles(t *testing.T, files ruleFiles) string {
|
||||
t.Helper()
|
||||
|
||||
dir := t.TempDir()
|
||||
|
||||
for name, content := range files {
|
||||
path := filepath.Join(dir, name)
|
||||
|
||||
err := os.MkdirAll(filepath.Dir(path), 0o700)
|
||||
if err != nil {
|
||||
t.Fatalf("mkdir: %v", err)
|
||||
}
|
||||
|
||||
err = os.WriteFile(path, []byte(content), 0o600)
|
||||
if err != nil {
|
||||
t.Fatalf("write %s: %v", name, err)
|
||||
}
|
||||
}
|
||||
|
||||
return dir
|
||||
}
|
||||
|
||||
// save writes content to the rule file name in dir as an editor that
|
||||
// saves by renaming does, so that the file is never seen half written.
|
||||
func save(t *testing.T, dir, name, content string) {
|
||||
t.Helper()
|
||||
|
||||
path := filepath.Join(dir, name)
|
||||
|
||||
err := os.WriteFile(path+".tmp", []byte(content), 0o600)
|
||||
if err != nil {
|
||||
t.Fatalf("write %s: %v", name, err)
|
||||
}
|
||||
|
||||
err = os.Rename(path+".tmp", path)
|
||||
if err != nil {
|
||||
t.Fatalf("rename: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// newParams returns Params for the rule files in dir, switched on, with
|
||||
// the process log in the processLog returned.
|
||||
func newParams(dir string) (rules.Params, processLog) {
|
||||
lines := make(processLog, maxLogLines)
|
||||
|
||||
return rules.Params{
|
||||
Dir: dir,
|
||||
Enabled: true,
|
||||
ProcessLog: slog.New(slog.NewJSONHandler(lines, nil)),
|
||||
}, lines
|
||||
}
|
||||
|
||||
// load writes files into a new directory and loads the rules in it.
|
||||
func load(t *testing.T, files ruleFiles) *rules.Files {
|
||||
t.Helper()
|
||||
|
||||
params, _ := newParams(writeFiles(t, files))
|
||||
params.ProcessLog = slog.New(slog.DiscardHandler)
|
||||
|
||||
loaded, err := rules.Load(params)
|
||||
if err != nil {
|
||||
t.Fatalf("load: %v", err)
|
||||
}
|
||||
|
||||
return loaded
|
||||
}
|
||||
|
||||
// watch loads the rules in dir, runs their Watch until the test ends, and
|
||||
// waits until it watches the directory.
|
||||
func watch(t *testing.T, dir string) (*rules.Files, processLog) {
|
||||
t.Helper()
|
||||
|
||||
params, lines := newParams(dir)
|
||||
|
||||
files, err := rules.Load(params)
|
||||
if err != nil {
|
||||
t.Fatalf("load: %v", err)
|
||||
}
|
||||
|
||||
ctx, stop := context.WithCancel(t.Context())
|
||||
stopped := make(chan struct{})
|
||||
|
||||
go func() {
|
||||
files.Watch(ctx)
|
||||
close(stopped)
|
||||
}()
|
||||
|
||||
t.Cleanup(func() {
|
||||
stop()
|
||||
<-stopped
|
||||
})
|
||||
|
||||
lines.waitFor(t, watching)
|
||||
|
||||
return files, lines
|
||||
}
|
||||
|
||||
// wantRefused checks that loading the rule files in dir fails with the
|
||||
// error want.
|
||||
func wantRefused(t *testing.T, dir, want string) {
|
||||
t.Helper()
|
||||
|
||||
params, _ := newParams(dir)
|
||||
|
||||
_, err := rules.Load(params)
|
||||
if err == nil || err.Error() != want {
|
||||
t.Errorf("error %v, want %s", err, want)
|
||||
}
|
||||
}
|
||||
|
||||
// get returns a GET request for target, a path and an optional query, as
|
||||
// smallwebwaf's server reads it, without a user agent.
|
||||
func get(t *testing.T, target string) *http.Request {
|
||||
t.Helper()
|
||||
|
||||
return httptest.NewRequestWithContext(t.Context(), http.MethodGet,
|
||||
"http://app.example"+target, nil)
|
||||
}
|
||||
|
||||
// wantRefusedBy checks that a GET request for target with the user agent
|
||||
// sent matches rule alone, and that rule refuses it.
|
||||
func wantRefusedBy(t *testing.T, files *rules.Files, target, sent, rule string) {
|
||||
t.Helper()
|
||||
|
||||
r := get(t, target)
|
||||
r.Header.Set(userAgent, sent)
|
||||
|
||||
matched := files.Match(r)
|
||||
if len(matched) != 1 || matched[0].ID != rule || matched[0].Action == rules.ActionLog {
|
||||
t.Errorf("%s from %q matched %v, want %s alone, refusing it", target,
|
||||
sent, ids(matched), rule)
|
||||
}
|
||||
}
|
||||
|
||||
// wantMatched checks the ids of the rules r matches, in order.
|
||||
func wantMatched(t *testing.T, files *rules.Files, r *http.Request, want ...string) {
|
||||
t.Helper()
|
||||
|
||||
got := ids(files.Match(r))
|
||||
if !slices.Equal(got, want) {
|
||||
t.Errorf("%s matched %v, want %v", r.URL, got, want)
|
||||
}
|
||||
}
|
||||
|
||||
// ids returns the ids of matched.
|
||||
func ids(matched []rules.Rule) []string {
|
||||
got := make([]string, 0, len(matched))
|
||||
for _, rule := range matched {
|
||||
got = append(got, rule.ID)
|
||||
}
|
||||
|
||||
return got
|
||||
}
|
||||
|
||||
// processLog receives the lines of a process log, each a JSON object, for
|
||||
// a test to wait for.
|
||||
type processLog chan string
|
||||
|
||||
// Write receives a line of the process log.
|
||||
func (l processLog) Write(line []byte) (int, error) {
|
||||
l <- string(line)
|
||||
|
||||
return len(line), nil
|
||||
}
|
||||
|
||||
// waitFor returns the next line of the process log whose message is msg,
|
||||
// passing over the lines before it. It waits as long as that takes, so
|
||||
// that a slow test process cannot fail the test.
|
||||
func (l processLog) waitFor(t *testing.T, msg string) map[string]any {
|
||||
t.Helper()
|
||||
|
||||
for line := range l {
|
||||
var fields map[string]any
|
||||
|
||||
err := json.Unmarshal([]byte(line), &fields)
|
||||
if err != nil {
|
||||
t.Fatalf("process log line %q is not JSON: %v", line, err)
|
||||
}
|
||||
|
||||
if fields["msg"] == msg {
|
||||
return fields
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// 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. One
|
||||
// change can be seen more than once, and so read more than once.
|
||||
func (l processLog) waitUntil(t *testing.T, done func() bool) {
|
||||
t.Helper()
|
||||
|
||||
for !done() {
|
||||
l.waitFor(t, read)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user