package coreruleset_test import ( "net/http" "slices" "testing" "sneak.berlin/go/smallwebwaf/internal/coreruleset" ) // giteaAdminUnadoptedRepos and forgejoAdminUnadoptedRepos are the admin pages // of unadopted repositories. The forms that adopt or delete one post there. const ( giteaAdminUnadoptedRepos = "/-/admin/repos/unadopted" forgejoAdminUnadoptedRepos = "/admin/repos/unadopted" ) // giteaSearches are searches of the pages gitea and Forgejo share whose q // skips the lists of system files and command names, in the eighth change. // gitea serves its admin pages under /-/admin/ and Forgejo under /admin/. // //nolint:gochecknoglobals // a constant cannot be a list var giteaSearches = []string{ "/notifications/watching", "/org/acme/milestones/developers", "/org/acme/teams/-/search", "/alice/-/projects", "/alice/-/packages/generic/tools-cli/versions", "/alice/-/packages/npm/@alice%2Ftools-cli/versions", "/alice/tools/milestones", "/alice/tools/projects", "/alice/tools/issues/suggestions", "/alice/tools/settings/actions/runners", "/org/acme/settings/actions/runners", "/user/settings/actions/runners", "/-/admin/actions/runners", "/-/admin/users", "/-/admin/orgs", "/-/admin/repos", giteaAdminUnadoptedRepos, "/-/admin/emails", "/-/admin/emails/activate", "/admin/actions/runners", "/admin/users", forgejoAdminUnadoptedRepos, "/admin/emails/activate", } // giteaTagAndPackageSearches are the searches of tags and packages, whose q // also lets /bin/sh through, since a tag or package name can hold a slash. // //nolint:gochecknoglobals // a constant cannot be a list var giteaTagAndPackageSearches = []string{ "/alice/tools/tags", "/alice/tools/packages", "/-/admin/packages", "/admin/packages", } func TestGiteaAdminFormsLetHardValuesThroughInTheBody(t *testing.T) { t.Parallel() crs := readingBodies(t) // The forms that adopt a repository and activate an email send the // admin page's search in q, as gitea 1.27.3 and Forgejo 17.0.1 do. for _, value := range hardValues { adopt := "id=alice%2Flegacy-site&action=adopt&q=" + value + "&page=1" for _, path := range []string{giteaAdminUnadoptedRepos, forgejoAdminUnadoptedRepos} { r := request{http.MethodPost, path, []string{formData}} wantBody(t, crs, r, adopt, coreruleset.Result{}, adopt) } activate := "sort=email&q=" + value + "&is_primary=&is_activated=" + "&uid=2&email=bob%40example.com&primary=1&activate=0" for _, path := range []string{"/-/admin/emails/activate", "/admin/emails/activate"} { r := request{http.MethodPost, path, []string{formData}} wantBody(t, crs, r, activate, coreruleset.Result{}, activate) } } } func TestGiteaFormsThatAdoptARepositoryLetItsNameThrough(t *testing.T) { t.Parallel() const threshold = 5 // SWW_WAF_CORE_RULE_SET_SCORE_THRESHOLD's default crs := readingBodies(t) // The admin forms send the repository's owner and name in id, and the // form in a user's settings only its name. robin/assets holds bin/as, a // shell path. ownerAndName := []string{ "alice%2Fpython3-app", "alice%2Fpowershell-scripts", "alice%2F.profile", "robin%2Fassets", } for _, form := range []struct { path string ids []string }{ {giteaAdminUnadoptedRepos, ownerAndName}, {forgejoAdminUnadoptedRepos, ownerAndName}, { "/user/settings/repos/unadopted", // gitea's and Forgejo's []string{"python3-app", "powershell-scripts", ".profile"}, }, } { r := request{http.MethodPost, form.path, []string{formData}} for _, action := range []string{"adopt", "delete"} { for _, id := range form.ids { body := "id=" + id + "&action=" + action wantBody(t, crs, r, body, coreruleset.Result{}, body) } } // Another field keeps those rules. body := "id=" + form.ids[0] + "&action=adopt&page=python3" wantBody(t, crs, r, body, matched(932260), body) // Attacks in id are still refused. for _, attack := range []string{ sqlInjection, scriptInjection, commandInjection, "..%2F..%2Fetc%2Fpasswd", } { body := "id=" + attack + "&action=adopt" if got, _ := inspectBody(t, crs, r, body); got.Score < threshold { t.Errorf("POST %s %s let through: %+v", form.path, body, got) } } } // On another path, id keeps those rules. r := request{http.MethodPost, "/-/admin/repos", []string{formData}} body := "id=alice%2Fpython3-app&action=adopt&q=&page=1" wantBody(t, crs, r, body, matched(932260), body) } func TestGiteaAttacksAreRefusedInItsSearches(t *testing.T) { t.Parallel() const threshold = 5 // SWW_WAF_CORE_RULE_SET_SCORE_THRESHOLD's default defaults, bodies := atDefaults(t), readingBodies(t) for _, path := range slices.Concat(giteaSearches, giteaTagAndPackageSearches) { for _, attack := range []string{ sqlInjection, pathTraversal, scriptInjection, commandInjection, } { if got := inspect(t, defaults, get(path+"?q="+attack)); got.Score < threshold { t.Errorf("GET %s?q=%s let through: %+v", path, attack, got) } // The same field in a form data body. r := request{http.MethodPost, path, []string{formData}} if got, _ := inspectBody(t, bodies, r, "q="+attack); got.Score < threshold { t.Errorf("POST %s q=%s let through: %+v", path, attack, got) } } } } func TestGiteaSearchExclusionsAreNarrow(t *testing.T) { t.Parallel() const threshold = 5 // SWW_WAF_CORE_RULE_SET_SCORE_THRESHOLD's default crs := atDefaults(t) // /bin/sh stays refused in q on the searches that are not of tags or // packages. for _, path := range giteaSearches { if got := inspect(t, crs, get(path+"?q=%2Fbin%2Fsh")); got.Score < threshold { t.Errorf("%s?q=%%2Fbin%%2Fsh let through: %+v", path, got) } } attacks := []string{"%2Fbin%2Fsh", commandInjection, pathTraversal} // /bin/sh, a shell command after a ";", a path traversal and the hard // values stay refused in q on paths near the searches, and in another // field on them. for _, target := range []string{ "/org/acme/teams?q=", "/org/acme/teams/-/search/x?q=", "/org/acme/issues/developers/x?q=", "/alice/-/projects/1?q=", "/alice/-/packages/generic/tools-cli/1.0.0?q=", "/alice/tools/milestones/new?q=", "/alice/tools/milestone/new?q=", "/alice/tools/projects/1?q=", "/alice/tools/packages/x?q=", "/alice/tools/tags/v1.0?q=", "/alice/tools/issues/1?q=", "/alice/tools/settings/actions?q=", "/user/settings/actions?q=", "/-/admin/users/1/edit?q=", "/-/admin/emails/delete?q=", "/-/admin/actions/runners/1?q=", "/-/admin/packages/delete?q=", "/alice/tools/milestones?other=", "/alice/tools/milestone/1?other=", "/alice/tools/tags?other=", "/org/acme/issues/developers?other=", "/org/acme/teams/-/search?other=", "/-/admin/users?other=", "/-/admin/packages?other=", } { for _, value := range slices.Concat(attacks, hardValues) { if got := inspect(t, crs, get(target+value)); got.Score < threshold { t.Errorf("%s%s let through: %+v", target, value, got) } } } // Under /admin/, where Forgejo serves its admin pages, only the hard // values are left out: the tenth change lets them through in q on every // path there, for Drupal. for _, target := range []string{ "/admin/packages/delete?q=", "/admin/packages?other=", } { for _, value := range attacks { if got := inspect(t, crs, get(target+value)); got.Score < threshold { t.Errorf("%s%s let through: %+v", target, value, got) } } } // Referer keeps its rules on a page opened from a path near the // searches of one team and of one milestone. for _, path := range []string{ "/org/acme/issues/developers/x", "/alice/tools/milestone/new", } { for _, attack := range []string{scriptTag, injection} { referer := "Referer: http://example.com" + path + "?q=" + attack if got := inspect(t, crs, get("/", referer)); got.Score < threshold { t.Errorf("%s let through: %+v", referer, got) } } } }