package coreruleset_test import ( "net/http" "slices" "strings" "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{binSh, 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) } } } } // giteaAPISearches are the API's searches that gitea and Forgejo share whose // field skips the lists of system files and command names. Each ends in the // field it sends its words in. // //nolint:gochecknoglobals // a constant cannot be a list var giteaAPISearches = []string{ "/api/v1/users/search?q=", "/api/v1/topics/search?q=", "/api/v1/orgs/acme/teams/search?q=", "/api/v1/admin/emails/search?q=", "/api/v1/admin/unadopted?query=", "/api/v1/repos/alice/tools/milestones?name=", } // giteaAPIPackageSearch is the API's search of an owner's packages, whose q // also lets /bin/sh through, since a package name can hold a slash. const giteaAPIPackageSearch = "/api/v1/packages/alice?q=" func TestGiteaAttacksAreRefusedInItsAPISearches(t *testing.T) { t.Parallel() checkSearchesRefuseAttacks( t, giteaAPISearches, []string{giteaAPIPackageSearch}, ) } // checkSearchesRefuseAttacks checks that SQL injection, a path traversal, // script injection and a shell command stay refused in the field of each // search, in the query string and in a form data body. Each search ends in // its field. /bin/sh stays refused too in the field of each of // refusingBinSh, but not of lettingBinSh. func checkSearchesRefuseAttacks( t *testing.T, refusingBinSh, lettingBinSh []string, ) { t.Helper() const threshold = 5 // SWW_WAF_CORE_RULE_SET_SCORE_THRESHOLD's default defaults, bodies := atDefaults(t), readingBodies(t) for _, search := range slices.Concat(refusingBinSh, lettingBinSh) { path, field, _ := strings.Cut(search, "?") for _, attack := range []string{ sqlInjection, pathTraversal, scriptInjection, commandInjection, } { if got := inspect(t, defaults, get(search+attack)); got.Score < threshold { t.Errorf("GET %s%s let through: %+v", search, attack, got) } // The same field in a form data body. r := request{http.MethodPost, path, []string{formData}} if got, _ := inspectBody(t, bodies, r, field+attack); got.Score < threshold { t.Errorf( "POST %s %s%s let through: %+v", path, field, attack, got, ) } } } for _, search := range refusingBinSh { if got := inspect(t, defaults, get(search+binSh)); got.Score < threshold { t.Errorf("%s%s let through: %+v", search, binSh, got) } } } func TestGiteaAPISearchExclusionsAreNarrow(t *testing.T) { t.Parallel() // Each search's field on paths near the searches, and another field on // them. checkValuesStayRefused(t, []string{ "/api/v1/topics/search/x?q=", "/api/v1/orgs/acme/teams?q=", "/api/v1/orgs/acme/teams/search/x?q=", "/api/v1/admin/emails?q=", "/api/v1/admin/unadopted/alice/legacy-site?query=", "/api/v1/admin/users?query=", "/api/v1/repos/alice/tools/milestones/1?name=", "/api/v1/repos/alice/tools/labels?name=", "/api/v1/packages/alice/generic/tools-cli?q=", "/api/v1/topics/search?other=", "/api/v1/orgs/acme/teams/search?other=", "/api/v1/admin/emails/search?other=", "/api/v1/admin/unadopted?q=", "/api/v1/repos/alice/tools/milestones?q=", "/api/v1/packages/alice?other=", }) } // checkValuesStayRefused checks that /bin/sh, a shell command after a ";", a // path traversal and the hard values stay refused at the end of each target. func checkValuesStayRefused(t *testing.T, targets []string) { t.Helper() const threshold = 5 // SWW_WAF_CORE_RULE_SET_SCORE_THRESHOLD's default crs := atDefaults(t) for _, target := range targets { for _, value := range slices.Concat( []string{binSh, commandInjection, pathTraversal}, hardValues, ) { if got := inspect(t, crs, get(target+value)); got.Score < threshold { t.Errorf("%s%s let through: %+v", target, value, got) } } } } // giteaRegistrySearches are the package registries' searches whose field // skips the lists of system files and command names. Each ends in the field // it sends its words in. // //nolint:gochecknoglobals // a constant cannot be a list var giteaRegistrySearches = []string{ "/api/packages/alice/cargo/api/v1/crates?q=", "/api/packages/alice/chef/api/v1/search?q=", "/api/packages/alice/chef/api/v1/cookbooks?q=", "/api/packages/alice/nuget/query?q=", "/api/packages/alice/nuget/Search()?searchTerm=", "/api/packages/alice/nuget/Packages()?$filter=", "/api/packages/alice/nuget/Search()/$count?searchTerm=", "/api/packages/alice/nuget/Packages()/$count?$filter=", } // giteaRegistrySearchesLettingBinSh are the package registries' searches // whose field also lets /bin/sh through, since a Composer, Conan or npm name // can hold a slash. // //nolint:gochecknoglobals // a constant cannot be a list var giteaRegistrySearchesLettingBinSh = []string{ "/api/packages/alice/composer/search.json?q=", "/api/packages/alice/conan/v1/conans/search?q=", "/api/packages/alice/conan/v2/conans/search?q=", "/api/packages/alice/npm/-/v1/search?text=", } func TestGiteaAttacksAreRefusedInItsRegistrySearches(t *testing.T) { t.Parallel() checkSearchesRefuseAttacks( t, giteaRegistrySearches, giteaRegistrySearchesLettingBinSh, ) } func TestGiteaRegistrySearchExclusionsAreNarrow(t *testing.T) { t.Parallel() // Each search's field on paths near the searches, and another field on // them. checkValuesStayRefused(t, []string{ "/api/packages/alice/cargo/api/v1/crates/tools-cli?q=", "/api/packages/alice/chef/api/v1/cookbooks/tools?q=", "/api/packages/alice/composer/packages.json?q=", "/api/packages/alice/conan/v1/conans/tools/1.0/alice/stable/search?q=", "/api/packages/alice/nuget/query/x?q=", "/api/packages/alice/nuget/FindPackagesById()?id=", "/api/packages/alice/nuget/FindPackagesById()/$count?id=", "/api/packages/alice/nuget/Search()/$count/x?searchTerm=", "/api/packages/alice/npm/tools-cli?text=", "/api/packages/alice/cargo/api/v1/crates?other=", "/api/packages/alice/chef/api/v1/search?other=", "/api/packages/alice/composer/search.json?type=", "/api/packages/alice/conan/v2/conans/search?other=", "/api/packages/alice/nuget/query?other=", "/api/packages/alice/nuget/Search()?targetFramework=", "/api/packages/alice/nuget/Packages()?$orderby=", "/api/packages/alice/nuget/Search()/$count?targetFramework=", "/api/packages/alice/nuget/Packages()/$count?$orderby=", "/api/packages/alice/npm/-/v1/search?q=", }) const threshold = 5 // SWW_WAF_CORE_RULE_SET_SCORE_THRESHOLD's default crs := atDefaults(t) // The rule for MongoDB operators (942290) still refuses the name of // another operator on NuGet's searches, and $filter on NuGet's other // paths. The rule for SQL injection (942100) still refuses tools/* in a // field other than a Conan search's, SQL injection in a second q after a // Conan pattern, SQL comments or dots that stand in for spaces, and SQL // injection holding + or --. for _, target := range []string{ "/api/packages/alice/nuget/Search()?$where=1", "/api/packages/alice/nuget/Packages()?$ne=1", "/api/packages/alice/nuget/Search()/$count?$where=1", "/api/packages/alice/nuget/FindPackagesById()?$filter=IsLatestVersion", "/api/packages/alice/nuget/FindPackagesById()/$count?$filter=IsLatestVersion", "/api/packages/alice/composer/search.json?q=tools%2F%2A", "/api/packages/alice/conan/v2/conans/search?other=tools%2F%2A", "/api/packages/alice/conan/v2/conans/search?q=tools%2F%2A&q=" + sqlInjection, "/api/packages/alice/conan/v2/conans/search?q=1%2F%2A%2A%2For%2F%2A%2A%2F1", "/api/packages/alice/conan/v2/conans/search?q=1%2F%2A%40%2A%2For-1", "/api/packages/alice/conan/v2/conans/search?q=1%2Bor%2B1--", "/api/packages/alice/conan/v2/conans/search?q=1-or-1--", "/api/packages/alice/conan/v2/conans/search?q=x%2F1.or.1", "/api/packages/alice/conan/v2/conans/search?q=x%2F1.and.1", "/api/packages/alice/conan/v2/conans/search?q=1.having.1", } { if got := inspect(t, crs, get(target)); got.Score < threshold { t.Errorf("%s let through: %+v", target, got) } } // SQL injection in a q in the body is refused, even after a Conan // pattern in the query string. search := request{ http.MethodPost, "/api/packages/alice/conan/v2/conans/search?q=zlib", []string{formData}, } body := "q=" + sqlInjection if got, _ := inspectBody(t, readingBodies(t), search, body); got.Score < threshold { t.Errorf("POST %s %s let through: %+v", search.target, body, got) } // So is SQL injection with dots for spaces, in a q sent only in the body. search.target = "/api/packages/alice/conan/v2/conans/search" body = "q=x%2F1.or.1" if got, _ := inspectBody(t, readingBodies(t), search, body); got.Score < threshold { t.Errorf("POST %s %s let through: %+v", search.target, body, got) } // The rule that lets a Conan pattern through is in phase 2, where it // checks tx.app_gitea itself: a hostname that does not serve gitea // refuses the pattern. pattern := get( "/api/packages/alice/conan/v2/conans/search?q=tools%2F%2A", ) apps := servingApps(t, map[string][]string{ giteaHost: {gitea}, otherHost: {wordpress}, }) wantResult(t, apps, withHost(pattern, giteaHost), coreruleset.Result{}) wantResult(t, apps, withHost(pattern, otherHost), matched(942100)) }