Skip to content

Commit 6bc3079

Browse files
wxiaoguanglunny
andauthored
Refactor git command package to improve security and maintainability (#22678)
This PR follows #21535 (and replace #22592) ## Review without space diff https://github.com/go-gitea/gitea/pull/22678/files?diff=split&w=1 ## Purpose of this PR 1. Make git module command completely safe (risky user inputs won't be passed as argument option anymore) 2. Avoid low-level mistakes like #22098 (comment) 3. Remove deprecated and dirty `CmdArgCheck` function, hide the `CmdArg` type 4. Simplify code when using git command ## The main idea of this PR * Move the `git.CmdArg` to the `internal` package, then no other package except `git` could use it. Then developers could never do `AddArguments(git.CmdArg(userInput))` any more. * Introduce `git.ToTrustedCmdArgs`, it's for user-provided and already trusted arguments. It's only used in a few cases, for example: use git arguments from config file, help unit test with some arguments. * Introduce `AddOptionValues` and `AddOptionFormat`, they make code more clear and simple: * Before: `AddArguments("-m").AddDynamicArguments(message)` * After: `AddOptionValues("-m", message)` * - * Before: `AddArguments(git.CmdArg(fmt.Sprintf("--author='%s <%s>'", sig.Name, sig.Email)))` * After: `AddOptionFormat("--author='%s <%s>'", sig.Name, sig.Email)` ## FAQ ### Why these changes were not done in #21535 ? #21535 is mainly a search&replace, it did its best to not change too much logic. Making the framework better needs a lot of changes, so this separate PR is needed as the second step. ### The naming of `AddOptionXxx` According to git's manual, the `--xxx` part is called `option`. ### How can it guarantee that `internal.CmdArg` won't be not misused? Go's specification guarantees that. Trying to access other package's internal package causes compilation error. And, `golangci-lint` also denies the git/internal package. Only the `git/command.go` can use it carefully. ### There is still a `ToTrustedCmdArgs`, will it still allow developers to make mistakes and pass untrusted arguments? Generally speaking, no. Because when using `ToTrustedCmdArgs`, the code will be very complex (see the changes for examples). Then developers and reviewers can know that something might be unreasonable. ### Why there was a `CmdArgCheck` and why it's removed? At the moment of #21535, to reduce unnecessary changes, `CmdArgCheck` was introduced as a hacky patch. Now, almost all code could be written as `cmd := NewCommand(); cmd.AddXxx(...)`, then there is no need for `CmdArgCheck` anymore. ### Why many codes for `signArg == ""` is deleted? Because in the old code, `signArg` could never be empty string, it's either `-S[key-id]` or `--no-gpg-sign`. So the `signArg == ""` is just dead code. --------- Co-authored-by: Lunny Xiao <[email protected]>
1 parent 3c5655c commit 6bc3079

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

42 files changed

+379
-382
lines changed

.golangci.yml

+1
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,7 @@ linters-settings:
8484
- github.com/unknwon/com: "use gitea's util and replacements"
8585
- io/ioutil: "use os or io instead"
8686
- golang.org/x/exp: "it's experimental and unreliable."
87+
- code.gitea.io/gitea/modules/git/internal: "do not use the internal package, use AddXxx function instead"
8788

8889
issues:
8990
max-issues-per-linter: 0

modules/git/command.go

+69-28
Original file line numberDiff line numberDiff line change
@@ -16,14 +16,20 @@ import (
1616
"time"
1717
"unsafe"
1818

19+
"code.gitea.io/gitea/modules/git/internal" //nolint:depguard // only this file can use the internal type CmdArg, other files and packages should use AddXxx functions
1920
"code.gitea.io/gitea/modules/log"
2021
"code.gitea.io/gitea/modules/process"
2122
"code.gitea.io/gitea/modules/util"
2223
)
2324

25+
// TrustedCmdArgs returns the trusted arguments for git command.
26+
// It's mainly for passing user-provided and trusted arguments to git command
27+
// In most cases, it shouldn't be used. Use AddXxx function instead
28+
type TrustedCmdArgs []internal.CmdArg
29+
2430
var (
2531
// globalCommandArgs global command args for external package setting
26-
globalCommandArgs []CmdArg
32+
globalCommandArgs TrustedCmdArgs
2733

2834
// defaultCommandExecutionTimeout default command execution timeout duration
2935
defaultCommandExecutionTimeout = 360 * time.Second
@@ -42,8 +48,6 @@ type Command struct {
4248
brokenArgs []string
4349
}
4450

45-
type CmdArg string
46-
4751
func (c *Command) String() string {
4852
if len(c.args) == 0 {
4953
return c.name
@@ -53,7 +57,7 @@ func (c *Command) String() string {
5357

5458
// NewCommand creates and returns a new Git Command based on given command and arguments.
5559
// Each argument should be safe to be trusted. User-provided arguments should be passed to AddDynamicArguments instead.
56-
func NewCommand(ctx context.Context, args ...CmdArg) *Command {
60+
func NewCommand(ctx context.Context, args ...internal.CmdArg) *Command {
5761
// Make an explicit copy of globalCommandArgs, otherwise append might overwrite it
5862
cargs := make([]string, 0, len(globalCommandArgs)+len(args))
5963
for _, arg := range globalCommandArgs {
@@ -70,15 +74,9 @@ func NewCommand(ctx context.Context, args ...CmdArg) *Command {
7074
}
7175
}
7276

73-
// NewCommandNoGlobals creates and returns a new Git Command based on given command and arguments only with the specify args and don't care global command args
74-
// Each argument should be safe to be trusted. User-provided arguments should be passed to AddDynamicArguments instead.
75-
func NewCommandNoGlobals(args ...CmdArg) *Command {
76-
return NewCommandContextNoGlobals(DefaultContext, args...)
77-
}
78-
7977
// NewCommandContextNoGlobals creates and returns a new Git Command based on given command and arguments only with the specify args and don't care global command args
8078
// Each argument should be safe to be trusted. User-provided arguments should be passed to AddDynamicArguments instead.
81-
func NewCommandContextNoGlobals(ctx context.Context, args ...CmdArg) *Command {
79+
func NewCommandContextNoGlobals(ctx context.Context, args ...internal.CmdArg) *Command {
8280
cargs := make([]string, 0, len(args))
8381
for _, arg := range args {
8482
cargs = append(cargs, string(arg))
@@ -96,27 +94,70 @@ func (c *Command) SetParentContext(ctx context.Context) *Command {
9694
return c
9795
}
9896

99-
// SetDescription sets the description for this command which be returned on
100-
// c.String()
97+
// SetDescription sets the description for this command which be returned on c.String()
10198
func (c *Command) SetDescription(desc string) *Command {
10299
c.desc = desc
103100
return c
104101
}
105102

106-
// AddArguments adds new git argument(s) to the command. Each argument must be safe to be trusted.
107-
// User-provided arguments should be passed to AddDynamicArguments instead.
108-
func (c *Command) AddArguments(args ...CmdArg) *Command {
103+
// isSafeArgumentValue checks if the argument is safe to be used as a value (not an option)
104+
func isSafeArgumentValue(s string) bool {
105+
return s == "" || s[0] != '-'
106+
}
107+
108+
// isValidArgumentOption checks if the argument is a valid option (starting with '-').
109+
// It doesn't check whether the option is supported or not
110+
func isValidArgumentOption(s string) bool {
111+
return s != "" && s[0] == '-'
112+
}
113+
114+
// AddArguments adds new git arguments (option/value) to the command. It only accepts string literals, or trusted CmdArg.
115+
// Type CmdArg is in the internal package, so it can not be used outside of this package directly,
116+
// it makes sure that user-provided arguments won't cause RCE risks.
117+
// User-provided arguments should be passed by other AddXxx functions
118+
func (c *Command) AddArguments(args ...internal.CmdArg) *Command {
109119
for _, arg := range args {
110120
c.args = append(c.args, string(arg))
111121
}
112122
return c
113123
}
114124

115-
// AddDynamicArguments adds new dynamic argument(s) to the command.
116-
// The arguments may come from user input and can not be trusted, so no leading '-' is allowed to avoid passing options
125+
// AddOptionValues adds a new option with a list of non-option values
126+
// For example: AddOptionValues("--opt", val) means 2 arguments: {"--opt", val}.
127+
// The values are treated as dynamic argument values. It equals to: AddArguments("--opt") then AddDynamicArguments(val).
128+
func (c *Command) AddOptionValues(opt internal.CmdArg, args ...string) *Command {
129+
if !isValidArgumentOption(string(opt)) {
130+
c.brokenArgs = append(c.brokenArgs, string(opt))
131+
return c
132+
}
133+
c.args = append(c.args, string(opt))
134+
c.AddDynamicArguments(args...)
135+
return c
136+
}
137+
138+
// AddOptionFormat adds a new option with a format string and arguments
139+
// For example: AddOptionFormat("--opt=%s %s", val1, val2) means 1 argument: {"--opt=val1 val2"}.
140+
func (c *Command) AddOptionFormat(opt string, args ...any) *Command {
141+
if !isValidArgumentOption(opt) {
142+
c.brokenArgs = append(c.brokenArgs, opt)
143+
return c
144+
}
145+
// a quick check to make sure the format string matches the number of arguments, to find low-level mistakes ASAP
146+
if strings.Count(strings.ReplaceAll(opt, "%%", ""), "%") != len(args) {
147+
c.brokenArgs = append(c.brokenArgs, opt)
148+
return c
149+
}
150+
s := fmt.Sprintf(opt, args...)
151+
c.args = append(c.args, s)
152+
return c
153+
}
154+
155+
// AddDynamicArguments adds new dynamic argument values to the command.
156+
// The arguments may come from user input and can not be trusted, so no leading '-' is allowed to avoid passing options.
157+
// TODO: in the future, this function can be renamed to AddArgumentValues
117158
func (c *Command) AddDynamicArguments(args ...string) *Command {
118159
for _, arg := range args {
119-
if arg != "" && arg[0] == '-' {
160+
if !isSafeArgumentValue(arg) {
120161
c.brokenArgs = append(c.brokenArgs, arg)
121162
}
122163
}
@@ -137,14 +178,14 @@ func (c *Command) AddDashesAndList(list ...string) *Command {
137178
return c
138179
}
139180

140-
// CmdArgCheck checks whether the string is safe to be used as a dynamic argument.
141-
// It panics if the check fails. Usually it should not be used, it's just for refactoring purpose
142-
// deprecated
143-
func CmdArgCheck(s string) CmdArg {
144-
if s != "" && s[0] == '-' {
145-
panic("invalid git cmd argument: " + s)
181+
// ToTrustedCmdArgs converts a list of strings (trusted as argument) to TrustedCmdArgs
182+
// In most cases, it shouldn't be used. Use AddXxx function instead
183+
func ToTrustedCmdArgs(args []string) TrustedCmdArgs {
184+
ret := make(TrustedCmdArgs, len(args))
185+
for i, arg := range args {
186+
ret[i] = internal.CmdArg(arg)
146187
}
147-
return CmdArg(s)
188+
return ret
148189
}
149190

150191
// RunOpts represents parameters to run the command. If UseContextTimeout is specified, then Timeout is ignored.
@@ -364,9 +405,9 @@ func (c *Command) RunStdBytes(opts *RunOpts) (stdout, stderr []byte, runErr RunS
364405
}
365406

366407
// AllowLFSFiltersArgs return globalCommandArgs with lfs filter, it should only be used for tests
367-
func AllowLFSFiltersArgs() []CmdArg {
408+
func AllowLFSFiltersArgs() TrustedCmdArgs {
368409
// Now here we should explicitly allow lfs filters to run
369-
filteredLFSGlobalArgs := make([]CmdArg, len(globalCommandArgs))
410+
filteredLFSGlobalArgs := make(TrustedCmdArgs, len(globalCommandArgs))
370411
j := 0
371412
for _, arg := range globalCommandArgs {
372413
if strings.Contains(string(arg), "lfs") {

modules/git/command_test.go

+11
Original file line numberDiff line numberDiff line change
@@ -41,3 +41,14 @@ func TestRunWithContextStd(t *testing.T) {
4141
assert.Empty(t, stderr)
4242
assert.Contains(t, stdout, "git version")
4343
}
44+
45+
func TestGitArgument(t *testing.T) {
46+
assert.True(t, isValidArgumentOption("-x"))
47+
assert.True(t, isValidArgumentOption("--xx"))
48+
assert.False(t, isValidArgumentOption(""))
49+
assert.False(t, isValidArgumentOption("x"))
50+
51+
assert.True(t, isSafeArgumentValue(""))
52+
assert.True(t, isSafeArgumentValue("x"))
53+
assert.False(t, isSafeArgumentValue("-x"))
54+
}

modules/git/commit.go

+9-9
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,6 @@ import (
99
"bytes"
1010
"context"
1111
"errors"
12-
"fmt"
1312
"io"
1413
"os/exec"
1514
"strconv"
@@ -91,8 +90,8 @@ func AddChanges(repoPath string, all bool, files ...string) error {
9190
}
9291

9392
// AddChangesWithArgs marks local changes to be ready for commit.
94-
func AddChangesWithArgs(repoPath string, globalArgs []CmdArg, all bool, files ...string) error {
95-
cmd := NewCommandNoGlobals(append(globalArgs, "add")...)
93+
func AddChangesWithArgs(repoPath string, globalArgs TrustedCmdArgs, all bool, files ...string) error {
94+
cmd := NewCommandContextNoGlobals(DefaultContext, globalArgs...).AddArguments("add")
9695
if all {
9796
cmd.AddArguments("--all")
9897
}
@@ -111,27 +110,28 @@ type CommitChangesOptions struct {
111110
// CommitChanges commits local changes with given committer, author and message.
112111
// If author is nil, it will be the same as committer.
113112
func CommitChanges(repoPath string, opts CommitChangesOptions) error {
114-
cargs := make([]CmdArg, len(globalCommandArgs))
113+
cargs := make(TrustedCmdArgs, len(globalCommandArgs))
115114
copy(cargs, globalCommandArgs)
116115
return CommitChangesWithArgs(repoPath, cargs, opts)
117116
}
118117

119118
// CommitChangesWithArgs commits local changes with given committer, author and message.
120119
// If author is nil, it will be the same as committer.
121-
func CommitChangesWithArgs(repoPath string, args []CmdArg, opts CommitChangesOptions) error {
122-
cmd := NewCommandNoGlobals(args...)
120+
func CommitChangesWithArgs(repoPath string, args TrustedCmdArgs, opts CommitChangesOptions) error {
121+
cmd := NewCommandContextNoGlobals(DefaultContext, args...)
123122
if opts.Committer != nil {
124-
cmd.AddArguments("-c", CmdArg("user.name="+opts.Committer.Name), "-c", CmdArg("user.email="+opts.Committer.Email))
123+
cmd.AddOptionValues("-c", "user.name="+opts.Committer.Name)
124+
cmd.AddOptionValues("-c", "user.email="+opts.Committer.Email)
125125
}
126126
cmd.AddArguments("commit")
127127

128128
if opts.Author == nil {
129129
opts.Author = opts.Committer
130130
}
131131
if opts.Author != nil {
132-
cmd.AddArguments(CmdArg(fmt.Sprintf("--author='%s <%s>'", opts.Author.Name, opts.Author.Email)))
132+
cmd.AddOptionFormat("--author='%s <%s>'", opts.Author.Name, opts.Author.Email)
133133
}
134-
cmd.AddArguments("-m").AddDynamicArguments(opts.Message)
134+
cmd.AddOptionValues("-m", opts.Message)
135135

136136
_, _, err := cmd.RunStdString(&RunOpts{Dir: repoPath})
137137
// No stderr but exit status 1 means nothing to commit.

modules/git/git.go

+1-1
Original file line numberDiff line numberDiff line change
@@ -383,6 +383,6 @@ func configUnsetAll(key, value string) error {
383383
}
384384

385385
// Fsck verifies the connectivity and validity of the objects in the database
386-
func Fsck(ctx context.Context, repoPath string, timeout time.Duration, args ...CmdArg) error {
386+
func Fsck(ctx context.Context, repoPath string, timeout time.Duration, args TrustedCmdArgs) error {
387387
return NewCommand(ctx, "fsck").AddArguments(args...).Run(&RunOpts{Timeout: timeout, Dir: repoPath})
388388
}

modules/git/internal/cmdarg.go

+9
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
// Copyright 2023 The Gitea Authors. All rights reserved.
2+
// SPDX-License-Identifier: MIT
3+
4+
package internal
5+
6+
// CmdArg represents a command argument for git command, and it will be used for the git command directly without any further processing.
7+
// In most cases, you should use the "AddXxx" functions to add arguments, but not use this type directly.
8+
// Casting a risky (user-provided) string to CmdArg would cause security issues if it's injected with a "--xxx" argument.
9+
type CmdArg string

modules/git/repo.go

+1-1
Original file line numberDiff line numberDiff line change
@@ -115,7 +115,7 @@ func Clone(ctx context.Context, from, to string, opts CloneRepoOptions) error {
115115
}
116116

117117
// CloneWithArgs original repository to target path.
118-
func CloneWithArgs(ctx context.Context, args []CmdArg, from, to string, opts CloneRepoOptions) (err error) {
118+
func CloneWithArgs(ctx context.Context, args TrustedCmdArgs, from, to string, opts CloneRepoOptions) (err error) {
119119
toDir := path.Dir(to)
120120
if err = os.MkdirAll(toDir, os.ModePerm); err != nil {
121121
return err

modules/git/repo_archive.go

+2-2
Original file line numberDiff line numberDiff line change
@@ -57,9 +57,9 @@ func (repo *Repository) CreateArchive(ctx context.Context, format ArchiveType, t
5757

5858
cmd := NewCommand(ctx, "archive")
5959
if usePrefix {
60-
cmd.AddArguments(CmdArg("--prefix=" + filepath.Base(strings.TrimSuffix(repo.Path, ".git")) + "/"))
60+
cmd.AddOptionFormat("--prefix=%s", filepath.Base(strings.TrimSuffix(repo.Path, ".git"))+"/")
6161
}
62-
cmd.AddArguments(CmdArg("--format=" + format.String()))
62+
cmd.AddOptionFormat("--format=%s", format.String())
6363
cmd.AddDynamicArguments(commitID)
6464

6565
var stderr strings.Builder

modules/git/repo_attribute.go

+19-21
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ import (
1717
type CheckAttributeOpts struct {
1818
CachedOnly bool
1919
AllAttributes bool
20-
Attributes []CmdArg
20+
Attributes []string
2121
Filenames []string
2222
IndexFile string
2323
WorkTree string
@@ -48,7 +48,7 @@ func (repo *Repository) CheckAttribute(opts CheckAttributeOpts) (map[string]map[
4848
} else {
4949
for _, attribute := range opts.Attributes {
5050
if attribute != "" {
51-
cmd.AddArguments(attribute)
51+
cmd.AddDynamicArguments(attribute)
5252
}
5353
}
5454
}
@@ -95,7 +95,7 @@ func (repo *Repository) CheckAttribute(opts CheckAttributeOpts) (map[string]map[
9595
// CheckAttributeReader provides a reader for check-attribute content that can be long running
9696
type CheckAttributeReader struct {
9797
// params
98-
Attributes []CmdArg
98+
Attributes []string
9999
Repo *Repository
100100
IndexFile string
101101
WorkTree string
@@ -111,19 +111,6 @@ type CheckAttributeReader struct {
111111

112112
// Init initializes the CheckAttributeReader
113113
func (c *CheckAttributeReader) Init(ctx context.Context) error {
114-
cmdArgs := []CmdArg{"check-attr", "--stdin", "-z"}
115-
116-
if len(c.IndexFile) > 0 {
117-
cmdArgs = append(cmdArgs, "--cached")
118-
c.env = append(c.env, "GIT_INDEX_FILE="+c.IndexFile)
119-
}
120-
121-
if len(c.WorkTree) > 0 {
122-
c.env = append(c.env, "GIT_WORK_TREE="+c.WorkTree)
123-
}
124-
125-
c.env = append(c.env, "GIT_FLUSH=1")
126-
127114
if len(c.Attributes) == 0 {
128115
lw := new(nulSeparatedAttributeWriter)
129116
lw.attributes = make(chan attributeTriple)
@@ -134,11 +121,22 @@ func (c *CheckAttributeReader) Init(ctx context.Context) error {
134121
return fmt.Errorf("no provided Attributes to check")
135122
}
136123

137-
cmdArgs = append(cmdArgs, c.Attributes...)
138-
cmdArgs = append(cmdArgs, "--")
139-
140124
c.ctx, c.cancel = context.WithCancel(ctx)
141-
c.cmd = NewCommand(c.ctx, cmdArgs...)
125+
c.cmd = NewCommand(c.ctx, "check-attr", "--stdin", "-z")
126+
127+
if len(c.IndexFile) > 0 {
128+
c.cmd.AddArguments("--cached")
129+
c.env = append(c.env, "GIT_INDEX_FILE="+c.IndexFile)
130+
}
131+
132+
if len(c.WorkTree) > 0 {
133+
c.env = append(c.env, "GIT_WORK_TREE="+c.WorkTree)
134+
}
135+
136+
c.env = append(c.env, "GIT_FLUSH=1")
137+
138+
// The empty "--" comes from #16773 , and it seems unnecessary because nothing else would be added later.
139+
c.cmd.AddDynamicArguments(c.Attributes...).AddArguments("--")
142140

143141
var err error
144142

@@ -294,7 +292,7 @@ func (repo *Repository) CheckAttributeReader(commitID string) (*CheckAttributeRe
294292
}
295293

296294
checker := &CheckAttributeReader{
297-
Attributes: []CmdArg{"linguist-vendored", "linguist-generated", "linguist-language", "gitlab-language"},
295+
Attributes: []string{"linguist-vendored", "linguist-generated", "linguist-language", "gitlab-language"},
298296
Repo: repo,
299297
IndexFile: indexFilename,
300298
WorkTree: worktree,

modules/git/repo_blame.go

+5-3
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,9 @@
33

44
package git
55

6-
import "fmt"
6+
import (
7+
"fmt"
8+
)
79

810
// FileBlame return the Blame object of file
911
func (repo *Repository) FileBlame(revision, path, file string) ([]byte, error) {
@@ -14,8 +16,8 @@ func (repo *Repository) FileBlame(revision, path, file string) ([]byte, error) {
1416
// LineBlame returns the latest commit at the given line
1517
func (repo *Repository) LineBlame(revision, path, file string, line uint) (*Commit, error) {
1618
res, _, err := NewCommand(repo.Ctx, "blame").
17-
AddArguments(CmdArg(fmt.Sprintf("-L %d,%d", line, line))).
18-
AddArguments("-p").AddDynamicArguments(revision).
19+
AddOptionFormat("-L %d,%d", line, line).
20+
AddOptionValues("-p", revision).
1921
AddDashesAndList(file).RunStdString(&RunOpts{Dir: path})
2022
if err != nil {
2123
return nil, err

0 commit comments

Comments
 (0)