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, and in absolute // form, as a client sends it to a proxy. for _, target := range []string{tc.sent, "http://app.example" + 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:", }, { "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:", }, { "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) } }