check / check (push) Failing after 1m52s
Recorded from gitea 1.27.3 and Forgejo 17.0.1 with each registry's own client, and curl for the Chef cookbook list, NuGet's Packages() and the counts at Search()/$count and Packages()/$count, with python3, powershell, ls -la, .htaccess, file names and plain words. q on the Cargo, Chef, Composer and Conan searches and NuGet's search service joins 900603. 900605 lets /bin/sh through on Composer and Conan. New: 900609 for npm's text; 900611, in phase 2, lets one q shaped as a Conan reference with no */, such as zlib/*, skip 942100; 900612 for NuGet's Search(), Packages() and their counts: searchTerm, $filter, and the names $filter, $orderby and $top for 942290. Model: opus-5-5
488 lines
16 KiB
Go
488 lines
16 KiB
Go
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=",
|
|
}
|
|
|
|
// giteaRegistryNameSearches 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 giteaRegistryNameSearches = []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,
|
|
giteaRegistryNameSearches,
|
|
)
|
|
}
|
|
|
|
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, and SQL comments that stand in for spaces.
|
|
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",
|
|
} {
|
|
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)
|
|
}
|
|
|
|
// 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))
|
|
}
|