Core Rule Set: WordPress's ordinary requests pass by default (closes #235)
check / check (push) Failing after 1m23s
check / check (push) Failing after 1m23s
Add the ninth change: wordpress.conf switches off, per path and field, each rule that refused recorded WordPress 7.1.4 traffic. 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
95b5c447ee
6 files changed
+817
-18
No files matched your search
+258
-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,260 @@ 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
|
||||
}
|
||||
|
||||
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 []string{
|
||||
"/?id=" + attack,
|
||||
wpLogin + "?id=" + attack,
|
||||
"/wp-admin/admin-ajax.php?id=" + attack,
|
||||
"/wp-json/wp/v2/posts?id=" + attack,
|
||||
// In the fields whose searches and file names skip some rules.
|
||||
"/?s=" + attack,
|
||||
"/index.php?s=" + attack,
|
||||
"/wp-admin/edit.php?s=" + attack,
|
||||
"/wp-admin/upload.php?s=" + attack,
|
||||
"/wp-admin/plugin-install.php?s=" + attack,
|
||||
"/wp-json/wp/v2/search?search=" + attack,
|
||||
"/wp-json/wp/v2/posts?search=" + attack,
|
||||
"/wp-admin/plugin-editor.php?file=" + attack,
|
||||
"/wp-admin/theme-editor.php?file=" + 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 addresses of route on the REST API,
|
||||
// one for each permalink setting.
|
||||
func wordpressRESTAddresses(route string) []string {
|
||||
return []string{
|
||||
"/wp-json" + route,
|
||||
"/index.php/wp-json" + route,
|
||||
"/index.php?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/wp/v2/comments?search=powershell"), matched(932120))
|
||||
wantChange(t, crs, get("/index.php?rest_route=/wp/v2/search&search=powershell"),
|
||||
get("/index.php?rest_route=/wp/v2/comments&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))
|
||||
}
|
||||
|
||||
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