//go:build recorded // The tests that check the apps' recorded requests in testdata. The recorded // tag keeps them out of the test phase's pass with the race detector, and // its second pass, without the race detector, runs them. package coreruleset_test import ( "encoding/json" "os" "path/filepath" "slices" "strings" "testing" "sneak.berlin/go/smallwebwaf/internal/coreruleset" ) // giteaRequests returns the recorded ordinary gitea requests in testdata. // Each has the Referer of a page that names the same, as a page opened // from it has. func giteaRequests(t *testing.T) []request { t.Helper() text, err := os.ReadFile(filepath.Join("testdata", "gitea-requests.txt")) if err != nil { t.Fatal(err) } var requests []request for line := range strings.Lines(string(text)) { line = strings.TrimSpace(line) if line == "" || strings.HasPrefix(line, "#") { continue } target, header, _ := strings.Cut(line, " ") headers := []string{"Referer: http://git.example" + target} if header != "" { headers = append(headers, header) } requests = append(requests, get(target, headers...)) } return requests } func TestOrdinaryGiteaRequestsPass(t *testing.T) { t.Parallel() const threshold = 5 // SWW_WAF_ANOMALY_THRESHOLD's default crs := servingApps(t, map[string][]string{giteaHost: {gitea}}) for _, r := range giteaRequests(t) { if got := inspect(t, crs, withHost(r, giteaHost)); got.Score >= threshold { t.Errorf("%s %q refused: %+v", r.target, r.headers[1:], got) } } } // recordedRequest is a line of a file of recorded requests in testdata. type recordedRequest struct { Method string `json:"method"` Target string `json:"target"` Headers []string `json:"headers"` Body string `json:"body"` } // recordedRequests returns the recorded ordinary requests in the file name // in testdata, each with its body. func recordedRequests(t *testing.T, name string) []recordedRequest { t.Helper() path := filepath.Join("testdata", name) text, err := os.ReadFile(path) //nolint:gosec // a file in testdata if err != nil { t.Fatal(err) } var requests []recordedRequest for line := range strings.Lines(string(text)) { if strings.HasPrefix(line, "#") { continue } var r recordedRequest err := json.Unmarshal([]byte(line), &r) if err != nil { t.Fatalf("%q: %v", line, err) } requests = append(requests, r) } return requests } // wantRecordedRequestsPass checks that none of the recorded ordinary // requests in the file name in testdata is refused on the hostname where // they were recorded, otherHost for WordPress and example.com for the other // apps, while SWW_APPS lists only app for it. They are checked at the // defaults and while bodies are read. With SWW_APPS unset, every app's fixes // apply. These only switch checks off, so the requests pass then too. A // request with neither a body nor a Content-Type header is checked only at // the defaults: with neither, the set that reads bodies has nothing more to // check. Each request runs as its own parallel subtest. func wantRecordedRequestsPass(t *testing.T, name, app string) { t.Helper() const threshold = 5 // SWW_WAF_ANOMALY_THRESHOLD's default apps := map[string][]string{"example.com": {app}, otherHost: {app}} defaults := servingApps(t, apps) bodies := shared(t, coreruleset.Params{ ParanoiaLevel: 1, DisabledRules: defaultDisabledRules, BodyLimit: bodyLimit, Apps: apps, }) for _, recorded := range recordedRequests(t, name) { t.Run(recorded.Method+" "+recorded.Target, func(t *testing.T) { t.Parallel() r := request{recorded.Method, recorded.Target, recorded.Headers} got, _ := inspectBody(t, defaults, r, recorded.Body) if got.Score >= threshold { t.Errorf("%s %s refused: %+v", r.method, r.target, got) } typed := slices.ContainsFunc(recorded.Headers, func(header string) bool { name, _, _ := strings.Cut(header, ":") return strings.EqualFold(name, "Content-Type") }) if recorded.Body == "" && !typed { return } got, _ = inspectBody(t, bodies, r, recorded.Body) if got.Score >= threshold { t.Errorf("%s %s %.200q refused while bodies are read: %+v", r.method, r.target, recorded.Body, got) } }) } } func TestOrdinaryDrupalRequestsPass(t *testing.T) { t.Parallel() wantRecordedRequestsPass(t, "drupal-requests.txt", "drupal") } // TestOrdinaryForgejoRequestsPass checks Forgejo's recorded requests at the // defaults only: with SWW_APPS unset, and on example.com, where they were // recorded, while SWW_APPS lists only forgejo for it. While bodies are read, // the Core Rule Set still refuses the file and branch names some of them // carry in a form or JSON body, as it does gitea's // (TestGiteaBodiesTheCoreRuleSetRefuses). func TestOrdinaryForgejoRequestsPass(t *testing.T) { t.Parallel() const threshold = 5 // SWW_WAF_ANOMALY_THRESHOLD's default requests := recordedRequests(t, "forgejo-requests.txt") forgejoOnly := map[string][]string{"example.com": {forgejo}} for apps, crs := range map[string]*coreruleset.CoreRuleSet{ "SWW_APPS unset": atDefaults(t), "SWW_APPS=example.com:forgejo": servingApps(t, forgejoOnly), } { for _, recorded := range requests { r := request{recorded.Method, recorded.Target, recorded.Headers} if got, _ := inspectBody(t, crs, r, recorded.Body); got.Score >= threshold { t.Errorf("%s: %s %s refused: %+v", apps, r.method, r.target, got) } } } } func TestOrdinaryMastodonRequestsPass(t *testing.T) { t.Parallel() wantRecordedRequestsPass(t, "mastodon-requests.txt", "mastodon") } func TestOrdinaryMediaWikiRequestsPass(t *testing.T) { t.Parallel() wantRecordedRequestsPass(t, "mediawiki-requests.txt", mediawiki) } func TestOrdinaryNextcloudRequestsPass(t *testing.T) { t.Parallel() wantRecordedRequestsPass(t, "nextcloud-requests.txt", nextcloud) } func TestOrdinaryVaultwardenRequestsPass(t *testing.T) { t.Parallel() wantRecordedRequestsPass(t, "vaultwarden-requests.txt", "vaultwarden") } func TestOrdinaryWordPressRequestsPass(t *testing.T) { t.Parallel() wantRecordedRequestsPass(t, "wordpress-requests.txt", wordpress) }