check / check (push) Waiting to run
The Gitea setup runs the Core Rule Set in block mode. Rules that refused ordinary Gitea requests are switched off only where they misfired: - deploy/gitea/crs-exclusions.conf, loaded through the new setting SWWAF_CORE_RULE_SET_EXCLUSIONS_FILE before the Core Rule Set's rules, covers Gitea's search, file-name and branch-name fields, Referer, file paths, API attachment names and Actions artifact uploads; - smallwebwaf's defaults gain a seventh change: the registry upload path's from parameter skips 932260. A file that cannot be read, does not parse or names an id from 900000 to 900999 stops servewaf at start, in every mode. internal/waf's tests and script/gitea-setup send the recorded requests in internal/waf/testdata/gitea-requests.txt and three attacks. Model: opus-5-5
851 lines
26 KiB
Go
851 lines
26 KiB
Go
package waf_test
|
|
|
|
import (
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"net/netip"
|
|
"net/url"
|
|
"os"
|
|
"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 seven 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{})
|
|
}
|
|
|
|
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) (*waf.CoreRuleSet, error) {
|
|
return waf.New(waf.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 := waf.New(waf.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~"), waf.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()
|
|
|
|
for _, path := range []string{
|
|
filepath.Join(t.TempDir(), "missing.conf"),
|
|
exclusionsFile(t, "SecNoSuchDirective On\n"),
|
|
// The Core Rule Set's rules are not loaded yet.
|
|
exclusionsFile(t, "SecRuleUpdateTargetById 942100 \"!ARGS:id\"\n"),
|
|
// smallwebwaf's own rules may not be switched off or changed, by
|
|
// id or in a range.
|
|
exclusionsFile(t, "SecRuleRemoveById 900300\n"),
|
|
exclusionsFile(t, "SecRuleRemoveById 899999-901000\n"),
|
|
exclusionsFile(t, "SecAction \"id:1,phase:1,pass,nolog,"+
|
|
"ctl:ruleRemoveById=899999-901000\"\n"),
|
|
exclusionsFile(t, "SecAction \"id:1,phase:1,pass,nolog,"+
|
|
"ctl:ruleRemoveTargetById=900500;ARGS:from\"\n"),
|
|
} {
|
|
_, err := withExclusions(path)
|
|
if err == nil {
|
|
t.Errorf("%s loaded", path)
|
|
}
|
|
}
|
|
}
|
|
|
|
// giteaRequests returns the recorded ordinary gitea requests in testdata,
|
|
// which script/gitea-setup sends too. 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 TestGiteaExclusionsLetOrdinaryGiteaRequestsThroughAndRefuseAttacks(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
crs, err := withExclusions(filepath.Join("..", "..", "deploy", "gitea",
|
|
"crs-exclusions.conf"))
|
|
if err != nil {
|
|
t.Fatalf("load gitea's exclusions: %v", err)
|
|
}
|
|
|
|
const threshold = 5 // SWWAF_WAF_ANOMALY_THRESHOLD's default
|
|
|
|
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)
|
|
}
|
|
}
|
|
|
|
for _, attack := range []string{
|
|
"/owner/repo?id=1'%20OR%20'1'='1",
|
|
"/owner/repo?id=..%2Fcustom%2Fconf%2Fapp.ini",
|
|
"/owner/repo?id=%3Cscript%3Ealert(1)%3C%2Fscript%3E",
|
|
} {
|
|
if got := inspect(t, crs, get(attack)); got.Score < threshold {
|
|
t.Errorf("%s let through: %+v", attack, got)
|
|
}
|
|
}
|
|
}
|
|
|
|
// 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)
|
|
}
|
|
}
|