package waf_test import ( "net/http" "net/http/httptest" "net/netip" "net/url" "path/filepath" "reflect" "strconv" "strings" "testing" "sneak.berlin/go/smallwebwaf/internal/waf" ) // defaultDisabledRules are the rules SWWAF_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) *waf.CoreRuleSet { t.Helper() crs, err := waf.New(waf.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 *waf.CoreRuleSet, r request) waf.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 *waf.CoreRuleSet, r request, body string, ) (waf.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 *waf.CoreRuleSet, r request, want waf.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) waf.Result { const critical = 5 return waf.Result{RuleIDs: ids, Score: critical * len(ids)} } // atDefaults returns the Core Rule Set as smallwebwaf runs it by default. func atDefaults(t *testing.T) *waf.CoreRuleSet { t.Helper() return newCoreRuleSet(t, 1, defaultDisabledRules...) } // 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 // like it that the change is not for. func wantChange( t *testing.T, crs *waf.CoreRuleSet, passes, refused request, result waf.Result, ) { t.Helper() wantResult(t, crs, passes, waf.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"}}, waf.Result{}) wantResult(t, crs, request{http.MethodPost, fetch, []string{ fetchType, length, "Content-Encoding: gzip", }}, waf.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", }}, waf.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 waf.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"), waf.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 TestRefererIsNotCheckedForACommandOrJavaStartingAProcess(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" ) 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)) // It is still checked for script and SQL injection. wantResult(t, crs, get("/", "Referer: https://git.example/?q="), 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: "), waf.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, waf.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, waf.Result{RuleIDs: []int{911100, 920350}, Score: 8}) wantResult(t, newCoreRuleSet(t, 1, 920350, 911100), r, waf.Result{}) } // bodyLimit is SWWAF_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 smallwebwaf runs it by // default, but reading bodies up to bodyLimit. func readingBodies(t *testing.T) *waf.CoreRuleSet { t.Helper() crs, err := waf.New(waf.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 *waf.CoreRuleSet, r request, body string, want waf.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, "" + injection + ""}, } { wantBody(t, off, post(tc.header), tc.body, waf.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, waf.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, waf.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, "" + injection + "", ""}, } { 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, waf.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, waf.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, "1"}, } { 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", waf.Result{}, "") wantBody(t, atDefaults(t), post(formData, gzip), "a", waf.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 waf.Result }{ {"path=.gitignore", waf.Result{}}, {"q=.gitignore", matched(930120)}, {"redirect_uri=" + local, waf.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, waf.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", "" + injection + ""}, {"application/vnd.example+xml", "" + injection + ""}, {"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 := "" + injection + "" 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 := waf.New(waf.Params{ParanoiaLevel: 1, BodyLimit: 1 << 30}) if err != nil { t.Errorf("load the Core Rule Set reading bodies up to 1G: %v", err) } }