Core Rule Set: WordPress's ordinary requests pass by default (closes #235)
check / check (push) Successful in 4m43s

Add the ninth change: wordpress.conf switches off, per path and field, each
rule that refused ordinary WordPress 7.1.4 requests. Add the recorded requests,
a test at the defaults and reading bodies, and list WordPress in the README.

Model: opus-5-5
This commit is contained in:
sneak authored and clawbot committed 2026-11-09 08:00:04 +00:00
1 parent aede434f6a
commit 650d4c0936
6 files changed
+1109 -18

No files matched your search

+380 -3
View File
@@ -1,6 +1,7 @@
package waf_test
import (
"encoding/json"
"net/http"
"net/http/httptest"
"net/netip"
@@ -119,9 +120,9 @@ func atDefaults(t *testing.T) *waf.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 *waf.CoreRuleSet, passes, refused request, result waf.Result,
) {
@@ -550,6 +551,382 @@ 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) *waf.CoreRuleSet {
t.Helper()
crs, err := waf.New(waf.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/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 := []*waf.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, waf.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?s=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?theme=docker-compose.yml"), matched(932260))
}
// 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 options = "/wp-admin/options.php"
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(options+"?"+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/wp/v2/tags?term=python3"), matched(932260))
wantChange(t, crs, get(directory+"?term=powershell"),
get("/wp-json/wp/v2/tags?term=powershell"), matched(932120))
wantChange(t, crs, get(directory+"?term=ls+-la"),
get("/wp-json/wp/v2/tags?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, waf.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}},
waf.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, waf.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