Skip to content

Commit 8fcdd5c

Browse files
committed
fix
1 parent 44aadc3 commit 8fcdd5c

File tree

7 files changed

+125
-66
lines changed

7 files changed

+125
-66
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

+9
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,15 @@ func IsUsableRepoName(name string) error {
8989
return db.IsUsableName(vars.reservedRepoNames, vars.reservedRepoPatterns, name)
9090
}
9191

92+
// IsValidSSHAccessRepoName is like IsUsableRepoName, but it doesn't check reserved names (because "reponame.wiki" needs to be accessed)
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(nil, vars.reservedRepoPatterns, name) == nil
99+
}
100+
92101
// TrustModelType defines the types of trust model for this repository
93102
type TrustModelType int
94103

models/repo/repo_test.go

+11
Original file line numberDiff line numberDiff line change
@@ -221,3 +221,14 @@ func TestIsUsableRepoName(t *testing.T) {
221221
assert.Error(t, IsUsableRepoName("foo.git"))
222222
assert.Error(t, IsUsableRepoName("foo.RSS"))
223223
}
224+
225+
func TestIsValidSSHAccessRepoName(t *testing.T) {
226+
assert.True(t, IsValidSSHAccessRepoName("a"))
227+
assert.True(t, IsValidSSHAccessRepoName("-1_."))
228+
assert.True(t, IsValidSSHAccessRepoName(".profile"))
229+
assert.True(t, IsValidSSHAccessRepoName("foo.wiki"))
230+
231+
assert.False(t, IsValidSSHAccessRepoName("-"))
232+
assert.False(t, IsValidSSHAccessRepoName("🌞"))
233+
assert.False(t, IsValidSSHAccessRepoName("the..repo"))
234+
}

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-5
Original file line numberDiff line numberDiff line change
@@ -46,17 +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-
}
56+
reqURL += "&verb=" + url.QueryEscape(verb)
57+
if lfsVerb != "" {
58+
reqURL += "&lfs_verb=" + url.QueryEscape(lfsVerb)
6059
}
6160
req := newInternalRequestAPI(ctx, reqURL, "GET")
6261
return requestJSONResp(req, &ServCommandResults{})

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

tests/integration/git_general_test.go

+28-1
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import (
1111
"net/http"
1212
"net/url"
1313
"os"
14+
"os/exec"
1415
"path"
1516
"path/filepath"
1617
"strconv"
@@ -30,6 +31,7 @@ import (
3031
api "code.gitea.io/gitea/modules/structs"
3132
"code.gitea.io/gitea/tests"
3233

34+
"github.com/kballard/go-shellquote"
3335
"github.com/stretchr/testify/assert"
3436
"github.com/stretchr/testify/require"
3537
)
@@ -105,7 +107,11 @@ func testGitGeneral(t *testing.T, u *url.URL) {
105107

106108
// Setup key the user ssh key
107109
withKeyFile(t, keyname, func(keyFile string) {
108-
t.Run("CreateUserKey", doAPICreateUserKey(sshContext, "test-key", keyFile))
110+
var keyID int64
111+
t.Run("CreateUserKey", doAPICreateUserKey(sshContext, "test-key", keyFile, func(t *testing.T, key api.PublicKey) {
112+
keyID = key.ID
113+
}))
114+
assert.NotZero(t, keyID)
109115

110116
// Setup remote link
111117
// TODO: get url from api
@@ -132,10 +138,31 @@ func testGitGeneral(t *testing.T, u *url.URL) {
132138
})
133139

134140
t.Run("PushCreate", doPushCreate(sshContext, sshURL))
141+
t.Run("LFSUploadTest", doLFSUploadTest(sshContext, keyID))
135142
})
136143
})
137144
}
138145

146+
func doLFSUploadTest(_ APITestContext, keyID int64) func(*testing.T) {
147+
return func(t *testing.T) {
148+
sshCommand := os.Getenv("GIT_SSH_COMMAND") // it is set in withKeyFile and ensure correctly
149+
sshCmdParts, err := shellquote.Split(sshCommand) // and parse the ssh command to construct some mocked arguments
150+
require.NoError(t, err)
151+
152+
t.Run("User2AccessUser5Repo4", func(t *testing.T) {
153+
sshCmdParts = append(sshCmdParts,
154+
"-p", strconv.Itoa(setting.SSH.ListenPort), "git@"+setting.SSH.ListenHost,
155+
"git-lfs-authenticate", "user5/repo4.git", "upload", // user2's key upload lfs file to user5/repo4
156+
)
157+
cmd := exec.CommandContext(t.Context(), sshCmdParts[0], sshCmdParts[1:]...)
158+
_, err := cmd.Output()
159+
var errExit *exec.ExitError
160+
require.ErrorAs(t, err, &errExit)
161+
assert.Contains(t, string(errExit.Stderr), fmt.Sprintf("User: 2:user2 with Key: %d:test-key is not authorized to write to user5/repo4.", keyID))
162+
})
163+
}
164+
}
165+
139166
func ensureAnonymousClone(t *testing.T, u *url.URL) {
140167
dstLocalPath := t.TempDir()
141168
t.Run("CloneAnonymous", doGitClone(dstLocalPath, u))

0 commit comments

Comments
 (0)