Skip to content

Commit e88ea03

Browse files
6543lunnyzeripathtechknowlogick
committed
Add Allow-/Block-List for Migrate & Mirrors (#13610)
* add black list and white list support for migrating repositories * specify log message * use blocklist/allowlist * allways use lowercase to match url * Apply allow/block * Settings: use existing "migrations" section * convert domains lower case * dont store unused value * Block private addresses for migration by default * use proposed-upstream func to detect private IP addr * add own error for blocked migration, add tests, imprufe api * fix test * fix-if-localhost-is-ipv4 * rename error & error message * rename setting options * Apply suggestions from code review Co-authored-by: Lunny Xiao <[email protected]> Co-authored-by: zeripath <[email protected]> Co-authored-by: techknowlogick <[email protected]>
1 parent d475b65 commit e88ea03

File tree

11 files changed

+229
-4
lines changed

11 files changed

+229
-4
lines changed

custom/conf/app.example.ini

+8
Original file line numberDiff line numberDiff line change
@@ -1188,6 +1188,14 @@ QUEUE_CONN_STR = "addrs=127.0.0.1:6379 db=0"
11881188
MAX_ATTEMPTS = 3
11891189
; Backoff time per http/https request retry (seconds)
11901190
RETRY_BACKOFF = 3
1191+
; Allowed domains for migrating, default is blank. Blank means everything will be allowed.
1192+
; Multiple domains could be separated by commas.
1193+
ALLOWED_DOMAINS =
1194+
; Blocklist for migrating, default is blank. Multiple domains could be separated by commas.
1195+
; When ALLOWED_DOMAINS is not blank, this option will be ignored.
1196+
BLOCKED_DOMAINS =
1197+
; Allow private addresses defined by RFC 1918, RFC 1122, RFC 4632 and RFC 4291 (false by default)
1198+
ALLOW_LOCALNETWORKS = false
11911199

11921200
; default storage for attachments, lfs and avatars
11931201
[storage]

docs/content/doc/advanced/config-cheat-sheet.en-us.md

+3
Original file line numberDiff line numberDiff line change
@@ -811,6 +811,9 @@ Task queue configuration has been moved to `queue.task`. However, the below conf
811811

812812
- `MAX_ATTEMPTS`: **3**: Max attempts per http/https request on migrations.
813813
- `RETRY_BACKOFF`: **3**: Backoff time per http/https request retry (seconds)
814+
- `ALLOWED_DOMAINS`: **\<empty\>**: Domains allowlist for migrating repositories, default is blank. It means everything will be allowed. Multiple domains could be separated by commas.
815+
- `BLOCKED_DOMAINS`: **\<empty\>**: Domains blocklist for migrating repositories, default is blank. Multiple domains could be separated by commas. When `ALLOWED_DOMAINS` is not blank, this option will be ignored.
816+
- `ALLOW_LOCALNETWORKS`: **false**: Allow private addresses defined by RFC 1918, RFC 1122, RFC 4632 and RFC 4291
814817

815818
## Mirror (`mirror`)
816819

docs/content/doc/advanced/config-cheat-sheet.zh-cn.md

+3
Original file line numberDiff line numberDiff line change
@@ -313,6 +313,9 @@ IS_INPUT_FILE = false
313313

314314
- `MAX_ATTEMPTS`: **3**: 在迁移过程中的 http/https 请求重试次数。
315315
- `RETRY_BACKOFF`: **3**: 等待下一次重试的时间,单位秒。
316+
- `ALLOWED_DOMAINS`: **\<empty\>**: 迁移仓库的域名白名单,默认为空,表示允许从任意域名迁移仓库,多个域名用逗号分隔。
317+
- `BLOCKED_DOMAINS`: **\<empty\>**: 迁移仓库的域名黑名单,默认为空,多个域名用逗号分隔。如果 `ALLOWED_DOMAINS` 不为空,此选项将会被忽略。
318+
- `ALLOW_LOCALNETWORKS`: **false**: Allow private addresses defined by RFC 1918
316319

317320
## LFS (`lfs`)
318321

integrations/api_repo_test.go

+11-1
Original file line numberDiff line numberDiff line change
@@ -309,6 +309,8 @@ 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},
313+
{ctxUserID: 2, userID: 3, cloneURL: "https://10.0.0.1/user/test_repo.git", repoName: "private-ip", expectedStatus: http.StatusUnprocessableEntity},
312314
}
313315

