Gitea setup: Core Rule Set blocks attacks, not ordinary Gitea traffic (closes #198)
check / check (push) Successful in 3m16s
check / check (push) Successful in 3m16s
The Gitea setup runs the Core Rule Set in block mode. Rules that refused ordinary Gitea requests are switched off only where they misfired: - deploy/gitea/crs-exclusions.conf, loaded through the new setting SWWAF_CORE_RULE_SET_EXCLUSIONS_FILE before the Core Rule Set's rules, covers Gitea's search, file-name and branch-name fields, Referer, file paths, API attachment names and Actions artifact uploads; - smallwebwaf's defaults gain a seventh change: the registry upload path's from parameter skips 932260. A file that cannot be read, does not parse or names an id from 900000 to 900999 stops servewaf at start, in every mode. internal/waf's tests and script/gitea-setup send the recorded requests in internal/waf/testdata/gitea-requests.txt and three attacks. Model: opus-5-5
This commit is contained in:
1 parent
21cac70b50
commit
866b948ec0
17 files changed
+1048
-242
No files matched your search
+163
-1
@@ -5,6 +5,7 @@ import (
|
||||
"net/http/httptest"
|
||||
"net/netip"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"strconv"
|
||||
@@ -119,7 +120,7 @@ func atDefaults(t *testing.T) *waf.CoreRuleSet {
|
||||
}
|
||||
|
||||
// wantChange checks that crs lets through passes, a gitea request one of
|
||||
// the six changes is for, and still finds result in refused, a request
|
||||
// the seven changes is for, and still finds result in refused, a request
|
||||
// like it that the change is not for.
|
||||
func wantChange(
|
||||
t *testing.T, crs *waf.CoreRuleSet, passes, refused request, result waf.Result,
|
||||
@@ -362,6 +363,167 @@ func TestEachDisabledRuleIsSwitchedOff(t *testing.T) {
|
||||
wantResult(t, newCoreRuleSet(t, 1, 920350, 911100), r, waf.Result{})
|
||||
}
|
||||
|
||||
func TestImagePushThatReusesALayerIsNotCheckedForCommandNames(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
crs := atDefaults(t)
|
||||
reuse := "?mount=sha256:0000&from=owner/python3-app"
|
||||
|
||||
wantChange(t, crs, get("/v2/owner/app/blobs/uploads/"+reuse), get("/upload/"+reuse),
|
||||
matched(932260))
|
||||
|
||||
// A command after the image's name is still refused.
|
||||
wantResult(t, crs,
|
||||
get("/v2/owner/app/blobs/uploads/?from=owner/app;cat%20/etc/passwd"),
|
||||
matched(930120, 932160))
|
||||
}
|
||||
|
||||
// withExclusions returns the Core Rule Set at the defaults, with the
|
||||
// exclusions file at path.
|
||||
func withExclusions(path string) (*waf.CoreRuleSet, error) {
|
||||
return waf.New(waf.Params{
|
||||
ParanoiaLevel: 1, DisabledRules: defaultDisabledRules, ExclusionsFile: path,
|
||||
})
|
||||
}
|
||||
|
||||
// exclusionsFile writes text to a file of exclusions and returns its path.
|
||||
func exclusionsFile(t *testing.T, text string) string {
|
||||
t.Helper()
|
||||
|
||||
path := filepath.Join(t.TempDir(), "exclusions.conf")
|
||||
|
||||
err := os.WriteFile(path, []byte(text), 0o600)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
return path
|
||||
}
|
||||
|
||||
// backupFileExclusion is an exclusions file that switches 920500, a rule
|
||||
// of phase 1, off for the path of a backup file under /files/.
|
||||
const backupFileExclusion = `SecRule REQUEST_FILENAME "@beginsWith /files/" ` +
|
||||
`"id:1,phase:1,pass,nolog,ctl:ruleRemoveTargetById=920500;REQUEST_FILENAME"` + "\n"
|
||||
|
||||
func TestExclusionsFileIsLoadedBeforeTheRules(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
crs, err := withExclusions(exclusionsFile(t, backupFileExclusion))
|
||||
if err != nil {
|
||||
t.Fatalf("load the exclusions: %v", err)
|
||||
}
|
||||
|
||||
wantChange(t, crs, get("/files/notes.txt~"), get("/notes.txt~"), matched(920500))
|
||||
}
|
||||
|
||||
func TestRuleSwitchedOffAndNamedInTheExclusionsFileIsOff(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
crs, err := waf.New(waf.Params{
|
||||
ParanoiaLevel: 1, DisabledRules: []int{920500},
|
||||
ExclusionsFile: exclusionsFile(t, backupFileExclusion),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("load the exclusions: %v", err)
|
||||
}
|
||||
|
||||
wantResult(t, crs, get("/notes.txt~"), waf.Result{})
|
||||
}
|
||||
|
||||
func TestExclusionsFileMayNameAnyNumberInAComment(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
_, err := withExclusions(exclusionsFile(t,
|
||||
"# 942100: GET /issues/900123\n"+backupFileExclusion))
|
||||
if err != nil {
|
||||
t.Errorf("the exclusions did not load: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExclusionsFileThatCannotBeUsedIsAnError(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
for _, path := range []string{
|
||||
filepath.Join(t.TempDir(), "missing.conf"),
|
||||
exclusionsFile(t, "SecNoSuchDirective On\n"),
|
||||
// The Core Rule Set's rules are not loaded yet.
|
||||
exclusionsFile(t, "SecRuleUpdateTargetById 942100 \"!ARGS:id\"\n"),
|
||||
// smallwebwaf's own rules may not be switched off or changed, by
|
||||
// id or in a range.
|
||||
exclusionsFile(t, "SecRuleRemoveById 900300\n"),
|
||||
exclusionsFile(t, "SecRuleRemoveById 899999-901000\n"),
|
||||
exclusionsFile(t, "SecAction \"id:1,phase:1,pass,nolog,"+
|
||||
"ctl:ruleRemoveById=899999-901000\"\n"),
|
||||
exclusionsFile(t, "SecAction \"id:1,phase:1,pass,nolog,"+
|
||||
"ctl:ruleRemoveTargetById=900500;ARGS:from\"\n"),
|
||||
} {
|
||||
_, err := withExclusions(path)
|
||||
if err == nil {
|
||||
t.Errorf("%s loaded", path)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// giteaRequests returns the recorded ordinary gitea requests in testdata,
|
||||
// which script/gitea-setup sends too. Each has the Referer of a page that
|
||||
// names the same, as a page opened from it has.
|
||||
func giteaRequests(t *testing.T) []request {
|
||||
t.Helper()
|
||||
|
||||
text, err := os.ReadFile(filepath.Join("testdata", "gitea-requests.txt"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
var requests []request
|
||||
|
||||
for line := range strings.Lines(string(text)) {
|
||||
line = strings.TrimSpace(line)
|
||||
if line == "" || strings.HasPrefix(line, "#") {
|
||||
continue
|
||||
}
|
||||
|
||||
target, header, _ := strings.Cut(line, " ")
|
||||
|
||||
headers := []string{"Referer: http://git.example" + target}
|
||||
if header != "" {
|
||||
headers = append(headers, header)
|
||||
}
|
||||
|
||||
requests = append(requests, get(target, headers...))
|
||||
}
|
||||
|
||||
return requests
|
||||
}
|
||||
|
||||
func TestGiteaExclusionsLetOrdinaryGiteaRequestsThroughAndRefuseAttacks(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
crs, err := withExclusions(filepath.Join("..", "..", "deploy", "gitea",
|
||||
"crs-exclusions.conf"))
|
||||
if err != nil {
|
||||
t.Fatalf("load gitea's exclusions: %v", err)
|
||||
}
|
||||
|
||||
const threshold = 5 // SWWAF_WAF_ANOMALY_THRESHOLD's default
|
||||
|
||||
for _, r := range giteaRequests(t) {
|
||||
if got := inspect(t, crs, r); got.Score >= threshold {
|
||||
t.Errorf("%s %q refused: %+v", r.target, r.headers[1:], got)
|
||||
}
|
||||
}
|
||||
|
||||
for _, attack := range []string{
|
||||
"/owner/repo?id=1'%20OR%20'1'='1",
|
||||
"/owner/repo?id=..%2Fcustom%2Fconf%2Fapp.ini",
|
||||
"/owner/repo?id=%3Cscript%3Ealert(1)%3C%2Fscript%3E",
|
||||
} {
|
||||
if got := inspect(t, crs, get(attack)); got.Score < threshold {
|
||||
t.Errorf("%s let through: %+v", attack, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// bodyLimit is SWWAF_WAF_BODY_LIMIT in the tests that read bodies.
|
||||
const bodyLimit = 8 << 10
|
||||
|
||||
|
||||
Reference in new issue
Block a user