Name log fields, metrics and the Core Rule Set package for what they are (closes #192)
check / check (push) Failing after 3m22s
check / check (push) Failing after 3m22s
The first half of the naming audit: names now say what they are. `internal/waf` is `internal/coreruleset`. Log fields, the action `blocked_by_core_rule_set`, the metric and the alert event say Core Rule Set. Timings end `_ms`. Percentages say of what, as `rate_limit_percent` and `bytes_limit_percent`. Setting names follow in #234. Judgement call: `blocked_by_core_rule_set`, not the audit's `core_rule_set_blocked`, to match `blocked_by_block_rule`. Trips a reader: an existing state file holding `waf_blocked` or the anomaly scope `watch` stops the start and must be removed. `SWW_ALERT_EVENTS` listing `waf_block` must list `core_rule_set_match`. Model: opus-5-5
This commit was merged in pull request #242.
This commit is contained in:
1 parent
aede434f6a
commit
719691d1e8
58 files changed
+743
-691
No files matched your search
@@ -0,0 +1,882 @@
|
||||
package coreruleset_test
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/netip"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"sneak.berlin/go/smallwebwaf/internal/coreruleset"
|
||||
)
|
||||
|
||||
// defaultDisabledRules are the rules SWW_WAF_DISABLED_RULES switches off
|
||||
// by default.
|
||||
//
|
||||
//nolint:gochecknoglobals // a constant cannot be a list
|
||||
var defaultDisabledRules = []int{920340, 920420, 920440, 920640, 930130, 930140}
|
||||
|
||||
// newCoreRuleSet returns the Core Rule Set at paranoia level level, with
|
||||
// the rules in disabled switched off.
|
||||
func newCoreRuleSet(t *testing.T, level int, disabled ...int) *coreruleset.CoreRuleSet {
|
||||
t.Helper()
|
||||
|
||||
crs, err := coreruleset.New(coreruleset.Params{
|
||||
ParanoiaLevel: level, DisabledRules: disabled,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("load the Core Rule Set: %v", err)
|
||||
}
|
||||
|
||||
return crs
|
||||
}
|
||||
|
||||
// request is a request a test inspects: its method, its target, the path
|
||||
// and the query as a client sends them, and its headers, each written
|
||||
// "Name: value".
|
||||
type request struct {
|
||||
method, target string
|
||||
headers []string
|
||||
}
|
||||
|
||||
// get is a GET request for target with headers.
|
||||
func get(target string, headers ...string) request {
|
||||
return request{http.MethodGet, target, headers}
|
||||
}
|
||||
|
||||
// inspect returns what crs finds in r, sent to git.example by a browser,
|
||||
// whose Host, User-Agent and Accept r.headers may replace.
|
||||
func inspect(t *testing.T, crs *coreruleset.CoreRuleSet, r request) coreruleset.Result {
|
||||
t.Helper()
|
||||
|
||||
result, _ := inspectBody(t, crs, r, "")
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// inspectBody is inspect for r with body, which is announced with its
|
||||
// Content-Length unless it is "", and returns what crs read of body too.
|
||||
func inspectBody(
|
||||
t *testing.T, crs *coreruleset.CoreRuleSet, r request, body string,
|
||||
) (coreruleset.Result, string) {
|
||||
t.Helper()
|
||||
|
||||
req := httptest.NewRequestWithContext(t.Context(), r.method,
|
||||
"http://git.example"+r.target, strings.NewReader(body))
|
||||
req.Header.Set("User-Agent", "Mozilla/5.0 (X11; Linux x86_64; rv:131.0) "+
|
||||
"Gecko/20100101 Firefox/131.0")
|
||||
req.Header.Set("Accept", "text/html")
|
||||
|
||||
if body != "" {
|
||||
req.Header.Set("Content-Length", strconv.Itoa(len(body)))
|
||||
}
|
||||
|
||||
for _, header := range r.headers {
|
||||
// Go's server keeps Host and Transfer-Encoding out of the headers.
|
||||
name, value, _ := strings.Cut(header, ": ")
|
||||
switch name {
|
||||
case "Host":
|
||||
req.Host = value
|
||||
case "Transfer-Encoding":
|
||||
req.TransferEncoding = []string{value}
|
||||
default:
|
||||
req.Header.Set(name, value)
|
||||
}
|
||||
}
|
||||
|
||||
result, read, err := crs.Inspect(req, netip.MustParseAddr("203.0.113.9"), req.Body)
|
||||
if err != nil {
|
||||
t.Fatalf("read the body: %v", err)
|
||||
}
|
||||
|
||||
return result, string(read)
|
||||
}
|
||||
|
||||
// wantResult checks what crs finds in r.
|
||||
func wantResult(
|
||||
t *testing.T, crs *coreruleset.CoreRuleSet, r request, want coreruleset.Result,
|
||||
) {
|
||||
t.Helper()
|
||||
|
||||
if got := inspect(t, crs, r); !reflect.DeepEqual(got, want) {
|
||||
t.Errorf("%s %s %q: %+v, want %+v", r.method, r.target, r.headers, got, want)
|
||||
}
|
||||
}
|
||||
|
||||
// matched is the result of a request that the rules ids match, each of
|
||||
// them a critical one, which adds 5 to the score.
|
||||
func matched(ids ...int) coreruleset.Result {
|
||||
const critical = 5
|
||||
|
||||
return coreruleset.Result{RuleIDs: ids, Score: critical * len(ids)}
|
||||
}
|
||||
|
||||
// atDefaults returns the Core Rule Set as sww runs it by default.
|
||||
func atDefaults(t *testing.T) *coreruleset.CoreRuleSet {
|
||||
t.Helper()
|
||||
|
||||
return newCoreRuleSet(t, 1, defaultDisabledRules...)
|
||||
}
|
||||
|
||||
// wantChange checks that crs lets through passes, a gitea request one of
|
||||
// the eight 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 *coreruleset.CoreRuleSet, passes, refused request,
|
||||
result coreruleset.Result,
|
||||
) {
|
||||
t.Helper()
|
||||
|
||||
wantResult(t, crs, passes, coreruleset.Result{})
|
||||
wantResult(t, crs, refused, result)
|
||||
}
|
||||
|
||||
func TestPutPatchAndDeleteAreAllowed(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
crs := atDefaults(t)
|
||||
|
||||
for _, r := range []request{
|
||||
{http.MethodPut, "/v2/owner/image/blobs/uploads/1?digest=sha256:ab", nil},
|
||||
{http.MethodPatch, "/api/v1/repos/owner/repo/issues/1", nil},
|
||||
{http.MethodDelete, "/api/v1/repos/owner/repo/branches/old", nil},
|
||||
} {
|
||||
wantChange(t, crs, r, request{http.MethodTrace, r.target, nil}, matched(911100))
|
||||
}
|
||||
|
||||
wantChange(t, crs, get("/"), request{"PROPFIND", "/", nil}, matched(911100))
|
||||
}
|
||||
|
||||
func TestExpectAndContentEncodingAreAllowed(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
const (
|
||||
pushType = "Content-Type: application/x-git-receive-pack-request"
|
||||
fetchType = "Content-Type: application/x-git-upload-pack-request"
|
||||
length = "Content-Length: 1024"
|
||||
push = "/owner/repo.git/git-receive-pack"
|
||||
fetch = "/owner/repo.git/git-upload-pack"
|
||||
)
|
||||
|
||||
crs := atDefaults(t)
|
||||
|
||||
wantResult(t, crs,
|
||||
request{http.MethodPost, push, []string{pushType, length, "Expect: 100-continue"}},
|
||||
coreruleset.Result{})
|
||||
wantResult(t, crs,
|
||||
request{http.MethodPost, fetch, []string{
|
||||
fetchType, length, "Content-Encoding: gzip",
|
||||
}},
|
||||
coreruleset.Result{})
|
||||
|
||||
// Every other header on the Core Rule Set's list stays refused.
|
||||
for _, header := range []string{
|
||||
"Proxy: http://proxy.example",
|
||||
"Lock-Token: token",
|
||||
"Content-Range: bytes 0-1023/1024",
|
||||
"If: token",
|
||||
"X-HTTP-Method-Override: DELETE",
|
||||
"X-HTTP-Method: DELETE",
|
||||
"X-Method-Override: DELETE",
|
||||
"X-Middleware-Subrequest: middleware",
|
||||
} {
|
||||
wantResult(t, crs,
|
||||
request{http.MethodPost, push, []string{pushType, length, header}},
|
||||
matched(920450))
|
||||
}
|
||||
}
|
||||
|
||||
func TestTransferEncodingIsRead(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// git sends a large push in chunks, with no Content-Length. Without
|
||||
// Transfer-Encoding, that would be a POST without a length (920180).
|
||||
wantResult(t, atDefaults(t),
|
||||
request{http.MethodPost, "/owner/repo.git/git-receive-pack", []string{
|
||||
"Content-Type: application/x-git-receive-pack-request",
|
||||
"Transfer-Encoding: chunked",
|
||||
}},
|
||||
coreruleset.Result{})
|
||||
}
|
||||
|
||||
func TestMoreParametersThanCorazaKeepsIsAMatch(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
const attack = "id=1'%20OR%20'1'='1"
|
||||
|
||||
crs := atDefaults(t)
|
||||
|
||||
// Coraza keeps 1000: an attack that is the 1000th is read, and one
|
||||
// after it is not, but the request is a match all the same.
|
||||
wantResult(t, crs, get("/?"+strings.Repeat("a=1&", 999)+attack), matched(942100))
|
||||
wantResult(t, crs, get("/?"+strings.Repeat("a=1&", 1000)+attack), matched(900300))
|
||||
|
||||
// So it is with the fields of a form data or JSON body.
|
||||
crs = readingBodies(t)
|
||||
|
||||
for _, tc := range []struct{ header, body string }{
|
||||
{formData, strings.Repeat("a=1&", 999) + attack},
|
||||
{jsonBody, `{"a":[` + strings.Repeat("1,", 998) + `1],"id":"` + injection + `"}`},
|
||||
} {
|
||||
wantBody(t, crs, post(tc.header), tc.body, matched(942100), tc.body)
|
||||
}
|
||||
|
||||
for _, tc := range []struct{ header, body string }{
|
||||
{formData, strings.Repeat("a=1&", 1000) + attack},
|
||||
{jsonBody, `{"a":[` + strings.Repeat("1,", 999) + `1],"id":"` + injection + `"}`},
|
||||
} {
|
||||
wantBody(t, crs, post(tc.header), tc.body, matched(900300), tc.body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRedirectURIMayNameALocalAddress(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
const oauth = "/login/oauth/authorize?client_id=tea&response_type=code&"
|
||||
|
||||
crs := atDefaults(t)
|
||||
|
||||
wantChange(t, crs, get(oauth+"redirect_uri=http://127.0.0.1:52341/"),
|
||||
get(oauth+"next=http://127.0.0.1:52341/"), matched(931100, 934110))
|
||||
wantChange(t, crs, get(oauth+"redirect_uri=http://localhost:52341/"),
|
||||
get(oauth+"next=http://localhost:52341/"), matched(934110))
|
||||
}
|
||||
|
||||
func TestParametersGiteaSendsNamesInSkipTheListsOfFilesPathsAndCommands(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
crs := atDefaults(t)
|
||||
|
||||
for _, value := range []struct {
|
||||
name string
|
||||
// result is what a parameter that is not one of gitea's gets.
|
||||
result coreruleset.Result
|
||||
}{
|
||||
// A file on the list of system files.
|
||||
{".gitignore", matched(930120)},
|
||||
// A command's name, after a directory on the list of shell paths.
|
||||
{"bin/docker-entrypoint", matched(932260, 932160)},
|
||||
} {
|
||||
for _, parameter := range []string{
|
||||
"path", "files", "skip-to", "sub_path", "ref", "sha", "branch", "workflow",
|
||||
"artifactName", "redirect_to",
|
||||
} {
|
||||
wantChange(t, crs, get("/?"+parameter+"="+value.name),
|
||||
get("/?q="+value.name), value.result)
|
||||
}
|
||||
}
|
||||
|
||||
// What only those rules refuse gets through there too, but path
|
||||
// traversal and SQL injection are still refused.
|
||||
wantChange(t, crs, get("/?path=|cat%20/etc/passwd"), get("/?q=|cat%20/etc/passwd"),
|
||||
matched(930120, 932160))
|
||||
wantResult(t, crs, get("/?path=../../etc/passwd"),
|
||||
coreruleset.Result{RuleIDs: []int{930100, 930110}, Score: 20})
|
||||
wantResult(t, crs, get("/?path=1'%20OR%20'1'='1"), matched(942100))
|
||||
}
|
||||
|
||||
func TestParameterNamesAreMatchedWithoutRegardToCase(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
crs := atDefaults(t)
|
||||
|
||||
wantChange(t, crs, get("/?Path=.gitignore"), get("/?q=.gitignore"), matched(930120))
|
||||
wantChange(t, crs, get("/?REDIRECT_URI=http://127.0.0.1:52341/"),
|
||||
get("/?next=http://127.0.0.1:52341/"), matched(931100, 934110))
|
||||
}
|
||||
|
||||
func TestCookiesGiteaFlashAndRedirectToAreNotRead(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
const (
|
||||
flash = "success%3DFile%2Bpackage.json%2Bdeleted"
|
||||
redirectTo = "%2Fowner%2Frepo%2Fsrc%2Fbranch%2Fmain%2Fpackage.json"
|
||||
)
|
||||
|
||||
crs := atDefaults(t)
|
||||
|
||||
wantChange(t, crs, get("/owner/repo", "Cookie: gitea_flash="+flash),
|
||||
get("/owner/repo", "Cookie: flash="+flash), matched(930120))
|
||||
wantChange(t, crs, get("/", "Cookie: redirect_to="+redirectTo),
|
||||
get("/", "Cookie: redirect="+redirectTo), matched(930120))
|
||||
|
||||
// Among other cookies, which are read.
|
||||
wantChange(t, crs,
|
||||
get("/", "Cookie: lang=en-US; gitea_flash="+flash+"; redirect_to="+redirectTo+
|
||||
"; i_like_gitea=abc"),
|
||||
get("/", "Cookie: lang=en-US; gitea_flash="+flash+"; redirect="+redirectTo+
|
||||
"; i_like_gitea=abc"),
|
||||
matched(930120))
|
||||
}
|
||||
|
||||
func TestRefererIsNotCheckedForACommandOrJava(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
const (
|
||||
search = "https://git.example/explore/repos?q=env"
|
||||
runtimeJava = "https://git.example/openjdk/jdk/src/branch/master/src/" +
|
||||
"java.base/share/classes/java/lang/Runtime.java"
|
||||
stringMD = "https://git.example/owner/repo/src/branch/main/java.lang.String.md"
|
||||
)
|
||||
|
||||
crs := atDefaults(t)
|
||||
|
||||
wantChange(t, crs, get("/", "Referer: "+search), get("/", "User-Agent: "+search),
|
||||
matched(932340))
|
||||
wantChange(t, crs, get("/", "Referer: "+runtimeJava),
|
||||
get("/", "X-Page: "+runtimeJava), matched(944110))
|
||||
wantChange(t, crs, get("/", "Referer: "+stringMD), get("/", "X-Page: "+stringMD),
|
||||
matched(944130))
|
||||
|
||||
// It is still checked for script and SQL injection.
|
||||
wantResult(t, crs,
|
||||
get("/", "Referer: https://git.example/?q=<script>alert(1)</script>"),
|
||||
matched(941110, 941160))
|
||||
wantResult(t, crs, get("/", "Referer: https://git.example/?q=1' OR '1'='1"),
|
||||
matched(942100))
|
||||
}
|
||||
|
||||
func TestEmptyHeaderIsRead(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// An empty User-Agent is a notice, which adds 2.
|
||||
wantResult(t, atDefaults(t), get("/", "User-Agent: "),
|
||||
coreruleset.Result{RuleIDs: []int{920330}, Score: 2})
|
||||
}
|
||||
|
||||
func TestParanoiaLevel(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// Accept-Charset is refused from paranoia level 2.
|
||||
r := get("/", "Accept-Charset: utf-8")
|
||||
|
||||
wantResult(t, newCoreRuleSet(t, 1), r, coreruleset.Result{})
|
||||
wantResult(t, newCoreRuleSet(t, 2), r, matched(920451))
|
||||
}
|
||||
|
||||
func TestEachDisabledRuleIsSwitchedOff(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// A method not allowed, and a Host that is an IP address, a warning,
|
||||
// which adds 3.
|
||||
r := request{http.MethodTrace, "/", []string{"Host: 192.0.2.1"}}
|
||||
|
||||
wantResult(t, newCoreRuleSet(t, 1), r,
|
||||
coreruleset.Result{RuleIDs: []int{911100, 920350}, Score: 8})
|
||||
wantResult(t, newCoreRuleSet(t, 1, 920350, 911100), r, coreruleset.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) (*coreruleset.CoreRuleSet, error) {
|
||||
return coreruleset.New(coreruleset.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 := coreruleset.New(coreruleset.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~"), coreruleset.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()
|
||||
|
||||
missing := filepath.Join(t.TempDir(), "missing.conf")
|
||||
|
||||
_, err := withExclusions(missing)
|
||||
if err == nil {
|
||||
t.Errorf("%s loaded", missing)
|
||||
}
|
||||
|
||||
for _, tc := range []struct{ text, want string }{
|
||||
{"SecNoSuchDirective On\n", "unknown directive"},
|
||||
// sww's own rules may not be switched off or changed, by
|
||||
// id or in a range, on one line or split over several.
|
||||
{"SecRuleRemoveById 899999-901000\n", "names 899999-901000"},
|
||||
{
|
||||
"SecAction \"id:1,phase:1,pass,nolog,ctl:ruleRemoveById=899999-901000\"\n",
|
||||
"names 899999-901000",
|
||||
},
|
||||
{
|
||||
"SecAction \"id:1,phase:1,pass,nolog,ctl:ruleRemoveById=899999-+901000\"\n",
|
||||
"names 899999-+901000",
|
||||
},
|
||||
{
|
||||
"SecAction \"id:1,phase:1,pass,nolog,ctl:ruleRemoveTargetById=900500;ARGS:from\"\n",
|
||||
"names 900500",
|
||||
},
|
||||
{"SecRuleRemoveById 899000-\\\n 901000\n", "names 899000-901000"},
|
||||
{
|
||||
"SecAction \"id:1,phase:1,pass,nolog,\\\n" +
|
||||
" ctl:ruleRemoveById=9005\\\n 00\"\n",
|
||||
"names 900500",
|
||||
},
|
||||
// The Core Rule Set's rules load after the file.
|
||||
{"SecRuleRemoveById 942100\n", "SecRuleRemoveById in the exclusions file"},
|
||||
{
|
||||
"SecRuleUpdateTargetById 942100 \"!ARGS:id\"\n",
|
||||
"SecRuleUpdateTargetById in the exclusions file",
|
||||
},
|
||||
} {
|
||||
_, err := withExclusions(exclusionsFile(t, tc.text))
|
||||
if err == nil || !strings.Contains(err.Error(), tc.want) {
|
||||
t.Errorf("%q gave the error %v, want one with %q", tc.text, err, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// giteaRequests returns the recorded ordinary gitea requests in testdata.
|
||||
// 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 TestOrdinaryGiteaRequestsPassAndAttacksAreRefused(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
const threshold = 5 // SWW_WAF_ANOMALY_THRESHOLD's default
|
||||
|
||||
crs := atDefaults(t)
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
// In q too, off gitea's search pages.
|
||||
for _, parameter := range []string{"id", "q"} {
|
||||
for _, attack := range []string{
|
||||
"1'%20OR%20'1'='1",
|
||||
"..%2Fcustom%2Fconf%2Fapp.ini",
|
||||
"%3Cscript%3Ealert(1)%3C%2Fscript%3E",
|
||||
} {
|
||||
target := "/owner/repo?" + parameter + "=" + attack
|
||||
if got := inspect(t, crs, get(target)); got.Score < threshold {
|
||||
t.Errorf("%s let through: %+v", target, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// bodyLimit is SWW_WAF_BODY_LIMIT in the tests that read bodies.
|
||||
const bodyLimit = 8 << 10
|
||||
|
||||
// The Content-Type headers of the kinds of body the Core Rule Set reads.
|
||||
const (
|
||||
formData = "Content-Type: application/x-www-form-urlencoded"
|
||||
multipart = "Content-Type: multipart/form-data; boundary=b"
|
||||
jsonBody = "Content-Type: application/json"
|
||||
xmlBody = "Content-Type: application/xml"
|
||||
)
|
||||
|
||||
// injection is an SQL injection, which rule 942100 matches.
|
||||
const injection = "1' OR '1'='1"
|
||||
|
||||
// readingBodies returns the Core Rule Set as sww runs it by
|
||||
// default, but reading bodies up to bodyLimit.
|
||||
func readingBodies(t *testing.T) *coreruleset.CoreRuleSet {
|
||||
t.Helper()
|
||||
|
||||
crs, err := coreruleset.New(coreruleset.Params{
|
||||
ParanoiaLevel: 1, DisabledRules: defaultDisabledRules, BodyLimit: bodyLimit,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("load the Core Rule Set: %v", err)
|
||||
}
|
||||
|
||||
return crs
|
||||
}
|
||||
|
||||
// post is a POST request for / with a body of the type contentType, a
|
||||
// Content-Type header, gives, and headers besides.
|
||||
func post(contentType string, headers ...string) request {
|
||||
return request{http.MethodPost, "/", append([]string{contentType}, headers...)}
|
||||
}
|
||||
|
||||
// field is a part of a multipart body: the field name, holding value.
|
||||
func field(name, value string) string {
|
||||
return "--b\r\nContent-Disposition: form-data; name=\"" + name + "\"\r\n\r\n" +
|
||||
value + "\r\n"
|
||||
}
|
||||
|
||||
// end ends a multipart body.
|
||||
const end = "--b--\r\n"
|
||||
|
||||
// padded returns head and tail with as many a's between them as make n
|
||||
// bytes in all.
|
||||
func padded(head, tail string, n int) string {
|
||||
return head + strings.Repeat("a", n-len(head)-len(tail)) + tail
|
||||
}
|
||||
|
||||
// wantBody checks what crs finds in r with body, and that what it read of
|
||||
// body is read.
|
||||
func wantBody(
|
||||
t *testing.T, crs *coreruleset.CoreRuleSet, r request, body string,
|
||||
want coreruleset.Result,
|
||||
read string,
|
||||
) {
|
||||
t.Helper()
|
||||
|
||||
got, gotRead := inspectBody(t, crs, r, body)
|
||||
if !reflect.DeepEqual(got, want) || gotRead != read {
|
||||
t.Errorf("%q with a body of %d bytes, %.40q: %+v, reading %d bytes, "+
|
||||
"want %+v, reading %d", r.headers, len(body), body, got, len(gotRead),
|
||||
want, len(read))
|
||||
}
|
||||
}
|
||||
|
||||
func TestBodiesAreReadOnlyWhileBodyLimitIsSet(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
off, on := atDefaults(t), readingBodies(t)
|
||||
|
||||
for _, tc := range []struct{ header, body string }{
|
||||
{formData, "q=" + url.QueryEscape(injection)},
|
||||
{multipart, field("q", injection) + end},
|
||||
{jsonBody, `{"q":"` + injection + `"}`},
|
||||
{xmlBody, "<q>" + injection + "</q>"},
|
||||
} {
|
||||
wantBody(t, off, post(tc.header), tc.body, coreruleset.Result{}, "")
|
||||
wantBody(t, on, post(tc.header), tc.body, matched(942100), tc.body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormDataAndMultipartAreReadUpToTheLimit(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
crs := readingBodies(t)
|
||||
pad := strings.Repeat("a", bodyLimit)
|
||||
|
||||
for _, tc := range []struct{ header, attackFirst, attackLast string }{
|
||||
{
|
||||
formData, "q=" + url.QueryEscape(injection) + "&pad=" + pad,
|
||||
"pad=" + pad + "&q=" + url.QueryEscape(injection),
|
||||
},
|
||||
{
|
||||
multipart, field("q", injection) + field("pad", pad) + end,
|
||||
field("pad", pad) + field("q", injection) + end,
|
||||
},
|
||||
} {
|
||||
wantBody(t, crs, post(tc.header), tc.attackFirst, matched(942100),
|
||||
tc.attackFirst[:bodyLimit+1])
|
||||
wantBody(t, crs, post(tc.header), tc.attackLast, coreruleset.Result{},
|
||||
tc.attackLast[:bodyLimit+1])
|
||||
}
|
||||
|
||||
// To the byte: a system file's path is found when it ends at the limit,
|
||||
// and not when its last letter is past it, which is still read.
|
||||
atLimit := padded("pad=", "&q=/etc/passwd", bodyLimit)
|
||||
wantBody(t, crs, post(formData), atLimit, matched(930120, 932160), atLimit)
|
||||
|
||||
pastLimit := padded("pad=", "&q=/etc/passwd", bodyLimit+1)
|
||||
wantBody(t, crs, post(formData), pastLimit, coreruleset.Result{}, pastLimit)
|
||||
}
|
||||
|
||||
func TestJSONAndXMLAreReadOnlyWhenNoLargerThanTheLimit(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
crs := readingBodies(t)
|
||||
|
||||
for _, tc := range []struct{ header, head, tail string }{
|
||||
{jsonBody, `{"q":"` + injection + `","pad":"`, `"}`},
|
||||
{xmlBody, "<r><q>" + injection + "</q><pad>", "</pad></r>"},
|
||||
} {
|
||||
fits := padded(tc.head, tc.tail, bodyLimit)
|
||||
wantBody(t, crs, post(tc.header), fits, matched(942100), fits)
|
||||
|
||||
larger := padded(tc.head, tc.tail, bodyLimit+1)
|
||||
wantBody(t, crs, post(tc.header), larger, coreruleset.Result{}, larger)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOtherBodiesAreNotRead(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
crs := readingBodies(t)
|
||||
|
||||
// Read as form data, which the Core Rule Set does with a body of a type
|
||||
// it does not know, this would be an SQL injection.
|
||||
body := "q=" + url.QueryEscape(injection)
|
||||
|
||||
for _, header := range []string{
|
||||
"Content-Type: application/octet-stream",
|
||||
"Content-Type: text/plain",
|
||||
"Content-Type: application/x-git-receive-pack-request",
|
||||
} {
|
||||
wantBody(t, crs, post(header), body, coreruleset.Result{}, "")
|
||||
}
|
||||
}
|
||||
|
||||
func TestContentEncodingIsRefusedOnTheKindsOfBodyTheCoreRuleSetReads(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
const gzip = "Content-Encoding: gzip"
|
||||
|
||||
crs := readingBodies(t)
|
||||
|
||||
for _, tc := range []struct{ header, body string }{
|
||||
{formData, "a=1"},
|
||||
{multipart, field("a", "1") + end},
|
||||
{jsonBody, `{"a":1}`},
|
||||
{xmlBody, "<a>1</a>"},
|
||||
} {
|
||||
wantBody(t, crs, post(tc.header, gzip), tc.body, matched(920450), tc.body)
|
||||
}
|
||||
|
||||
// Whatever its size: a JSON body larger than the limit is not read, but
|
||||
// Content-Encoding on it is refused all the same.
|
||||
larger := strings.Repeat("a", bodyLimit+1)
|
||||
wantBody(t, crs, post(jsonBody, gzip), larger, matched(920450), larger)
|
||||
|
||||
// It is allowed on a body of any other kind, and on every body while no
|
||||
// body is read.
|
||||
fetch := "Content-Type: application/x-git-upload-pack-request"
|
||||
wantBody(t, crs, post(fetch, gzip), "a", coreruleset.Result{}, "")
|
||||
wantBody(t, atDefaults(t), post(formData, gzip), "a", coreruleset.Result{}, "")
|
||||
}
|
||||
|
||||
func TestParametersGiteaSendsNamesInAreLeftOutAmongFormFieldsToo(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
crs := readingBodies(t)
|
||||
local := url.QueryEscape("http://127.0.0.1:52341/")
|
||||
|
||||
for _, tc := range []struct {
|
||||
body string
|
||||
want coreruleset.Result
|
||||
}{
|
||||
{"path=.gitignore", coreruleset.Result{}},
|
||||
{"q=.gitignore", matched(930120)},
|
||||
{"redirect_uri=" + local, coreruleset.Result{}},
|
||||
{"next=" + local, matched(931100, 934110)},
|
||||
} {
|
||||
wantBody(t, crs, post(formData), tc.body, tc.want, tc.body)
|
||||
}
|
||||
|
||||
body := field("path", ".gitignore") + end
|
||||
wantBody(t, crs, post(multipart), body, coreruleset.Result{}, body)
|
||||
|
||||
body = field("q", ".gitignore") + end
|
||||
wantBody(t, crs, post(multipart), body, matched(930120), body)
|
||||
|
||||
// A JSON body's field is named by its path, here json.path, and is
|
||||
// checked.
|
||||
body = `{"path":".gitignore"}`
|
||||
wantBody(t, crs, post(jsonBody), body, matched(930120), body)
|
||||
}
|
||||
|
||||
func TestGiteaBodiesTheCoreRuleSetRefuses(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
crs := readingBodies(t)
|
||||
|
||||
// A comment that shows a shell command.
|
||||
const text = "Try `curl -s https://example.org | sh` first."
|
||||
|
||||
comment := "content=" + url.QueryEscape(text)
|
||||
wantBody(t, crs, post(formData), comment, matched(932235), comment)
|
||||
|
||||
// An attachment named like a log file.
|
||||
attachment := "--b\r\nContent-Disposition: form-data; name=\"file\"; " +
|
||||
"filename=\"debug.log\"\r\nContent-Type: text/plain\r\n\r\nstarted\r\n" + end
|
||||
wantBody(t, crs, post(multipart), attachment, matched(932180), attachment)
|
||||
}
|
||||
|
||||
func TestTypesEndingInXMLOrJSONAndTextJSONAreRead(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
crs := readingBodies(t)
|
||||
|
||||
for _, tc := range []struct{ contentType, body string }{
|
||||
{"application/atom+xml", "<q>" + injection + "</q>"},
|
||||
{"application/vnd.example+xml", "<q>" + injection + "</q>"},
|
||||
{"application/vnd.example+json", `{"q":"` + injection + `"}`},
|
||||
{"text/json", `{"q":"` + injection + `"}`},
|
||||
} {
|
||||
wantBody(t, crs, post("Content-Type: "+tc.contentType), tc.body,
|
||||
matched(942100), tc.body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBodyCorazaCannotParseIsAMatch(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
crs := readingBodies(t)
|
||||
|
||||
// An end tag after the root element, past which Coraza reads none of
|
||||
// the body, while an app may still read the attack before it.
|
||||
body := "<q>" + injection + "</q></r>"
|
||||
wantBody(t, crs, post(xmlBody), body, matched(900440), body)
|
||||
|
||||
// The multipart bodies Coraza cannot parse fail its strict checks too:
|
||||
// one whose type names its boundary twice, and one with a part header
|
||||
// that has no colon, before the attack. They do so padded past the
|
||||
// limit too, which cuts them in the padding.
|
||||
noColon := "--b\r\nContent-Disposition form-data; name=\"a\"\r\n\r\n1\r\n"
|
||||
pad := field("pad", strings.Repeat("a", bodyLimit))
|
||||
|
||||
for _, tc := range []struct{ header, head string }{
|
||||
{multipart + "; boundary=c", ""},
|
||||
{multipart, noColon},
|
||||
} {
|
||||
body = tc.head + field("q", injection) + end
|
||||
wantBody(t, crs, post(tc.header), body, matched(900440, 900450), body)
|
||||
|
||||
body = tc.head + field("q", injection) + pad + end
|
||||
wantBody(t, crs, post(tc.header), body, matched(900440, 900450),
|
||||
body[:bodyLimit+1])
|
||||
}
|
||||
}
|
||||
|
||||
func TestMultipartBodyCutBeforeAPartHeadersColonIsAMatch(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// The limit falls in the middle of the name of the second part's
|
||||
// header, which Coraza, reading up to the limit, cannot tell from a
|
||||
// header without a colon.
|
||||
cut := "--b\r\nContent-Di"
|
||||
first := field("pad", strings.Repeat("a", bodyLimit-len(field("pad", ""))-len(cut)))
|
||||
body := first + cut + "sposition: form-data; name=\"q\"\r\n\r\n1\r\n" + end
|
||||
|
||||
wantBody(t, readingBodies(t), post(multipart), body, matched(900440, 900450),
|
||||
body[:bodyLimit+1])
|
||||
}
|
||||
|
||||
func TestMultipartBodyCutBeforeALineFeedInAPartsHeadersIsAMatch(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
crs := readingBodies(t)
|
||||
headerLine := "--b\r\nContent-Disposition: form-data; name=\"q\"\r"
|
||||
|
||||
// The limit falls between the carriage return and the line feed that
|
||||
// end the second part's header line, and then between those that end
|
||||
// the empty line after it. Coraza, reading up to the limit, takes the
|
||||
// line ending in a lone carriage return for a malformed header.
|
||||
for _, cut := range []int{len(headerLine), len(headerLine + "\n\r")} {
|
||||
first := field("pad", strings.Repeat("a", bodyLimit-len(field("pad", ""))-cut))
|
||||
body := first + field("q", "1") + end
|
||||
|
||||
wantBody(t, crs, post(multipart), body, matched(900440, 900450),
|
||||
body[:bodyLimit+1])
|
||||
}
|
||||
}
|
||||
|
||||
// TestCorazaWritesNoFile is not parallel, since it sets TMPDIR, the
|
||||
// system's temporary directory, for the whole test process.
|
||||
func TestCorazaWritesNoFile(t *testing.T) {
|
||||
// The system's temporary directory is one that does not exist, so that
|
||||
// Coraza could write nothing there: built without no_fs_access, it
|
||||
// refuses to load, and could not write a file of a multipart body.
|
||||
t.Setenv("TMPDIR", filepath.Join(t.TempDir(), "missing"))
|
||||
|
||||
body := "--b\r\nContent-Disposition: form-data; name=\"file\"; " +
|
||||
"filename=\"notes.txt\"\r\nContent-Type: text/plain\r\n\r\n" +
|
||||
strings.Repeat("a", 1000) + "\r\n" + field("q", injection) + end
|
||||
wantBody(t, readingBodies(t), post(multipart), body, matched(942100), body)
|
||||
}
|
||||
|
||||
func TestBodyLimitOf1GLoads(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
_, err := coreruleset.New(coreruleset.Params{ParanoiaLevel: 1, BodyLimit: 1 << 30})
|
||||
if err != nil {
|
||||
t.Errorf("load the Core Rule Set reading bodies up to 1G: %v", err)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user