check / check (push) Waiting to run
SWWAF_WAF_BODY_LIMIT (default off, at most 1G) has the Core Rule Set read form data and multipart up to the limit, the rest streaming on, and JSON and XML (with +json, text/json and +xml) no larger than it. The part read is held for the app. A size or time limit met while reading ends the request. Content-Encoding is refused again on these kinds. A body Coraza cannot parse, or a multipart body failing its strict checks, adds 5, but not a multipart body reaching the limit. Coraza is built with no_fs_access, so writes no file. Rule 900300 moves to phase 2. Judgement call: Content-Encoding is refused on a JSON or XML body too large to read, as SPEC.md allows. Model: opus-5-5
661 lines
21 KiB
Go
661 lines
21 KiB
Go
package waf_test
|
|
|
|
import (
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"net/netip"
|
|
"net/url"
|
|
"path/filepath"
|
|
"reflect"
|
|
"strconv"
|
|
"strings"
|
|
"testing"
|
|
|
|
"sneak.berlin/go/smallwebwaf/internal/waf"
|
|
)
|
|
|
|
// defaultDisabledRules are the rules SWWAF_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) *waf.CoreRuleSet {
|
|
t.Helper()
|
|
|
|
crs, err := waf.New(waf.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 *waf.CoreRuleSet, r request) waf.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 *waf.CoreRuleSet, r request, body string,
|
|
) (waf.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 *waf.CoreRuleSet, r request, want waf.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) waf.Result {
|
|
const critical = 5
|
|
|
|
return waf.Result{RuleIDs: ids, Score: critical * len(ids)}
|
|
}
|
|
|
|
// atDefaults returns the Core Rule Set as smallwebwaf runs it by default.
|
|
func atDefaults(t *testing.T) *waf.CoreRuleSet {
|
|
t.Helper()
|
|
|
|
return newCoreRuleSet(t, 1, defaultDisabledRules...)
|
|
}
|
|
|
|
// wantChange checks that crs lets through passes, a gitea request one of
|
|
// the six changes is for, and still finds result in refused, a request
|
|
// like it that the change is not for.
|
|
func wantChange(
|
|
t *testing.T, crs *waf.CoreRuleSet, passes, refused request, result waf.Result,
|
|
) {
|
|
t.Helper()
|
|
|
|
wantResult(t, crs, passes, waf.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"}},
|
|
waf.Result{})
|
|
wantResult(t, crs,
|
|
request{http.MethodPost, fetch, []string{
|
|
fetchType, length, "Content-Encoding: gzip",
|
|
}},
|
|
waf.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",
|
|
}},
|
|
waf.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 waf.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"),
|
|
waf.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 TestRefererIsNotCheckedForACommandOrJavaStartingAProcess(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"
|
|
)
|
|
|
|
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))
|
|
|
|
// 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: "),
|
|
waf.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, waf.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,
|
|
waf.Result{RuleIDs: []int{911100, 920350}, Score: 8})
|
|
wantResult(t, newCoreRuleSet(t, 1, 920350, 911100), r, waf.Result{})
|
|
}
|
|
|
|
// bodyLimit is SWWAF_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 smallwebwaf runs it by
|
|
// default, but reading bodies up to bodyLimit.
|
|
func readingBodies(t *testing.T) *waf.CoreRuleSet {
|
|
t.Helper()
|
|
|
|
crs, err := waf.New(waf.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 *waf.CoreRuleSet, r request, body string, want waf.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, waf.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, waf.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, waf.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, waf.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, waf.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", waf.Result{}, "")
|
|
wantBody(t, atDefaults(t), post(formData, gzip), "a", waf.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 waf.Result
|
|
}{
|
|
{"path=.gitignore", waf.Result{}},
|
|
{"q=.gitignore", matched(930120)},
|
|
{"redirect_uri=" + local, waf.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, waf.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.
|
|
body = field("q", injection) + end
|
|
wantBody(t, crs, post(multipart+"; boundary=c"), body, matched(900440, 900450),
|
|
body)
|
|
|
|
body = "--b\r\nContent-Disposition form-data; name=\"a\"\r\n\r\n1\r\n" +
|
|
field("q", injection) + end
|
|
wantBody(t, crs, post(multipart), body, matched(900440, 900450), body)
|
|
}
|
|
|
|
func TestMultipartBodyCutInsideAPartHeaderIsNotAMatch(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, finds 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, waf.Result{},
|
|
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 := waf.New(waf.Params{ParanoiaLevel: 1, BodyLimit: 1 << 30})
|
|
if err != nil {
|
|
t.Errorf("load the Core Rule Set reading bodies up to 1G: %v", err)
|
|
}
|
|
}
|