Skip to content

Commit 6e423d5

Browse files
Ensure validation occurs on clone addresses too (#14994)
* Ensure validation occurs on clone addresses too Fix #14984 Signed-off-by: Andrew Thornton <[email protected]> * fix lint Signed-off-by: Andrew Thornton <[email protected]> * fix test Signed-off-by: Andrew Thornton <[email protected]> * Fix api tests Signed-off-by: Andrew Thornton <[email protected]> Co-authored-by: techknowlogick <[email protected]>
1 parent f268b48 commit 6e423d5

File tree

10 files changed

+165
-129
lines changed

10 files changed

+165
-129
lines changed

integrations/api_repo_test.go

Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -309,7 +309,7 @@ func TestAPIRepoMigrate(t *testing.T) {
309309
{ctxUserID: 2, userID: 1, cloneURL: "https://github.com/go-gitea/test_repo.git", repoName: "git-bad", expectedStatus: http.StatusForbidden},
310310
{ctxUserID: 2, userID: 3, cloneURL: "https://github.com/go-gitea/test_repo.git", repoName: "git-org", expectedStatus: http.StatusCreated},
311311
{ctxUserID: 2, userID: 6, cloneURL: "https://github.com/go-gitea/test_repo.git", repoName: "git-bad-org", expectedStatus: http.StatusForbidden},
312-
{ctxUserID: 2, userID: 3, cloneURL: "https://localhost:3000/user/test_repo.git", repoName: "local-ip", expectedStatus: http.StatusUnprocessableEntity},
312+
{ctxUserID: 2, userID: 3, cloneURL: "https://localhost:3000/user/test_repo.git", repoName: "private-ip", expectedStatus: http.StatusUnprocessableEntity},
313313
{ctxUserID: 2, userID: 3, cloneURL: "https://10.0.0.1/user/test_repo.git", repoName: "private-ip", expectedStatus: http.StatusUnprocessableEntity},
314314
}
315315

@@ -330,11 +330,8 @@ func TestAPIRepoMigrate(t *testing.T) {
330330
switch respJSON["message"] {
331331
case "Remote visit addressed rate limitation.":
332332
t.Log("test hit github rate limitation")
333-
case "migrate from '10.0.0.1' is not allowed: the host resolve to a private ip address '10.0.0.1'":
333+
case "You are not allowed to import from private IPs.":
334334
assert.EqualValues(t, "private-ip", testCase.repoName)
335-
case "migrate from 'localhost:3000' is not allowed: the host resolve to a private ip address '::1'",
336-
"migrate from 'localhost:3000' is not allowed: the host resolve to a private ip address '127.0.0.1'":
337-
assert.EqualValues(t, "local-ip", testCase.repoName)
338335
default:
339336
t.Errorf("unexpected error '%v' on url '%s'", respJSON["message"], testCase.cloneURL)
340337
}

models/error.go

Lines changed: 27 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -855,20 +855,43 @@ func (err ErrRepoRedirectNotExist) Error() string {
855855

856856
// ErrInvalidCloneAddr represents a "InvalidCloneAddr" kind of error.
857857
type ErrInvalidCloneAddr struct {
858+
Host string
858859
IsURLError bool
859860
IsInvalidPath bool
861+
IsProtocolInvalid bool
860862
IsPermissionDenied bool
863+
LocalPath bool
864+
NotResolvedIP bool
865+
PrivateNet string
861866
}
862867

863868
// IsErrInvalidCloneAddr checks if an error is a ErrInvalidCloneAddr.
864869
func IsErrInvalidCloneAddr(err error) bool {
865-
_, ok := err.(ErrInvalidCloneAddr)
870+
_, ok := err.(*ErrInvalidCloneAddr)
866871
return ok
867872
}
868873

869-
func (err ErrInvalidCloneAddr) Error() string {
870-
return fmt.Sprintf("invalid clone address [is_url_error: %v, is_invalid_path: %v, is_permission_denied: %v]",
871-
err.IsURLError, err.IsInvalidPath, err.IsPermissionDenied)
874+
func (err *ErrInvalidCloneAddr) Error() string {
875+
if err.NotResolvedIP {
876+
return fmt.Sprintf("migration/cloning from '%s' is not allowed: unknown hostname", err.Host)
877+
}
878+
if len(err.PrivateNet) != 0 {
879+
return fmt.Sprintf("migration/cloning from '%s' is not allowed: the host resolve to a private ip address '%s'", err.Host, err.PrivateNet)
880+
}
881+
if err.IsInvalidPath {
882+
return fmt.Sprintf("migration/cloning from '%s' is not allowed: the provided path is invalid", err.Host)
883+
}
884+
if err.IsProtocolInvalid {
885+
return fmt.Sprintf("migration/cloning from '%s' is not allowed: the provided url protocol is not allowed", err.Host)
886+
}
887+
if err.IsPermissionDenied {
888+
return fmt.Sprintf("migration/cloning from '%s' is not allowed.", err.Host)
889+
}
890+
if err.IsURLError {
891+
return fmt.Sprintf("migration/cloning from '%s' is not allowed: the provided url is invalid", err.Host)
892+
}
893+
894+
return fmt.Sprintf("migration/cloning from '%s' is not allowed", err.Host)
872895
}
873896

874897
// ErrUpdateTaskNotExist represents a "UpdateTaskNotExist" kind of error.
@@ -1065,29 +1088,6 @@ func IsErrWontSign(err error) bool {
10651088
return ok
10661089
}
10671090

1068-
// ErrMigrationNotAllowed explains why a migration from an url is not allowed
1069-
type ErrMigrationNotAllowed struct {
1070-
Host string
1071-
NotResolvedIP bool
1072-
PrivateNet string
1073-
}
1074-
1075-
func (e *ErrMigrationNotAllowed) Error() string {
1076-
if e.NotResolvedIP {
1077-
return fmt.Sprintf("migrate from '%s' is not allowed: unknown hostname", e.Host)
1078-
}
1079-
if len(e.PrivateNet) != 0 {
1080-
return fmt.Sprintf("migrate from '%s' is not allowed: the host resolve to a private ip address '%s'", e.Host, e.PrivateNet)
1081-
}
1082-
return fmt.Sprintf("migrate from '%s is not allowed'", e.Host)
1083-
}
1084-
1085-
// IsErrMigrationNotAllowed checks if an error is a ErrMigrationNotAllowed
1086-
func IsErrMigrationNotAllowed(err error) bool {
1087-
_, ok := err.(*ErrMigrationNotAllowed)
1088-
return ok
1089-
}
1090-
10911091
// __________ .__
10921092
// \______ \____________ ____ ____ | |__
10931093
// | | _/\_ __ \__ \ / \_/ ___\| | \

models/user.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -296,7 +296,7 @@ func (u *User) CanEditGitHook() bool {
296296

297297
// CanImportLocal returns true if user can migrate repository by local path.
298298
func (u *User) CanImportLocal() bool {
299-
if !setting.ImportLocalPaths {
299+
if !setting.ImportLocalPaths || u == nil {
300300
return false
301301
}
302302
return u.IsAdmin || u.AllowImportLocal

modules/forms/repo_form.go

Lines changed: 2 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -12,10 +12,8 @@ import (
1212

1313
"code.gitea.io/gitea/models"
1414
"code.gitea.io/gitea/modules/context"
15-
"code.gitea.io/gitea/modules/log"
1615
"code.gitea.io/gitea/modules/setting"
1716
"code.gitea.io/gitea/modules/structs"
18-
"code.gitea.io/gitea/modules/util"
1917
"code.gitea.io/gitea/modules/web/middleware"
2018
"code.gitea.io/gitea/routers/utils"
2119

@@ -92,36 +90,20 @@ func (f *MigrateRepoForm) Validate(req *http.Request, errs binding.Errors) bindi
9290

9391
// ParseRemoteAddr checks if given remote address is valid,
9492
// and returns composed URL with needed username and password.
95-
// It also checks if given user has permission when remote address
96-
// is actually a local path.
97-
func ParseRemoteAddr(remoteAddr, authUsername, authPassword string, user *models.User) (string, error) {
93+
func ParseRemoteAddr(remoteAddr, authUsername, authPassword string) (string, error) {
9894
remoteAddr = strings.TrimSpace(remoteAddr)
9995
// Remote address can be HTTP/HTTPS/Git URL or local path.
10096
if strings.HasPrefix(remoteAddr, "http://") ||
10197
strings.HasPrefix(remoteAddr, "https://") ||
10298
strings.HasPrefix(remoteAddr, "git://") {
10399
u, err := url.Parse(remoteAddr)
104100
if err != nil {
105-
return "", models.ErrInvalidCloneAddr{IsURLError: true}
101+
return "", &models.ErrInvalidCloneAddr{IsURLError: true}
106102
}
107103
if len(authUsername)+len(authPassword) > 0 {
108104
u.User = url.UserPassword(authUsername, authPassword)
109105
}
110106
remoteAddr = u.String()
111-
if u.Scheme == "git" && u.Port() != "" && (strings.Contains(remoteAddr, "%0d") || strings.Contains(remoteAddr, "%0a")) {
112-
return "", models.ErrInvalidCloneAddr{IsURLError: true}
113-
}
114-
} else if !user.CanImportLocal() {
115-
return "", models.ErrInvalidCloneAddr{IsPermissionDenied: true}
116-
} else {
117-
isDir, err := util.IsDir(remoteAddr)
118-
if err != nil {
119-
log.Error("Unable to check if %s is a directory: %v", remoteAddr, err)
120-
return "", err
121-
}
122-
if !isDir {
123-
return "", models.ErrInvalidCloneAddr{IsInvalidPath: true}
124-
}
125107
}
126108

127109
return remoteAddr, nil

modules/migrations/migrate.go

Lines changed: 41 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -10,13 +10,15 @@ import (
1010
"fmt"
1111
"net"
1212
"net/url"
13+
"path/filepath"
1314
"strings"
1415

1516
"code.gitea.io/gitea/models"
1617
"code.gitea.io/gitea/modules/log"
1718
"code.gitea.io/gitea/modules/matchlist"
1819
"code.gitea.io/gitea/modules/migrations/base"
1920
"code.gitea.io/gitea/modules/setting"
21+
"code.gitea.io/gitea/modules/util"
2022
)
2123

2224
// MigrateOptions is equal to base.MigrateOptions
@@ -34,39 +36,60 @@ func RegisterDownloaderFactory(factory base.DownloaderFactory) {
3436
factories = append(factories, factory)
3537
}
3638

37-
func isMigrateURLAllowed(remoteURL string) error {
39+
// IsMigrateURLAllowed checks if an URL is allowed to be migrated from
40+
func IsMigrateURLAllowed(remoteURL string, doer *models.User) error {
41+
// Remote address can be HTTP/HTTPS/Git URL or local path.
3842
u, err := url.Parse(strings.ToLower(remoteURL))
3943
if err != nil {
40-
return err
44+
return &models.ErrInvalidCloneAddr{IsURLError: true}
4145
}
4246

43-
if strings.EqualFold(u.Scheme, "http") || strings.EqualFold(u.Scheme, "https") {
44-
if len(setting.Migrations.AllowedDomains) > 0 {
45-
if !allowList.Match(u.Host) {
46-
return &models.ErrMigrationNotAllowed{Host: u.Host}
47-
}
48-
} else {
49-
if blockList.Match(u.Host) {
50-
return &models.ErrMigrationNotAllowed{Host: u.Host}
51-
}
47+
if u.Scheme == "file" || u.Scheme == "" {
48+
if !doer.CanImportLocal() {
49+
return &models.ErrInvalidCloneAddr{Host: "<LOCAL_FILESYSTEM>", IsPermissionDenied: true, LocalPath: true}
50+
}
51+
isAbs := filepath.IsAbs(u.Host + u.Path)
52+
if !isAbs {
53+
return &models.ErrInvalidCloneAddr{Host: "<LOCAL_FILESYSTEM>", IsInvalidPath: true, LocalPath: true}
54+
}
55+
isDir, err := util.IsDir(u.Host + u.Path)
56+
if err != nil {
57+
log.Error("Unable to check if %s is a directory: %v", u.Host+u.Path, err)
58+
return err
59+
}
60+
if !isDir {
61+
return &models.ErrInvalidCloneAddr{Host: "<LOCAL_FILESYSTEM>", IsInvalidPath: true, LocalPath: true}
5262
}
63+
64+
return nil
65+
}
66+
67+
if u.Scheme == "git" && u.Port() != "" && (strings.Contains(remoteURL, "%0d") || strings.Contains(remoteURL, "%0a")) {
68+
return &models.ErrInvalidCloneAddr{Host: u.Host, IsURLError: true}
5369
}
5470

55-
if u.Host == "" {
56-
if !setting.ImportLocalPaths {
57-
return &models.ErrMigrationNotAllowed{Host: "<LOCAL_FILESYSTEM>"}
71+
if u.Opaque != "" || u.Scheme != "" && u.Scheme != "http" && u.Scheme != "https" && u.Scheme != "git" {
72+
return &models.ErrInvalidCloneAddr{Host: u.Host, IsProtocolInvalid: true, IsPermissionDenied: true, IsURLError: true}
73+
}
74+
75+
if len(setting.Migrations.AllowedDomains) > 0 {
76+
if !allowList.Match(u.Host) {
77+
return &models.ErrInvalidCloneAddr{Host: u.Host, IsPermissionDenied: true}
78+
}
79+
} else {
80+
if blockList.Match(u.Host) {
81+
return &models.ErrInvalidCloneAddr{Host: u.Host, IsPermissionDenied: true}
5882
}
59-
return nil
6083
}
6184

6285
if !setting.Migrations.AllowLocalNetworks {
6386
addrList, err := net.LookupIP(strings.Split(u.Host, ":")[0])
6487
if err != nil {
65-
return &models.ErrMigrationNotAllowed{Host: u.Host, NotResolvedIP: true}
88+
return &models.ErrInvalidCloneAddr{Host: u.Host, NotResolvedIP: true}
6689
}
6790
for _, addr := range addrList {
6891
if isIPPrivate(addr) || !addr.IsGlobalUnicast() {
69-
return &models.ErrMigrationNotAllowed{Host: u.Host, PrivateNet: addr.String()}
92+
return &models.ErrInvalidCloneAddr{Host: u.Host, PrivateNet: addr.String(), IsPermissionDenied: true}
7093
}
7194
}
7295
}
@@ -76,7 +99,7 @@ func isMigrateURLAllowed(remoteURL string) error {
7699

77100
// MigrateRepository migrate repository according MigrateOptions
78101
func MigrateRepository(ctx context.Context, doer *models.User, ownerName string, opts base.MigrateOptions) (*models.Repository, error) {
79-
err := isMigrateURLAllowed(opts.CloneAddr)
102+
err := IsMigrateURLAllowed(opts.CloneAddr, doer)
80103
if err != nil {
81104
return nil, err
82105
}

modules/migrations/migrate_test.go

Lines changed: 30 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -5,41 +5,65 @@
55
package migrations
66

77
import (
8+
"path/filepath"
89
"testing"
910

11+
"code.gitea.io/gitea/models"
1012
"code.gitea.io/gitea/modules/setting"
1113

1214
"github.com/stretchr/testify/assert"
1315
)
1416

1517
func TestMigrateWhiteBlocklist(t *testing.T) {
18+
assert.NoError(t, models.PrepareTestDatabase())
19+
20+
adminUser := models.AssertExistsAndLoadBean(t, &models.User{Name: "user1"}).(*models.User)
21+
nonAdminUser := models.AssertExistsAndLoadBean(t, &models.User{Name: "user2"}).(*models.User)
22+
1623
setting.Migrations.AllowedDomains = []string{"github.com"}
1724
assert.NoError(t, Init())
1825

19-
err := isMigrateURLAllowed("https://gitlab.com/gitlab/gitlab.git")
26+
err := IsMigrateURLAllowed("https://gitlab.com/gitlab/gitlab.git", nonAdminUser)
2027
assert.Error(t, err)
2128

22-
err = isMigrateURLAllowed("https://github.com/go-gitea/gitea.git")
29+
err = IsMigrateURLAllowed("https://github.com/go-gitea/gitea.git", nonAdminUser)
2330
assert.NoError(t, err)
2431

2532
setting.Migrations.AllowedDomains = []string{}
2633
setting.Migrations.BlockedDomains = []string{"github.com"}
2734
assert.NoError(t, Init())
2835

29-
err = isMigrateURLAllowed("https://gitlab.com/gitlab/gitlab.git")
36+
err = IsMigrateURLAllowed("https://gitlab.com/gitlab/gitlab.git", nonAdminUser)
3037
assert.NoError(t, err)
3138

32-
err = isMigrateURLAllowed("https://github.com/go-gitea/gitea.git")
39+
err = IsMigrateURLAllowed("https://github.com/go-gitea/gitea.git", nonAdminUser)
40+
assert.Error(t, err)
41+
42+
err = IsMigrateURLAllowed("https://10.0.0.1/go-gitea/gitea.git", nonAdminUser)
3343
assert.Error(t, err)
3444

45+
setting.Migrations.AllowLocalNetworks = true
46+
err = IsMigrateURLAllowed("https://10.0.0.1/go-gitea/gitea.git", nonAdminUser)
47+
assert.NoError(t, err)
48+
3549
old := setting.ImportLocalPaths
3650
setting.ImportLocalPaths = false
3751

38-
err = isMigrateURLAllowed("/home/foo/bar/goo")
52+
err = IsMigrateURLAllowed("/home/foo/bar/goo", adminUser)
3953
assert.Error(t, err)
4054

4155
setting.ImportLocalPaths = true
42-
err = isMigrateURLAllowed("/home/foo/bar/goo")
56+
abs, err := filepath.Abs(".")
57+
assert.NoError(t, err)
58+
59+
err = IsMigrateURLAllowed(abs, adminUser)
60+
assert.NoError(t, err)
61+
62+
err = IsMigrateURLAllowed(abs, nonAdminUser)
63+
assert.Error(t, err)
64+
65+
nonAdminUser.AllowImportLocal = true
66+
err = IsMigrateURLAllowed(abs, nonAdminUser)
4367
assert.NoError(t, err)
4468

4569
setting.ImportLocalPaths = old

options/locale/locale_en-US.ini

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -789,6 +789,8 @@ migrate.clone_address = Migrate / Clone From URL
789789
migrate.clone_address_desc = The HTTP(S) or Git 'clone' URL of an existing repository
790790
migrate.clone_local_path = or a local server path
791791
migrate.permission_denied = You are not allowed to import local repositories.
792+
migrate.permission_denied_blocked = You are not allowed to import from blocked hosts.
793+
migrate.permission_denied_private_ip = You are not allowed to import from private IPs.
792794
migrate.invalid_local_path = "The local path is invalid. It does not exist or is not a directory."
793795
migrate.failed = Migration failed: %v
794796
migrate.lfs_mirror_unsupported = Mirroring LFS objects is not supported - use 'git lfs fetch --all' and 'git lfs push --all' instead.

routers/api/v1/repo/migrate.go

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -96,15 +96,24 @@ func Migrate(ctx *context.APIContext) {
9696
}
9797
}
9898

99-
remoteAddr, err := auth.ParseRemoteAddr(form.CloneAddr, form.AuthUsername, form.AuthPassword, ctx.User)
99+
remoteAddr, err := auth.ParseRemoteAddr(form.CloneAddr, form.AuthUsername, form.AuthPassword)
100+
if err == nil {
101+
err = migrations.IsMigrateURLAllowed(remoteAddr, ctx.User)
102+
}
100103
if err != nil {
101104
if models.IsErrInvalidCloneAddr(err) {
102-
addrErr := err.(models.ErrInvalidCloneAddr)
105+
addrErr := err.(*models.ErrInvalidCloneAddr)
103106
switch {
104107
case addrErr.IsURLError:
105108
ctx.Error(http.StatusUnprocessableEntity, "", err)
106109
case addrErr.IsPermissionDenied:
107-
ctx.Error(http.StatusUnprocessableEntity, "", "You are not allowed to import local repositories.")
110+
if addrErr.LocalPath {
111+
ctx.Error(http.StatusUnprocessableEntity, "", "You are not allowed to import local repositories.")
112+
} else if len(addrErr.PrivateNet) == 0 {
113+
ctx.Error(http.StatusUnprocessableEntity, "", "You are not allowed to import from blocked hosts.")
114+
} else {
115+
ctx.Error(http.StatusUnprocessableEntity, "", "You are not allowed to import from private IPs.")
116+
}
108117
case addrErr.IsInvalidPath:
109118
ctx.Error(http.StatusUnprocessableEntity, "", "Invalid local path, it does not exist or not a directory.")
110119
default:
@@ -219,7 +228,7 @@ func handleMigrateError(ctx *context.APIContext, repoOwner *models.User, remoteA
219228
ctx.Error(http.StatusUnprocessableEntity, "", fmt.Sprintf("The username '%s' contains invalid characters.", err.(models.ErrNameCharsNotAllowed).Name))
220229
case models.IsErrNamePatternNotAllowed(err):
221230
ctx.Error(http.StatusUnprocessableEntity, "", fmt.Sprintf("The pattern '%s' is not allowed in a username.", err.(models.ErrNamePatternNotAllowed).Pattern))
222-
case models.IsErrMigrationNotAllowed(err):
231+
case models.IsErrInvalidCloneAddr(err):
223232
ctx.Error(http.StatusUnprocessableEntity, "", err)
224233
case base.IsErrNotSupported(err):
225234
ctx.Error(http.StatusUnprocessableEntity, "", err)

0 commit comments

Comments
 (0)