Skip to content

Commit 6d738fe

Browse files
GiteaBotlunnywxiaoguang
authored
Fix a bug when uploading file via lfs ssh command (#34408) (#34416)
Backport #34408 by @lunny Co-authored-by: Lunny Xiao <[email protected]> Co-authored-by: wxiaoguang <[email protected]>
1 parent 38cc745 commit 6d738fe

File tree

7 files changed

+149
-76
lines changed

7 files changed

+149
-76
lines changed

cmd/serv.go

+31-58
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,6 @@ import (
1111
"os"
1212
"os/exec"
1313
"path/filepath"
14-
"regexp"
1514
"strconv"
1615
"strings"
1716
"time"
@@ -20,7 +19,7 @@ import (
2019
asymkey_model "code.gitea.io/gitea/models/asymkey"
2120
git_model "code.gitea.io/gitea/models/git"
2221
"code.gitea.io/gitea/models/perm"
23-
"code.gitea.io/gitea/modules/container"
22+
"code.gitea.io/gitea/models/repo"
2423
"code.gitea.io/gitea/modules/git"
2524
"code.gitea.io/gitea/modules/json"
2625
"code.gitea.io/gitea/modules/lfstransfer"
@@ -37,14 +36,6 @@ import (
3736
"github.com/urfave/cli/v2"
3837
)
3938

40-
const (
41-
verbUploadPack = "git-upload-pack"
42-
verbUploadArchive = "git-upload-archive"
43-
verbReceivePack = "git-receive-pack"
44-
verbLfsAuthenticate = "git-lfs-authenticate"
45-
verbLfsTransfer = "git-lfs-transfer"
46-
)
47-
4839
// CmdServ represents the available serv sub-command.
4940
var CmdServ = &cli.Command{
5041
Name: "serv",
@@ -78,22 +69,6 @@ func setup(ctx context.Context, debug bool) {
7869
}
7970
}
8071

81-
var (
82-
// keep getAccessMode() in sync
83-
allowedCommands = container.SetOf(
84-
verbUploadPack,
85-
verbUploadArchive,
86-
verbReceivePack,
87-
verbLfsAuthenticate,
88-
verbLfsTransfer,
89-
)
90-
allowedCommandsLfs = container.SetOf(
91-
verbLfsAuthenticate,
92-
verbLfsTransfer,
93-
)
94-
alphaDashDotPattern = regexp.MustCompile(`[^\w-\.]`)
95-
)
96-
9772
// fail prints message to stdout, it's mainly used for git serv and git hook commands.
9873
// The output will be passed to git client and shown to user.
9974
func fail(ctx context.Context, userMessage, logMsgFmt string, args ...any) error {
@@ -139,19 +114,20 @@ func handleCliResponseExtra(extra private.ResponseExtra) error {
139114

140115
func getAccessMode(verb, lfsVerb string) perm.AccessMode {
141116
switch verb {
142-
case verbUploadPack, verbUploadArchive:
117+
case git.CmdVerbUploadPack, git.CmdVerbUploadArchive:
143118
return perm.AccessModeRead
144-
case verbReceivePack:
119+
case git.CmdVerbReceivePack:
145120
return perm.AccessModeWrite
146-
case verbLfsAuthenticate, verbLfsTransfer:
121+
case git.CmdVerbLfsAuthenticate, git.CmdVerbLfsTransfer:
147122
switch lfsVerb {
148-
case "upload":
123+
case git.CmdSubVerbLfsUpload:
149124
return perm.AccessModeWrite
150-
case "download":
125+
case git.CmdSubVerbLfsDownload:
151126
return perm.AccessModeRead
152127
}
153128
}
154129
// should be unreachable
130+
setting.PanicInDevOrTesting("unknown verb: %s %s", verb, lfsVerb)
155131
return perm.AccessModeNone
156132
}
157133

@@ -230,12 +206,12 @@ func runServ(c *cli.Context) error {
230206
log.Debug("SSH_ORIGINAL_COMMAND: %s", os.Getenv("SSH_ORIGINAL_COMMAND"))
231207
}
232208

233-
words, err := shellquote.Split(cmd)
209+
sshCmdArgs, err := shellquote.Split(cmd)
234210
if err != nil {
235211
return fail(ctx, "Error parsing arguments", "Failed to parse arguments: %v", err)
236212
}
237213

238-
if len(words) < 2 {
214+
if len(sshCmdArgs) < 2 {
239215
if git.DefaultFeatures().SupportProcReceive {
240216
// for AGit Flow
241217
if cmd == "ssh_info" {
@@ -246,25 +222,21 @@ func runServ(c *cli.Context) error {
246222
return fail(ctx, "Too few arguments", "Too few arguments in cmd: %s", cmd)
247223
}
248224

249-
verb := words[0]
250-
repoPath := strings.TrimPrefix(words[1], "/")
251-
252-
var lfsVerb string
253-
254-
rr := strings.SplitN(repoPath, "/", 2)
255-
if len(rr) != 2 {
225+
repoPath := strings.TrimPrefix(sshCmdArgs[1], "/")
226+
repoPathFields := strings.SplitN(repoPath, "/", 2)
227+
if len(repoPathFields) != 2 {
256228
return fail(ctx, "Invalid repository path", "Invalid repository path: %v", repoPath)
257229
}
258230

259-
username := rr[0]
260-
reponame := strings.TrimSuffix(rr[1], ".git")
231+
username := repoPathFields[0]
232+
reponame := strings.TrimSuffix(repoPathFields[1], ".git") // “the-repo-name" or "the-repo-name.wiki"
261233

262234
// LowerCase and trim the repoPath as that's how they are stored.
263235
// This should be done after splitting the repoPath into username and reponame
264236
// so that username and reponame are not affected.
265237
repoPath = strings.ToLower(strings.TrimSpace(repoPath))
266238

267-
if alphaDashDotPattern.MatchString(reponame) {
239+
if !repo.IsValidSSHAccessRepoName(reponame) {
268240
return fail(ctx, "Invalid repo name", "Invalid repo name: %s", reponame)
269241
}
270242

@@ -286,22 +258,23 @@ func runServ(c *cli.Context) error {
286258
}()
287259
}
288260

289-
if allowedCommands.Contains(verb) {
290-
if allowedCommandsLfs.Contains(verb) {
291-
if !setting.LFS.StartServer {
292-
return fail(ctx, "LFS Server is not enabled", "")
293-
}
294-
if verb == verbLfsTransfer && !setting.LFS.AllowPureSSH {
295-
return fail(ctx, "LFS SSH transfer is not enabled", "")
296-
}
297-
if len(words) > 2 {
298-
lfsVerb = words[2]
299-
}
300-
}
301-
} else {
261+
verb, lfsVerb := sshCmdArgs[0], ""
262+
if !git.IsAllowedVerbForServe(verb) {
302263
return fail(ctx, "Unknown git command", "Unknown git command %s", verb)
303264
}
304265

266+
if git.IsAllowedVerbForServeLfs(verb) {
267+
if !setting.LFS.StartServer {
268+
return fail(ctx, "LFS Server is not enabled", "")
269+
}
270+
if verb == git.CmdVerbLfsTransfer && !setting.LFS.AllowPureSSH {
271+
return fail(ctx, "LFS SSH transfer is not enabled", "")
272+
}
273+
if len(sshCmdArgs) > 2 {
274+
lfsVerb = sshCmdArgs[2]
275+
}
276+
}
277+
305278
requestedMode := getAccessMode(verb, lfsVerb)
306279

307280
results, extra := private.ServCommand(ctx, keyID, username, reponame, requestedMode, verb, lfsVerb)
@@ -310,7 +283,7 @@ func runServ(c *cli.Context) error {
310283
}
311284

312285
// LFS SSH protocol
313-
if verb == verbLfsTransfer {
286+
if verb == git.CmdVerbLfsTransfer {
314287
token, err := getLFSAuthToken(ctx, lfsVerb, results)
315288
if err != nil {
316289
return err
@@ -319,7 +292,7 @@ func runServ(c *cli.Context) error {
319292
}
320293

321294
// LFS token authentication
322-
if verb == verbLfsAuthenticate {
295+
if verb == git.CmdVerbLfsAuthenticate {
323296
url := fmt.Sprintf("%s%s/%s.git/info/lfs", setting.AppURL, url.PathEscape(results.OwnerName), url.PathEscape(results.RepoName))
324297

325298
token, err := getLFSAuthToken(ctx, lfsVerb, results)

models/repo/repo.go

+18-9
Original file line numberDiff line numberDiff line change
@@ -64,18 +64,18 @@ func (err ErrRepoIsArchived) Error() string {
6464
}
6565

6666
type globalVarsStruct struct {
67-
validRepoNamePattern *regexp.Regexp
68-
invalidRepoNamePattern *regexp.Regexp
69-
reservedRepoNames []string
70-
reservedRepoPatterns []string
67+
validRepoNamePattern *regexp.Regexp
68+
invalidRepoNamePattern *regexp.Regexp
69+
reservedRepoNames []string
70+
reservedRepoNamePatterns []string
7171
}
7272

7373
var globalVars = sync.OnceValue(func() *globalVarsStruct {
7474
return &globalVarsStruct{
75-
validRepoNamePattern: regexp.MustCompile(`[-.\w]+`),
76-
invalidRepoNamePattern: regexp.MustCompile(`[.]{2,}`),
77-
reservedRepoNames: []string{".", "..", "-"},
78-
reservedRepoPatterns: []string{"*.git", "*.wiki", "*.rss", "*.atom"},
75+
validRepoNamePattern: regexp.MustCompile(`^[-.\w]+$`),
76+
invalidRepoNamePattern: regexp.MustCompile(`[.]{2,}`),
77+
reservedRepoNames: []string{".", "..", "-"},
78+
reservedRepoNamePatterns: []string{"*.wiki", "*.git", "*.rss", "*.atom"},
7979
}
8080
})
8181

@@ -86,7 +86,16 @@ func IsUsableRepoName(name string) error {
8686
// Note: usually this error is normally caught up earlier in the UI
8787
return db.ErrNameCharsNotAllowed{Name: name}
8888
}
89-
return db.IsUsableName(vars.reservedRepoNames, vars.reservedRepoPatterns, name)
89+
return db.IsUsableName(vars.reservedRepoNames, vars.reservedRepoNamePatterns, name)
90+
}
91+
92+
// IsValidSSHAccessRepoName is like IsUsableRepoName, but it allows "*.wiki" because wiki repo needs to be accessed in SSH code
93+
func IsValidSSHAccessRepoName(name string) bool {
94+
vars := globalVars()
95+
if !vars.validRepoNamePattern.MatchString(name) || vars.invalidRepoNamePattern.MatchString(name) {
96+
return false
97+
}
98+
return db.IsUsableName(vars.reservedRepoNames, vars.reservedRepoNamePatterns[1:], name) == nil
9099
}
91100

92101
// TrustModelType defines the types of trust model for this repository

models/repo/repo_test.go

+15
Original file line numberDiff line numberDiff line change
@@ -216,8 +216,23 @@ func TestIsUsableRepoName(t *testing.T) {
216216

217217
assert.Error(t, IsUsableRepoName("-"))
218218
assert.Error(t, IsUsableRepoName("🌞"))
219+
assert.Error(t, IsUsableRepoName("the/repo"))
219220
assert.Error(t, IsUsableRepoName("the..repo"))
220221
assert.Error(t, IsUsableRepoName("foo.wiki"))
221222
assert.Error(t, IsUsableRepoName("foo.git"))
222223
assert.Error(t, IsUsableRepoName("foo.RSS"))
223224
}
225+
226+
func TestIsValidSSHAccessRepoName(t *testing.T) {
227+
assert.True(t, IsValidSSHAccessRepoName("a"))
228+
assert.True(t, IsValidSSHAccessRepoName("-1_."))
229+
assert.True(t, IsValidSSHAccessRepoName(".profile"))
230+
assert.True(t, IsValidSSHAccessRepoName("foo.wiki"))
231+
232+
assert.False(t, IsValidSSHAccessRepoName("-"))
233+
assert.False(t, IsValidSSHAccessRepoName("🌞"))
234+
assert.False(t, IsValidSSHAccessRepoName("the/repo"))
235+
assert.False(t, IsValidSSHAccessRepoName("the..repo"))
236+
assert.False(t, IsValidSSHAccessRepoName("foo.git"))
237+
assert.False(t, IsValidSSHAccessRepoName("foo.RSS"))
238+
}

modules/git/cmdverb.go

+36
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
// Copyright 2025 The Gitea Authors. All rights reserved.
2+
// SPDX-License-Identifier: MIT
3+
4+
package git
5+
6+
const (
7+
CmdVerbUploadPack = "git-upload-pack"
8+
CmdVerbUploadArchive = "git-upload-archive"
9+
CmdVerbReceivePack = "git-receive-pack"
10+
CmdVerbLfsAuthenticate = "git-lfs-authenticate"
11+
CmdVerbLfsTransfer = "git-lfs-transfer"
12+
13+
CmdSubVerbLfsUpload = "upload"
14+
CmdSubVerbLfsDownload = "download"
15+
)
16+
17+
func IsAllowedVerbForServe(verb string) bool {
18+
switch verb {
19+
case CmdVerbUploadPack,
20+
CmdVerbUploadArchive,
21+
CmdVerbReceivePack,
22+
CmdVerbLfsAuthenticate,
23+
CmdVerbLfsTransfer:
24+
return true
25+
}
26+
return false
27+
}
28+
29+
func IsAllowedVerbForServeLfs(verb string) bool {
30+
switch verb {
31+
case CmdVerbLfsAuthenticate,
32+
CmdVerbLfsTransfer:
33+
return true
34+
}
35+
return false
36+
}

modules/private/serv.go

+4-6
Original file line numberDiff line numberDiff line change
@@ -46,18 +46,16 @@ type ServCommandResults struct {
4646
}
4747

4848
// ServCommand preps for a serv call
49-
func ServCommand(ctx context.Context, keyID int64, ownerName, repoName string, mode perm.AccessMode, verbs ...string) (*ServCommandResults, ResponseExtra) {
49+
func ServCommand(ctx context.Context, keyID int64, ownerName, repoName string, mode perm.AccessMode, verb, lfsVerb string) (*ServCommandResults, ResponseExtra) {
5050
reqURL := setting.LocalURL + fmt.Sprintf("api/internal/serv/command/%d/%s/%s?mode=%d",
5151
keyID,
5252
url.PathEscape(ownerName),
5353
url.PathEscape(repoName),
5454
mode,
5555
)
56-
for _, verb := range verbs {
57-
if verb != "" {
58-
reqURL += "&verb=" + url.QueryEscape(verb)
59-
}
60-
}
56+
reqURL += "&verb=" + url.QueryEscape(verb)
57+
// reqURL += "&lfs_verb=" + url.QueryEscape(lfsVerb) // TODO: actually there is no use of this parameter. In the future, the URL construction should be more flexible
58+
_ = lfsVerb
6159
req := newInternalRequestAPI(ctx, reqURL, "GET")
6260
return requestJSONResp(req, &ServCommandResults{})
6361
}

routers/private/serv.go

+6-2
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,7 @@ func ServCommand(ctx *context.PrivateContext) {
8181
ownerName := ctx.PathParam("owner")
8282
repoName := ctx.PathParam("repo")
8383
mode := perm.AccessMode(ctx.FormInt("mode"))
84+
verb := ctx.FormString("verb")
8485

8586
// Set the basic parts of the results to return
8687
results := private.ServCommandResults{
@@ -295,8 +296,11 @@ func ServCommand(ctx *context.PrivateContext) {
295296
return
296297
}
297298
} else {
298-
// Because of the special ref "refs/for" we will need to delay write permission check
299-
if git.DefaultFeatures().SupportProcReceive && unitType == unit.TypeCode {
299+
// Because of the special ref "refs/for" (AGit) we will need to delay write permission check,
300+
// AGit flow needs to write its own ref when the doer has "reader" permission (allowing to create PR).
301+
// The real permission check is done in HookPreReceive (routers/private/hook_pre_receive.go).
302+
// Here it should relax the permission check for "git push (git-receive-pack)", but not for others like LFS operations.
303+
if git.DefaultFeatures().SupportProcReceive && unitType == unit.TypeCode && verb == git.CmdVerbReceivePack {
300304
mode = perm.AccessModeRead
301305
}
302306

0 commit comments

Comments
 (0)