Core Rule Set: WordPress's ordinary requests pass by default (closes #235)
check / check (push) Failing after 16m5s
check / check (push) Failing after 16m5s
Add the ninth change: wordpress.conf switches off, per path and field, each rule that refused ordinary WordPress 7.1.4 requests. Three groups of query fields skip the five rules on system files and commands. Add the recorded requests, a test at the defaults and reading bodies, and list WordPress in the README. Model: opus-5-5 (rework); sonnet-5-5 (copied the recorded lines)
This commit is contained in:
1 parent
719691d1e8
commit
06709f9f65
6 files changed
+1169
-21
No files matched your search
@@ -1,6 +1,7 @@
|
||||
package coreruleset_test
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/netip"
|
||||
@@ -123,9 +124,9 @@ func atDefaults(t *testing.T) *coreruleset.CoreRuleSet {
|
||||
return newCoreRuleSet(t, 1, defaultDisabledRules...)
|
||||
}
|
||||
|
||||
// wantChange checks that crs lets through passes, a gitea request one of
|
||||
// the eight changes is for, and still finds result in refused, a request
|
||||
// like it that the change is not for.
|
||||
// wantChange checks that crs lets through passes, a request one of the
|
||||
// nine changes is for, and still finds result in refused, a request like
|
||||
// it that the change is not for.
|
||||
func wantChange(
|
||||
t *testing.T, crs *coreruleset.CoreRuleSet, passes, refused request,
|
||||
result coreruleset.Result,
|
||||
@@ -555,6 +556,445 @@ func TestOrdinaryGiteaRequestsPassAndAttacksAreRefused(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// wpLogin is WordPress's sign-in path.
|
||||
const wpLogin = "/wp-login.php"
|
||||
|
||||
// recordedRequest is a line of a file of recorded requests in testdata.
|
||||
type recordedRequest struct {
|
||||
Method string `json:"method"`
|
||||
Target string `json:"target"`
|
||||
Headers []string `json:"headers"`
|
||||
Body string `json:"body"`
|
||||
}
|
||||
|
||||
// wordpressRequests returns the recorded ordinary WordPress requests in
|
||||
// testdata, each with its body.
|
||||
func wordpressRequests(t *testing.T) []recordedRequest {
|
||||
t.Helper()
|
||||
|
||||
text, err := os.ReadFile(filepath.Join("testdata", "wordpress-requests.txt"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
var requests []recordedRequest
|
||||
|
||||
for line := range strings.Lines(string(text)) {
|
||||
if strings.HasPrefix(line, "#") {
|
||||
continue
|
||||
}
|
||||
|
||||
var r recordedRequest
|
||||
|
||||
err := json.Unmarshal([]byte(line), &r)
|
||||
if err != nil {
|
||||
t.Fatalf("%q: %v", line, err)
|
||||
}
|
||||
|
||||
requests = append(requests, r)
|
||||
}
|
||||
|
||||
return requests
|
||||
}
|
||||
|
||||
// readingBodiesUpTo64K returns the Core Rule Set as sww runs it by default,
|
||||
// but reading bodies up to the largest SWW_WAF_BODY_LIMIT.
|
||||
func readingBodiesUpTo64K(t *testing.T) *coreruleset.CoreRuleSet {
|
||||
t.Helper()
|
||||
|
||||
crs, err := coreruleset.New(coreruleset.Params{
|
||||
ParanoiaLevel: 1, DisabledRules: defaultDisabledRules, BodyLimit: 64<<10 - 1,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("load the Core Rule Set: %v", err)
|
||||
}
|
||||
|
||||
return crs
|
||||
}
|
||||
|
||||
// wordpressAttackTargets returns attack in a query parameter no exclusion
|
||||
// names, and in each field whose searches and names skip some rules there.
|
||||
func wordpressAttackTargets(attack string) []string {
|
||||
return []string{
|
||||
"/?id=" + attack,
|
||||
wpLogin + "?id=" + attack,
|
||||
"/wp-admin/admin-ajax.php?id=" + attack,
|
||||
"/wp-json/wp/v2/posts?id=" + attack,
|
||||
"/?s=" + attack,
|
||||
"/index.php?s=" + attack,
|
||||
"/page/2/?s=" + attack,
|
||||
"/index.php/page/2/?s=" + attack,
|
||||
"/wp-admin/edit.php?s=" + attack,
|
||||
"/wp-admin/upload.php?s=" + attack,
|
||||
"/wp-admin/plugin-install.php?s=" + attack,
|
||||
"/wp-admin/edit-comments.php?s=" + attack,
|
||||
"/wp-admin/users.php?s=" + attack,
|
||||
"/wp-admin/plugins.php?s=" + attack,
|
||||
"/wp-admin/edit-tags.php?s=" + attack,
|
||||
"/wp-json/wp/v2/search?search=" + attack,
|
||||
"/wp-json/wp/v2/posts?search=" + attack,
|
||||
"/wp-json/wp/v2/tags?search=" + attack,
|
||||
"/?rest_route=/wp/v2/search&search=" + attack,
|
||||
"/wp-json/wp/v2/posts?slug=" + attack,
|
||||
"/wp-json/wp/v2/tags?slug=" + attack,
|
||||
"/?rest_route=/wp/v2/posts&slug=" + attack,
|
||||
"/wp-json/wp/v2/block-directory/search?term=" + attack,
|
||||
"/wp-admin/themes.php?search=" + attack,
|
||||
"/wp-admin/theme-install.php?search=" + attack,
|
||||
"/wp-admin/upload.php?search=" + attack,
|
||||
"/wp-admin/admin-ajax.php?q=" + attack,
|
||||
"/wp-admin/admin-ajax.php?term=" + attack,
|
||||
"/wp-admin/edit.php?_wp_http_referer=" + attack,
|
||||
"/wp-admin/upload.php?_wp_http_referer=" + attack,
|
||||
"/?tag=" + attack,
|
||||
"/index.php?tag=" + attack,
|
||||
"/wp-admin/edit.php?tag=" + attack,
|
||||
"/wp-admin/edit.php?category_name=" + attack,
|
||||
"/wp-admin/plugin-editor.php?file=" + attack,
|
||||
"/wp-admin/theme-editor.php?file=" + attack,
|
||||
"/wp-admin/plugin-editor.php?plugin=" + attack,
|
||||
"/wp-admin/plugins.php?plugin=" + attack,
|
||||
"/wp-admin/update.php?plugin=" + attack,
|
||||
}
|
||||
}
|
||||
|
||||
func TestOrdinaryWordPressRequestsPassAndAttacksAreRefused(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
const threshold = 5 // SWW_WAF_ANOMALY_THRESHOLD's default
|
||||
|
||||
defaults, bodies := atDefaults(t), readingBodiesUpTo64K(t)
|
||||
|
||||
for _, recorded := range wordpressRequests(t) {
|
||||
r := request{recorded.Method, recorded.Target, recorded.Headers}
|
||||
|
||||
crsSets := []*coreruleset.CoreRuleSet{defaults}
|
||||
if recorded.Body != "" {
|
||||
crsSets = append(crsSets, bodies)
|
||||
}
|
||||
|
||||
for _, crs := range crsSets {
|
||||
got, _ := inspectBody(t, crs, r, recorded.Body)
|
||||
if got.Score >= threshold {
|
||||
t.Errorf("%s %s %.200q refused: %+v", r.method, r.target, recorded.Body, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for _, attack := range []string{
|
||||
"1'%20OR%20'1'='1",
|
||||
"..%2F..%2Fwp-config.php",
|
||||
"%3Cscript%3Ealert(1)%3C%2Fscript%3E",
|
||||
} {
|
||||
for _, target := range wordpressAttackTargets(attack) {
|
||||
if got := inspect(t, defaults, get(target)); got.Score < threshold {
|
||||
t.Errorf("%s let through: %+v", target, got)
|
||||
}
|
||||
}
|
||||
|
||||
// In a body field no exclusion names, on the paths with exclusions.
|
||||
body := "id=" + attack
|
||||
for _, target := range []string{
|
||||
wpLogin,
|
||||
"/wp-comments-post.php",
|
||||
"/wp-admin/admin-ajax.php",
|
||||
"/wp-admin/post.php",
|
||||
"/wp-admin/options.php",
|
||||
} {
|
||||
got, _ := inspectBody(t, bodies,
|
||||
request{http.MethodPost, target, []string{formData}}, body)
|
||||
if got.Score < threshold {
|
||||
t.Errorf("POST %s %q let through: %+v", target, body, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// wordpressBlock is the comment that starts every block of WordPress's
|
||||
// HTML, which rule 941180 matches.
|
||||
const wordpressBlock = "<!-- wp:paragraph -->"
|
||||
|
||||
// wordpressOverride is the header with which the block editor saves.
|
||||
const wordpressOverride = "X-HTTP-Method-Override: PUT"
|
||||
|
||||
// wordpressRESTAddresses returns the four addresses of route on the REST
|
||||
// API.
|
||||
func wordpressRESTAddresses(route string) []string {
|
||||
return []string{
|
||||
"/wp-json" + route,
|
||||
"/index.php/wp-json" + route,
|
||||
"/index.php?rest_route=" + route,
|
||||
"/?rest_route=" + route,
|
||||
}
|
||||
}
|
||||
|
||||
func TestWordPressExclusionsFollowEveryRESTAddress(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
crs := readingBodiesUpTo64K(t)
|
||||
content := `{"content":"` + wordpressBlock + `"}`
|
||||
|
||||
for _, route := range []string{
|
||||
"/wp/v2/posts/1",
|
||||
"/wp/v2/posts/1/autosaves",
|
||||
"/wp/v2/pages/2",
|
||||
"/wp/v2/templates/twentytwentyfive//index",
|
||||
"/wp/v2/template-parts/twentytwentyfive//header",
|
||||
} {
|
||||
for _, address := range wordpressRESTAddresses(route) {
|
||||
r := request{http.MethodPost, address, []string{jsonBody, wordpressOverride}}
|
||||
wantBody(t, crs, r, content, coreruleset.Result{}, content)
|
||||
}
|
||||
}
|
||||
|
||||
// The routes that save something else keep the rules.
|
||||
for _, route := range []string{
|
||||
"/wp/v2/comments", "/wp/v2/users/1", "/wp/v2/settings",
|
||||
} {
|
||||
for _, address := range wordpressRESTAddresses(route) {
|
||||
r := request{http.MethodPost, address, []string{jsonBody}}
|
||||
wantBody(t, crs, r, content, matched(941180), content)
|
||||
}
|
||||
}
|
||||
|
||||
// WordPress follows a route in the query over one in the path.
|
||||
r := request{
|
||||
http.MethodPost, "/wp-json/wp/v2/posts/1?rest_route=/wp/v2/comments",
|
||||
[]string{jsonBody},
|
||||
}
|
||||
wantBody(t, crs, r, content, matched(941180), content)
|
||||
}
|
||||
|
||||
func TestWordPressSearchesAndFileNamesAreExcludedOnlyThere(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
crs := atDefaults(t)
|
||||
|
||||
wantChange(t, crs, get("/?s=python3"), get("/wp-admin/options.php?id=python3"),
|
||||
matched(932260))
|
||||
wantChange(t, crs, get("/?s=python3"), get("/?q=python3"), matched(932260))
|
||||
wantChange(t, crs, get("/index.php?s=ls+-la"), get("/index.php?q=ls+-la"),
|
||||
matched(932250))
|
||||
wantChange(t, crs, get("/wp-json/wp/v2/search?search=powershell"),
|
||||
get("/wp-json/example/v1/things?search=powershell"), matched(932120))
|
||||
wantChange(t, crs, get("/index.php?rest_route=/wp/v2/search&search=powershell"),
|
||||
get("/index.php?rest_route=/example/v1/things&search=powershell"), matched(932120))
|
||||
wantChange(t, crs,
|
||||
get("/wp-admin/plugin-editor.php?file=akismet%2Fviews%2Fconfig.php"),
|
||||
get("/wp-admin/options.php?file=akismet%2Fviews%2Fconfig.php"), matched(930120))
|
||||
wantChange(t, crs, get("/wp-admin/theme-editor.php?file=docker-compose.yml"),
|
||||
get("/wp-admin/theme-editor.php?id=docker-compose.yml"), matched(932260))
|
||||
}
|
||||
|
||||
// TestWordPressGroupsSkipFiveRulesOnTheirFields checks each field of the
|
||||
// three groups in wordpress.conf at one address of its group, against the
|
||||
// same values in a field no group names, then one field at each other
|
||||
// address of the group.
|
||||
func TestWordPressGroupsSkipFiveRulesOnTheirFields(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
crs := atDefaults(t)
|
||||
|
||||
values := []struct {
|
||||
value string
|
||||
rules []int
|
||||
}{
|
||||
{".htaccess", []int{930120}},
|
||||
{"powershell", []int{932120}},
|
||||
{"%2Fwp-admin%2Fedit.php%3Fs%3Dpython3", []int{932235, 932260}},
|
||||
{"ls+-la", []int{932250}},
|
||||
{"python3", []int{932260}},
|
||||
}
|
||||
|
||||
for _, group := range []struct {
|
||||
address string
|
||||
fields []string
|
||||
}{
|
||||
{
|
||||
"/wp-admin/options.php?",
|
||||
[]string{
|
||||
"s", "search", "q", "term", "tag", "category_name", "plugin",
|
||||
"checked%5B%5D", "theme", "stylesheet", "url", "return",
|
||||
"redirect_to", "wp_http_referer", "_wp_http_referer",
|
||||
},
|
||||
},
|
||||
{"/wp-json/wp/v2/tags?", []string{"search", "term", "slug", "url"}},
|
||||
{"/?", []string{"s", "tag"}},
|
||||
} {
|
||||
for _, v := range values {
|
||||
wantResult(t, crs, get(group.address+"id="+v.value), matched(v.rules...))
|
||||
|
||||
for _, field := range group.fields {
|
||||
wantResult(t, crs, get(group.address+field+"="+v.value), coreruleset.Result{})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for _, target := range []string{
|
||||
"/wp-admin/term.php?s=python3",
|
||||
wpLogin + "?s=python3",
|
||||
"/index.php/wp-json/wp/v2/tags?slug=python3",
|
||||
"/index.php?rest_route=/wp/v2/tags&slug=python3",
|
||||
"/?rest_route=/wp/v2/tags&slug=python3",
|
||||
"/wp-json/oembed/1.0/embed?url=python3",
|
||||
"/?rest_route=/oembed/1.0/embed&url=python3",
|
||||
"/index.php?tag=python3",
|
||||
"/page/2/?tag=python3",
|
||||
"/index.php/page/2/?s=python3",
|
||||
} {
|
||||
wantResult(t, crs, get(target), coreruleset.Result{})
|
||||
}
|
||||
}
|
||||
|
||||
// TestWordPressOtherFieldsAreExcludedOnlyThere checks each field and rule
|
||||
// on one of its paths against the same value where no exclusion applies.
|
||||
func TestWordPressOtherFieldsAreExcludedOnlyThere(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
const elsewhere = "/example/things"
|
||||
|
||||
crs := atDefaults(t)
|
||||
|
||||
for _, tt := range []struct {
|
||||
target string
|
||||
rule int
|
||||
}{
|
||||
{"/page/2/?s=python3", 932260},
|
||||
{"/page/2/?s=ls+-la", 932250},
|
||||
{"/page/2/?s=powershell", 932120},
|
||||
{"/index.php/page/2/?s=python3", 932260},
|
||||
{"/?s=.htaccess", 930120},
|
||||
{"/?s=wp-config.php", 930120},
|
||||
{"/?s=.bashrc", 930120},
|
||||
{"/page/2/?s=.htaccess", 930120},
|
||||
{"/wp-admin/edit-comments.php?s=powershell", 932120},
|
||||
{"/wp-admin/users.php?s=ls+-la", 932250},
|
||||
{"/wp-admin/plugins.php?s=python3", 932260},
|
||||
{"/wp-admin/edit-tags.php?s=python3", 932260},
|
||||
{"/wp-admin/themes.php?search=powershell", 932120},
|
||||
{"/wp-admin/theme-install.php?search=ls+-la", 932250},
|
||||
{"/wp-admin/upload.php?search=python3", 932260},
|
||||
{"/wp-admin/admin-ajax.php?q=powershell", 932120},
|
||||
{"/wp-admin/admin-ajax.php?q=ls+-la", 932250},
|
||||
{"/wp-admin/admin-ajax.php?q=python3", 932260},
|
||||
{"/wp-admin/admin-ajax.php?term=powershell", 932120},
|
||||
{"/wp-admin/admin-ajax.php?term=ls+-la", 932250},
|
||||
{"/wp-admin/admin-ajax.php?term=python3", 932260},
|
||||
{"/wp-admin/edit.php?_wp_http_referer=.htaccess", 930120},
|
||||
{"/wp-admin/edit.php?_wp_http_referer=powershell", 932120},
|
||||
{"/wp-admin/upload.php?_wp_http_referer=ls+-la", 932250},
|
||||
{"/wp-admin/users.php?_wp_http_referer=python3", 932260},
|
||||
{"/wp-admin/upload.php?_wp_http_referer=%2Fupload.php%3Fmode%3Dlist%26ps", 932340},
|
||||
{"/?tag=python3", 932260},
|
||||
{"/index.php?tag=powershell", 932120},
|
||||
{"/wp-admin/edit.php?tag=ls+-la", 932250},
|
||||
{"/wp-admin/edit.php?category_name=python3", 932260},
|
||||
{"/wp-admin/edit.php?category_name=powershell", 932120},
|
||||
{"/wp-admin/edit.php?category_name=ls+-la", 932250},
|
||||
{"/wp-admin/plugin-editor.php?file=install-script.sh", 932120},
|
||||
{"/wp-admin/theme-editor.php?file=document.write.js", 941180},
|
||||
{"/wp-admin/plugin-editor.php?plugin=docker-helper", 932260},
|
||||
{"/wp-admin/plugins.php?plugin=powershell-tools", 932120},
|
||||
{"/wp-admin/update.php?plugin=docker-helper", 932260},
|
||||
} {
|
||||
_, query, _ := strings.Cut(tt.target, "?")
|
||||
wantChange(t, crs, get(tt.target), get(elsewhere+"?"+query), matched(tt.rule))
|
||||
}
|
||||
}
|
||||
|
||||
// TestWordPressRESTFieldsAreExcludedOnlyThere checks search, term and the
|
||||
// header X-HTTP-Method-Override at the REST API's addresses.
|
||||
func TestWordPressRESTFieldsAreExcludedOnlyThere(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
crs := atDefaults(t)
|
||||
|
||||
// Any route of WordPress's own API, at each address.
|
||||
for _, route := range []string{
|
||||
"/wp/v2/tags", "/wp/v2/categories", "/wp/v2/pages", "/wp/v2/media",
|
||||
"/wp/v2/users", "/wp/v2/blocks", "/wp/v2/pattern-directory/patterns",
|
||||
} {
|
||||
wantChange(t, crs, get("/wp-json"+route+"?search=python3"),
|
||||
get("/wp-json/example/v1/things?search=python3"), matched(932260))
|
||||
wantChange(t, crs, get("/index.php?rest_route="+route+"&search=python3"),
|
||||
get("/index.php?rest_route=/example/v1/things&search=python3"), matched(932260))
|
||||
wantChange(t, crs, get("/?rest_route="+route+"&search=.htaccess"),
|
||||
get("/?rest_route=/example/v1/things&search=.htaccess"), matched(930120))
|
||||
}
|
||||
|
||||
const directory = "/wp-json/wp/v2/block-directory/search"
|
||||
|
||||
wantChange(t, crs, get(directory+"?term=python3"),
|
||||
get("/wp-json/example/v1/things?term=python3"), matched(932260))
|
||||
wantChange(t, crs, get(directory+"?term=powershell"),
|
||||
get("/wp-json/example/v1/things?term=powershell"), matched(932120))
|
||||
wantChange(t, crs, get(directory+"?term=ls+-la"),
|
||||
get("/wp-json/example/v1/things?term=ls+-la"), matched(932250))
|
||||
|
||||
// The header X-HTTP-Method-Override at the address on the site root,
|
||||
// and off the REST API.
|
||||
headers := []string{jsonBody, "Content-Length: 2", wordpressOverride}
|
||||
wantChange(t, crs,
|
||||
request{http.MethodPost, "/?rest_route=/wp/v2/posts/1", headers},
|
||||
request{http.MethodPost, "/?p=1", headers}, matched(920450))
|
||||
}
|
||||
|
||||
func TestWordPressBodyFieldsAreCheckedInTheQuery(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
crs := atDefaults(t)
|
||||
|
||||
wantResult(t, crs, get(wpLogin+"?pwd=%2Fetc%2Fhosts"), matched(930120))
|
||||
wantResult(t, crs, get(wpLogin+"?PWD=%2Fetc%2Fhosts"), matched(930120))
|
||||
wantResult(t, crs, get("/wp-admin/admin-ajax.php?content=ls+-la"), matched(932250))
|
||||
wantResult(t, crs, get("/wp-admin/admin-ajax.php?newcontent=%3Cstyle%3E"),
|
||||
matched(941100, 941160))
|
||||
wantResult(t, crs,
|
||||
get("/wp-json/wp/v2/posts/1?json.content="+url.QueryEscape(wordpressBlock)),
|
||||
matched(941180))
|
||||
}
|
||||
|
||||
func TestWordPressExclusionsAreNarrow(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
const (
|
||||
save = "/wp-json/wp/v2/posts/1"
|
||||
offREST = "/api/posts/1"
|
||||
length = "Content-Length: 2"
|
||||
)
|
||||
|
||||
crs := readingBodiesUpTo64K(t)
|
||||
saving := request{http.MethodPost, save, []string{jsonBody, wordpressOverride}}
|
||||
|
||||
content := `{"content":"` + wordpressBlock + `"}`
|
||||
wantBody(t, crs, saving, content, coreruleset.Result{}, content)
|
||||
|
||||
// The other fields of the post keep the rule, and so does the same field
|
||||
// off the REST API.
|
||||
title := `{"title":"` + wordpressBlock + `"}`
|
||||
wantBody(t, crs, saving, title, matched(941180), title)
|
||||
wantBody(t, crs, request{http.MethodPost, offREST, []string{jsonBody}}, content,
|
||||
matched(941180), content)
|
||||
|
||||
// The header X-HTTP-Method-Override is refused off the REST API.
|
||||
defaults := atDefaults(t)
|
||||
wantResult(t, defaults,
|
||||
request{http.MethodPost, save, []string{jsonBody, length, wordpressOverride}},
|
||||
coreruleset.Result{})
|
||||
wantResult(t, defaults,
|
||||
request{http.MethodPost, offREST, []string{jsonBody, length, wordpressOverride}},
|
||||
matched(920450))
|
||||
|
||||
// Signing in: pwd alone skips the list of system files.
|
||||
signIn := request{http.MethodPost, wpLogin, []string{formData}}
|
||||
|
||||
body := "log=a&pwd=b%20%2Fetc%2Fhosts"
|
||||
wantBody(t, crs, signIn, body, coreruleset.Result{}, body)
|
||||
|
||||
body = "log=b%20%2Fetc%2Fhosts&pwd=a"
|
||||
wantBody(t, crs, signIn, body, matched(930120), body)
|
||||
}
|
||||
|
||||
// bodyLimit is SWW_WAF_BODY_LIMIT in the tests that read bodies.
|
||||
const bodyLimit = 8 << 10
|
||||
|
||||
|
||||
Reference in new issue
Block a user