check / check (push) Waiting to run
Every setting of waf and rproxy is read through viper, in place of the os.LookupEnv function Main passed down. NewViper turns AutomaticEnv and AllowEmptyEnv on, so a setting that is set but empty reads as set. A setting may also be given as a file, the _FILE form, which rproxy's settings now have too; rproxy still writes nothing to disk. The settings are renamed: waf's take the prefix SWW_WAF_, rproxy's take SWW_RPROXY_, and the shared SWW_COMMAND keeps SWW_. The Core Rule Set's settings take SWW_WAF_CORE_RULE_SET_ rather than SWW_WAF_WAF_. Model: deepseek-v4-pro (first version); opus-5-5 (rework)
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_ANOMALY_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_ANOMALY_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_ANOMALY_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)
|
|
}
|
|
}
|
|
}
|
|
}
|