314316
defer prepareTestEnv(t)()
@@ -325,8 +327,16 @@ func TestAPIRepoMigrate(t *testing.T) {
325327
if resp.Code == http.StatusUnprocessableEntity {
326328
respJSON := map[string]string{}
327329
DecodeJSON(t, resp, &respJSON)
328-
if assert.Equal(t, respJSON["message"], "Remote visit addressed rate limitation.") {
330+
switch respJSON["message"] {
331+
case "Remote visit addressed rate limitation.":
329332
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'":
334+
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)
338+
default:
339+
t.Errorf("unexpected error '%v' on url '%s'", respJSON["message"], testCase.cloneURL)
330340
}
331341
} else {
332342
assert.EqualValues(t, testCase.expectedStatus, resp.Code)

models/error.go

+23
Original file line numberDiff line numberDiff line change
@@ -1019,6 +1019,29 @@ func IsErrWontSign(err error) bool {
10191019
return ok
10201020
}
10211021

1022+
// ErrMigrationNotAllowed explains why a migration from an url is not allowed
1023+
type ErrMigrationNotAllowed struct {
1024+
Host string
1025+
NotResolvedIP bool
1026+
PrivateNet string
1027+
}
1028+
1029+
func (e *ErrMigrationNotAllowed) Error() string {
1030+
if e.NotResolvedIP {
1031+
return fmt.Sprintf("migrate from '%s' is not allowed: unknown hostname", e.Host)
1032+
}
1033+
if len(e.PrivateNet) != 0 {
1034+
return fmt.Sprintf("migrate from '%s' is not allowed: the host resolve to a private ip address '%s'", e.Host, e.PrivateNet)
1035+
}
1036+
return fmt.Sprintf("migrate from '%s is not allowed'", e.Host)
1037+
}
1038+
1039+
// IsErrMigrationNotAllowed checks if an error is a ErrMigrationNotAllowed
1040+
func IsErrMigrationNotAllowed(err error) bool {
1041+
_, ok := err.(*ErrMigrationNotAllowed)
1042+
return ok
1043+
}
1044+
10221045
// __________ .__
10231046
// \______ \____________ ____ ____ | |__
10241047
// | | _/\_ __ \__ \ / \_/ ___\| | \

modules/matchlist/matchlist.go

+46
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
// Copyright 2019 The Gitea Authors. All rights reserved.
2+
// Use of this source code is governed by a MIT-style
3+
// license that can be found in the LICENSE file.
4+
5+
package matchlist
6+
7+
import (
8+
"strings"
9+
10+
"github.com/gobwas/glob"
11+
)
12+
13+
// Matchlist represents a block or allow list
14+
type Matchlist struct {
15+
ruleGlobs []glob.Glob
16+
}
17+
18+
// NewMatchlist creates a new block or allow list
19+
func NewMatchlist(rules ...string) (*Matchlist, error) {
20+
for i := range rules {
21+
rules[i] = strings.ToLower(rules[i])
22+
}
23+
list := Matchlist{
24+
ruleGlobs: make([]glob.Glob, 0, len(rules)),
25+
}
26+
27+
for _, rule := range rules {
28+
rg, err := glob.Compile(rule)
29+
if err != nil {
30+
return nil, err
31+
}
32+
list.ruleGlobs = append(list.ruleGlobs, rg)
33+
}
34+
35+
return &list, nil
36+
}
37+
38+
// Match will matches
39+
func (b *Matchlist) Match(u string) bool {
40+
for _, r := range b.ruleGlobs {
41+
if r.Match(u) {
42+
return true
43+
}
44+
}
45+
return false
46+
}

modules/migrations/migrate.go

+74-1
Original file line numberDiff line numberDiff line change
@@ -8,9 +8,13 @@ package migrations
88
import (
99
"context"
1010
"fmt"
11+
"net"
12+
"net/url"
13+
"strings"
1114

1215
"code.gitea.io/gitea/models"
1316
"code.gitea.io/gitea/modules/log"
17+
"code.gitea.io/gitea/modules/matchlist"
1418
"code.gitea.io/gitea/modules/migrations/base"
1519
"code.gitea.io/gitea/modules/setting"
1620
)
@@ -20,19 +24,59 @@ type MigrateOptions = base.MigrateOptions
2024

2125
var (
2226
factories []base.DownloaderFactory
27+
28+
allowList *matchlist.Matchlist
29+
blockList *matchlist.Matchlist
2330
)
2431

2532
// RegisterDownloaderFactory registers a downloader factory
2633
func RegisterDownloaderFactory(factory base.DownloaderFactory) {
2734
factories = append(factories, factory)
2835
}
2936

37+
func isMigrateURLAllowed(remoteURL string) error {
38+
u, err := url.Parse(strings.ToLower(remoteURL))
39+
if err != nil {
40+
return err
41+
}
42+
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+
}
52+
}
53+
}
54+
55+
if !setting.Migrations.AllowLocalNetworks {
56+
addrList, err := net.LookupIP(strings.Split(u.Host, ":")[0])
57+
if err != nil {
58+
return &models.ErrMigrationNotAllowed{Host: u.Host, NotResolvedIP: true}
59+
}
60+
for _, addr := range addrList {
61+
if isIPPrivate(addr) || !addr.IsGlobalUnicast() {
62+
return &models.ErrMigrationNotAllowed{Host: u.Host, PrivateNet: addr.String()}
63+
}
64+
}
65+
}
66+
67+
return nil
68+
}
69+
3070
// MigrateRepository migrate repository according MigrateOptions
3171
func MigrateRepository(ctx context.Context, doer *models.User, ownerName string, opts base.MigrateOptions) (*models.Repository, error) {
72+
err := isMigrateURLAllowed(opts.CloneAddr)
73+
if err != nil {
74+
return nil, err
75+
}
76+
3277
var (
3378
downloader base.Downloader
3479
uploader = NewGiteaLocalUploader(ctx, doer, ownerName, opts.RepoName)
35-
err error
3680
)
3781

3882
for _, factory := range factories {
@@ -308,3 +352,32 @@ func migrateRepository(downloader base.Downloader, uploader base.Uploader, opts
308352

309353
return nil
310354
}
355+
356+
// Init migrations service
357+
func Init() error {
358+
var err error
359+
allowList, err = matchlist.NewMatchlist(setting.Migrations.AllowedDomains...)
360+
if err != nil {
361+
return fmt.Errorf("init migration allowList domains failed: %v", err)
362+
}
363+
364+
blockList, err = matchlist.NewMatchlist(setting.Migrations.BlockedDomains...)
365+
if err != nil {
366+
return fmt.Errorf("init migration blockList domains failed: %v", err)
367+
}
368+
369+
return nil
370+
}
371+
372+
// isIPPrivate reports whether ip is a private address, according to
373+
// RFC 1918 (IPv4 addresses) and RFC 4193 (IPv6 addresses).
374+
// from https://github.com/golang/go/pull/42793
375+
// TODO remove if https://github.com/golang/go/issues/29146 got resolved
376+
func isIPPrivate(ip net.IP) bool {
377+
if ip4 := ip.To4(); ip4 != nil {
378+
return ip4[0] == 10 ||
379+
(ip4[0] == 172 && ip4[1]&0xf0 == 16) ||
380+
(ip4[0] == 192 && ip4[1] == 168)
381+
}
382+
return len(ip) == net.IPv6len && ip[0]&0xfe == 0xfc
383+
}

modules/migrations/migrate_test.go

+34
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
// Copyright 2019 The Gitea Authors. All rights reserved.
2+
// Use of this source code is governed by a MIT-style
3+
// license that can be found in the LICENSE file.
4+
5+
package migrations
6+
7+
import (
8+
"testing"
9+
10+
"code.gitea.io/gitea/modules/setting"
11+
12+
"github.com/stretchr/testify/assert"
13+
)
14+
15+
func TestMigrateWhiteBlocklist(t *testing.T) {
16+
setting.Migrations.AllowedDomains = []string{"github.com"}
17+
assert.NoError(t, Init())
18+
19+
err := isMigrateURLAllowed("https://gitlab.com/gitlab/gitlab.git")
20+
assert.Error(t, err)
21+
22+
err = isMigrateURLAllowed("https://github.com/go-gitea/gitea.git")
23+
assert.NoError(t, err)
24+
25+
setting.Migrations.AllowedDomains = []string{}
26+
setting.Migrations.BlockedDomains = []string{"github.com"}
27+
assert.NoError(t, Init())
28+
29+
err = isMigrateURLAllowed("https://gitlab.com/gitlab/gitlab.git")
30+
assert.NoError(t, err)
31+
32+
err = isMigrateURLAllowed("https://github.com/go-gitea/gitea.git")
33+
assert.Error(t, err)
34+
}

modules/setting/migrations.go

+20-2
Original file line numberDiff line numberDiff line change
@@ -4,11 +4,18 @@
44

55
package setting
66

7+
import (
8+
"strings"
9+
)
10+
711
var (
812
// Migrations settings
913
Migrations = struct {
10-
MaxAttempts int
11-
RetryBackoff int
14+
MaxAttempts int
15+
RetryBackoff int
16+
AllowedDomains []string
17+
BlockedDomains []string
18+
AllowLocalNetworks bool
1219
}{
1320
MaxAttempts: 3,
1421
RetryBackoff: 3,
@@ -19,4 +26,15 @@ func newMigrationsService() {
1926
sec := Cfg.Section("migrations")
2027
Migrations.MaxAttempts = sec.Key("MAX_ATTEMPTS").MustInt(Migrations.MaxAttempts)
2128
Migrations.RetryBackoff = sec.Key("RETRY_BACKOFF").MustInt(Migrations.RetryBackoff)
29+
30+
Migrations.AllowedDomains = sec.Key("ALLOWED_DOMAINS").Strings(",")
31+
for i := range Migrations.AllowedDomains {
32+
Migrations.AllowedDomains[i] = strings.ToLower(Migrations.AllowedDomains[i])
33+
}
34+
Migrations.BlockedDomains = sec.Key("BLOCKED_DOMAINS").Strings(",")
35+
for i := range Migrations.BlockedDomains {
36+
Migrations.BlockedDomains[i] = strings.ToLower(Migrations.BlockedDomains[i])
37+
}
38+
39+
Migrations.AllowLocalNetworks = sec.Key("ALLOW_LOCALNETWORKS").MustBool(false)
2240
}

routers/api/v1/repo/migrate.go

+2
Original file line numberDiff line numberDiff line change
@@ -212,6 +212,8 @@ func handleMigrateError(ctx *context.APIContext, repoOwner *models.User, remoteA
212212
ctx.Error(http.StatusUnprocessableEntity, "", fmt.Sprintf("The username '%s' contains invalid characters.", err.(models.ErrNameCharsNotAllowed).Name))
213213
case models.IsErrNamePatternNotAllowed(err):
214214
ctx.Error(http.StatusUnprocessableEntity, "", fmt.Sprintf("The pattern '%s' is not allowed in a username.", err.(models.ErrNamePatternNotAllowed).Pattern))
215+
case models.IsErrMigrationNotAllowed(err):
216+
ctx.Error(http.StatusUnprocessableEntity, "", err)
215217
default:
216218
err = util.URLSanitizedError(err, remoteAddr)
217219
if strings.Contains(err.Error(), "Authentication failed") ||

routers/init.go

+5
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ import (
2424
"code.gitea.io/gitea/modules/log"
2525
"code.gitea.io/gitea/modules/markup"
2626
"code.gitea.io/gitea/modules/markup/external"
27+
repo_migrations "code.gitea.io/gitea/modules/migrations"
2728
"code.gitea.io/gitea/modules/notification"
2829
"code.gitea.io/gitea/modules/options"
2930
"code.gitea.io/gitea/modules/setting"
@@ -174,6 +175,10 @@ func GlobalInit(ctx context.Context) {
174175
}
175176
checkRunMode()
176177

178+
if err := repo_migrations.Init(); err != nil {
179+
log.Fatal("Failed to initialize repository migrations: %v", err)
180+
}
181+
177182
// Now because Install will re-run GlobalInit once it has set InstallLock
178183
// we can't tell if the ssh port will remain unused until that's done.
179184
// However, see FIXME comment in install.go

0 commit comments

Comments
 (0)