check / check (push) Waiting to run
Recorded from gitea 1.27.3 and Forgejo 17.0.1 with each registry's own client, and curl for the requests no client sends, 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 +, */ or --, such as zlib/*, skip 942100 if 942100 passes it with each /* comment read as a space; 900612 for NuGet's Search(), Packages() and their counts: searchTerm, $filter, and the names $filter, $orderby and $top for 942290. Model: opus-5-5
502 lines
17 KiB
Go
502 lines
17 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=",
|
|
}
|
|
|
|
// 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))
|
|
}
|