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 no larger than it, with text/json and the application and text types ending in +json or +xml. 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, as does a multipart body the limit cuts in a part's headers before a colon or a line feed. 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
689 lines
22 KiB
Go
689 lines
22 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. 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 := 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)
|
|
}
|
|
}
|