Core Rule Set: WordPress's ordinary requests pass by default (closes #235)
check / check (push) Successful in 4m43s
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:
1 parent
aede434f6a
commit
650d4c0936
6 files changed
+1109
-18
No files matched your search
+380
-3
@@ -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
|
||||
|
||||
|
||||
Reference in new issue
Block a user