//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" ) // appsUnset labels the checks made while SWW_WAF_APPS is unset. const appsUnset = "SWW_WAF_APPS unset" // 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 } // TestOrdinaryGiteaRequestsPass checks gitea's recorded requests with // SWW_WAF_APPS unset, and on giteaHost while SWW_WAF_APPS lists only gitea for it. func TestOrdinaryGiteaRequestsPass(t *testing.T) { t.Parallel() const threshold = 5 // SWW_WAF_CORE_RULE_SET_ANOMALY_THRESHOLD's default requests := giteaRequests(t) giteaOnly := map[string][]string{giteaHost: {gitea}} for apps, crs := range map[string]*coreruleset.CoreRuleSet{ appsUnset: atDefaults(t), "SWW_WAF_APPS=" + giteaHost + ":" + gitea: servingApps(t, giteaOnly), } { for _, r := range requests { got := inspect(t, crs, withHost(r, giteaHost)) if got.Score >= threshold { t.Errorf( "%s: %s %q refused: %+v", apps, 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: with SWW_WAF_APPS unset, and // on the hostname where they were recorded, otherHost for WordPress and // example.com for the other apps, while SWW_WAF_APPS lists only app for it. Both // times, they are checked at the defaults and while bodies are read. 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 for each setting of // SWW_WAF_APPS. func wantRecordedRequestsPass(t *testing.T, name, app string) { t.Helper() const threshold = 5 // SWW_WAF_CORE_RULE_SET_ANOMALY_THRESHOLD's default requests := recordedRequests(t, name) for setting, apps := range map[string]map[string][]string{ appsUnset: nil, "SWW_WAF_APPS lists only " + app: {"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 requests { t.Run( setting+": "+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_WAF_APPS unset, and on example.com, where they were // recorded, while SWW_WAF_APPS lists only forgejo for it. While bodies are read, // the Core Rule Set still refuses a new wiki page whose title and text name // commands and files, as it does such text in gitea's comments // (TestGiteaBodiesTheCoreRuleSetRefuses). The requests that name a file or a // branch in a body are checked while bodies are read in // TestForgejoNamesInBodiesPass. func TestOrdinaryForgejoRequestsPass(t *testing.T) { t.Parallel() const threshold = 5 // SWW_WAF_CORE_RULE_SET_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{ appsUnset: atDefaults(t), "SWW_WAF_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, ) } } } } // TestGiteaNamesInBodiesPass checks gitea's recorded requests that name a // file or a branch in a body, while bodies are read too. func TestGiteaNamesInBodiesPass(t *testing.T) { t.Parallel() wantRecordedRequestsPass(t, "gitea-names-in-bodies.txt", gitea) } // TestForgejoNamesInBodiesPass is TestGiteaNamesInBodiesPass for Forgejo. func TestForgejoNamesInBodiesPass(t *testing.T) { t.Parallel() wantRecordedRequestsPass(t, "forgejo-names-in-bodies.txt", forgejo) } func TestOrdinaryGhostRequestsPass(t *testing.T) { t.Parallel() wantRecordedRequestsPass(t, "ghost-requests.txt", "ghost") } 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) }