check / check (push) Waiting to run
Recorded with python3, powershell, ls -la, .htaccess, file names and plain words from gitea 1.27.3 and Forgejo 17.0.1. q on the API's searches of topics, of an organisation's teams and of emails joins 900603, which skips 930120, 932120, 932250 and 932260. The same four rules are skipped by query on the search of unadopted repositories (900607) and by name on the search of a repository's milestones (900608). 900605 also lets /bin/sh through in q on the API's search of packages, since a package name can hold a slash. The searches of repositories, users and issues already passed. Model: opus-5-5
351 lines
11 KiB
Go
351 lines
11 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()
|
|
|
|
const threshold = 5 // SWW_WAF_CORE_RULE_SET_SCORE_THRESHOLD's default
|
|
|
|
defaults, bodies := atDefaults(t), readingBodies(t)
|
|
searches := slices.Concat(
|
|
giteaAPISearches,
|
|
[]string{giteaAPIPackageSearch},
|
|
)
|
|
|
|
for _, search := range searches {
|
|
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,
|
|
)
|
|
}
|
|
}
|
|
}
|
|
|
|
// /bin/sh stays refused on the searches that are not of packages.
|
|
for _, search := range giteaAPISearches {
|
|
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()
|
|
|
|
const threshold = 5 // SWW_WAF_CORE_RULE_SET_SCORE_THRESHOLD's default
|
|
|
|
crs := atDefaults(t)
|
|
|
|
// /bin/sh, a shell command after a ";", a path traversal and the hard
|
|
// values stay refused in each search's field on paths near the
|
|
// searches, and in another field on them.
|
|
for _, target := range []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=",
|
|
} {
|
|
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)
|
|
}
|
|
}
|
|
}
|
|
}
|