package waf_test import ( "net/http" "net/http/httptest" "net/netip" "reflect" "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() req := httptest.NewRequestWithContext(t.Context(), r.method, "http://git.example"+r.target, http.NoBody) 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") for _, header := range r.headers { name, value, _ := strings.Cut(header, ": ") if name == "Host" { // Go's server keeps it out of the headers. req.Host = value } else { req.Header.Set(name, value) } } return crs.Inspect(req, netip.MustParseAddr("203.0.113.9")) } // 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" otherProxy = "Proxy: http://proxy.example" ) crs := atDefaults(t) wantChange(t, crs, request{http.MethodPost, push, []string{pushType, length, "Expect: 100-continue"}}, request{http.MethodPost, push, []string{pushType, length, otherProxy}}, matched(920450)) wantChange(t, crs, request{http.MethodPost, fetch, []string{ fetchType, length, "Content-Encoding: gzip", }}, request{http.MethodPost, fetch, []string{ fetchType, length, "Content-Range: bytes 0-1023/1024", }}, matched(920450)) } 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 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{}) }