check / check (push) Waiting to run
Applies the setting renames of the naming audit under the SWW_WAF_ and SWW_RPROXY_ prefixes, with sneak's names for the IP reputation cache time and the two time limits, which now take milliseconds. The rproxy log field default_disposition becomes waf_backend_no_answer, and config.Config and config.FromEnvironment become config.WAF and config.WAFFromEnvironment. Go fields, test names and prose follow. No aliases; nothing reads the old names. Model: opus-5-5
251 lines
7.7 KiB
Go
251 lines
7.7 KiB
Go
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)
|
|
}
|
|
}
|
|
}
|
|
}
|