Skip to content

Commit 013fb73

Browse files
authored
Use hostmatcher to replace matchlist, improve security (#17605)
Use hostmacher to replace matchlist. And we introduce a better DialContext to do a full host/IP check, otherwise the attackers can still bypass the allow/block list by a 302 redirection.
1 parent c96be0c commit 013fb73

33 files changed

+376
-292
lines changed

custom/conf/app.example.ini

+1-1
Original file line numberDiff line numberDiff line change
@@ -2114,7 +2114,7 @@ PATH =
21142114
;ALLOWED_DOMAINS =
21152115
;;
21162116
;; Blocklist for migrating, default is blank. Multiple domains could be separated by commas.
2117-
;; When ALLOWED_DOMAINS is not blank, this option will be ignored.
2117+
;; When ALLOWED_DOMAINS is not blank, this option has a higher priority to deny domains.
21182118
;BLOCKED_DOMAINS =
21192119
;;
21202120
;; Allow private addresses defined by RFC 1918, RFC 1122, RFC 4632 and RFC 4291 (false by default)

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

+1-1
Original file line numberDiff line numberDiff line change
@@ -1045,7 +1045,7 @@ Task queue configuration has been moved to `queue.task`. However, the below conf
10451045
- `MAX_ATTEMPTS`: **3**: Max attempts per http/https request on migrations.
10461046
- `RETRY_BACKOFF`: **3**: Backoff time per http/https request retry (seconds)
10471047
- `ALLOWED_DOMAINS`: **\<empty\>**: Domains allowlist for migrating repositories, default is blank. It means everything will be allowed. Multiple domains could be separated by commas.
1048-
- `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.
1048+
- `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 has a higher priority to deny domains.
10491049
- `ALLOW_LOCALNETWORKS`: **false**: Allow private addresses defined by RFC 1918, RFC 1122, RFC 4632 and RFC 4291
10501050
- `SKIP_TLS_VERIFY`: **false**: Allow skip tls verify
10511051

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

+1-1
Original file line numberDiff line numberDiff line change
@@ -335,7 +335,7 @@ IS_INPUT_FILE = false
335335
- `MAX_ATTEMPTS`: **3**: 在迁移过程中的 http/https 请求重试次数。
336336
- `RETRY_BACKOFF`: **3**: 等待下一次重试的时间,单位秒。
337337
- `ALLOWED_DOMAINS`: **\<empty\>**: 迁移仓库的域名白名单,默认为空,表示允许从任意域名迁移仓库,多个域名用逗号分隔。
338-
- `BLOCKED_DOMAINS`: **\<empty\>**: 迁移仓库的域名黑名单,默认为空,多个域名用逗号分隔。如果 `ALLOWED_DOMAINS` 不为空,此选项将会被忽略
338+
- `BLOCKED_DOMAINS`: **\<empty\>**: 迁移仓库的域名黑名单,默认为空,多个域名用逗号分隔。如果 `ALLOWED_DOMAINS` 不为空,此选项有更高的优先级拒绝这里的域名
339339
- `ALLOW_LOCALNETWORKS`: **false**: Allow private addresses defined by RFC 1918
340340
- `SKIP_TLS_VERIFY`: **false**: 允许忽略 TLS 认证
341341

integrations/api_repo_lfs_migrate_test.go

+3
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import (
1414
"code.gitea.io/gitea/modules/lfs"
1515
"code.gitea.io/gitea/modules/setting"
1616
api "code.gitea.io/gitea/modules/structs"
17+
"code.gitea.io/gitea/services/migrations"
1718

1819
"github.com/stretchr/testify/assert"
1920
)
@@ -25,6 +26,7 @@ func TestAPIRepoLFSMigrateLocal(t *testing.T) {
2526
oldAllowLocalNetworks := setting.Migrations.AllowLocalNetworks
2627
setting.ImportLocalPaths = true
2728
setting.Migrations.AllowLocalNetworks = true
29+
assert.NoError(t, migrations.Init())
2830

2931
user := unittest.AssertExistsAndLoadBean(t, &models.User{ID: 1}).(*models.User)
3032
session := loginUser(t, user.Name)
@@ -47,4 +49,5 @@ func TestAPIRepoLFSMigrateLocal(t *testing.T) {
4749

4850
setting.ImportLocalPaths = oldImportLocalPaths
4951
setting.Migrations.AllowLocalNetworks = oldAllowLocalNetworks
52+
assert.NoError(t, migrations.Init()) // reset old migration settings
5053
}

integrations/api_repo_test.go

+2-2
Original file line numberDiff line numberDiff line change
@@ -331,10 +331,10 @@ func TestAPIRepoMigrate(t *testing.T) {
331331
switch respJSON["message"] {
332332
case "Remote visit addressed rate limitation.":
333333
t.Log("test hit github rate limitation")
334-
case "You are not allowed to import from private IPs.":
334+
case "You can not import from disallowed hosts.":
335335
assert.EqualValues(t, "private-ip", testCase.repoName)
336336
default:
337-
t.Errorf("unexpected error '%v' on url '%s'", respJSON["message"], testCase.cloneURL)
337+
assert.Fail(t, "unexpected error '%v' on url '%s'", respJSON["message"], testCase.cloneURL)
338338
}
339339
} else {
340340
assert.EqualValues(t, testCase.expectedStatus, resp.Code)

integrations/mirror_pull_test.go

+1-1
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,7 @@ func TestMirrorPull(t *testing.T) {
4747

4848
ctx := context.Background()
4949

50-
mirror, err := repository.MigrateRepositoryGitData(ctx, user, mirrorRepo, opts)
50+
mirror, err := repository.MigrateRepositoryGitData(ctx, user, mirrorRepo, opts, nil)
5151
assert.NoError(t, err)
5252

5353
gitRepo, err := git.OpenRepository(repoPath)

integrations/mirror_push_test.go

+2
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import (
1616
"code.gitea.io/gitea/modules/git"
1717
"code.gitea.io/gitea/modules/repository"
1818
"code.gitea.io/gitea/modules/setting"
19+
"code.gitea.io/gitea/services/migrations"
1920
mirror_service "code.gitea.io/gitea/services/mirror"
2021

2122
"github.com/stretchr/testify/assert"
@@ -29,6 +30,7 @@ func testMirrorPush(t *testing.T, u *url.URL) {
2930
defer prepareTestEnv(t)()
3031

3132
setting.Migrations.AllowLocalNetworks = true
33+
assert.NoError(t, migrations.Init())
3234

3335
user := unittest.AssertExistsAndLoadBean(t, &models.User{ID: 2}).(*models.User)
3436
srcRepo := unittest.AssertExistsAndLoadBean(t, &models.Repository{ID: 1}).(*models.Repository)

models/error.go

-4
Original file line numberDiff line numberDiff line change
@@ -797,7 +797,6 @@ type ErrInvalidCloneAddr struct {
797797
IsPermissionDenied bool
798798
LocalPath bool
799799
NotResolvedIP bool
800-
PrivateNet string
801800
}
802801

803802
// IsErrInvalidCloneAddr checks if an error is a ErrInvalidCloneAddr.
@@ -810,9 +809,6 @@ func (err *ErrInvalidCloneAddr) Error() string {
810809
if err.NotResolvedIP {
811810
return fmt.Sprintf("migration/cloning from '%s' is not allowed: unknown hostname", err.Host)
812811
}
813-
if len(err.PrivateNet) != 0 {
814-
return fmt.Sprintf("migration/cloning from '%s' is not allowed: the host resolve to a private ip address '%s'", err.Host, err.PrivateNet)
815-
}
816812
if err.IsInvalidPath {
817813
return fmt.Sprintf("migration/cloning from '%s' is not allowed: the provided path is invalid", err.Host)
818814
}

modules/hostmatcher/hostmatcher.go

+99-39
Original file line numberDiff line numberDiff line change
@@ -13,15 +13,18 @@ import (
1313
)
1414

1515
// HostMatchList is used to check if a host or IP is in a list.
16-
// If you only need to do wildcard matching, consider to use modules/matchlist
1716
type HostMatchList struct {
18-
hosts []string
17+
SettingKeyHint string
18+
SettingValue string
19+
20+
// builtins networks
21+
builtins []string
22+
// patterns for host names (with wildcard support)
23+
patterns []string
24+
// ipNets is the CIDR network list
1925
ipNets []*net.IPNet
2026
}
2127

22-
// MatchBuiltinAll all hosts are matched
23-
const MatchBuiltinAll = "*"
24-
2528
// MatchBuiltinExternal A valid non-private unicast IP, all hosts on public internet are matched
2629
const MatchBuiltinExternal = "external"
2730

@@ -31,9 +34,13 @@ const MatchBuiltinPrivate = "private"
3134
// MatchBuiltinLoopback 127.0.0.0/8 for IPv4 and ::1/128 for IPv6, localhost is included.
3235
const MatchBuiltinLoopback = "loopback"
3336

37+
func isBuiltin(s string) bool {
38+
return s == MatchBuiltinExternal || s == MatchBuiltinPrivate || s == MatchBuiltinLoopback
39+
}
40+
3441
// ParseHostMatchList parses the host list HostMatchList
35-
func ParseHostMatchList(hostList string) *HostMatchList {
36-
hl := &HostMatchList{}
42+
func ParseHostMatchList(settingKeyHint string, hostList string) *HostMatchList {
43+
hl := &HostMatchList{SettingKeyHint: settingKeyHint, SettingValue: hostList}
3744
for _, s := range strings.Split(hostList, ",") {
3845
s = strings.ToLower(strings.TrimSpace(s))
3946
if s == "" {
@@ -42,53 +49,106 @@ func ParseHostMatchList(hostList string) *HostMatchList {
4249
_, ipNet, err := net.ParseCIDR(s)
4350
if err == nil {
4451
hl.ipNets = append(hl.ipNets, ipNet)
52+
} else if isBuiltin(s) {
53+
hl.builtins = append(hl.builtins, s)
4554
} else {
46-
hl.hosts = append(hl.hosts, s)
55+
hl.patterns = append(hl.patterns, s)
4756
}
4857
}
4958
return hl
5059
}
5160

52-
// MatchesHostOrIP checks if the host or IP matches an allow/deny(block) list
53-
func (hl *HostMatchList) MatchesHostOrIP(host string, ip net.IP) bool {
54-
var matched bool
55-
host = strings.ToLower(host)
56-
ipStr := ip.String()
57-
loop:
58-
for _, hostInList := range hl.hosts {
59-
switch hostInList {
60-
case "":
61+
// ParseSimpleMatchList parse a simple matchlist (no built-in networks, no CIDR support, only wildcard pattern match)
62+
func ParseSimpleMatchList(settingKeyHint string, matchList string) *HostMatchList {
63+
hl := &HostMatchList{
64+
SettingKeyHint: settingKeyHint,
65+
SettingValue: matchList,
66+
}
67+
for _, s := range strings.Split(matchList, ",") {
68+
s = strings.ToLower(strings.TrimSpace(s))
69+
if s == "" {
6170
continue
62-
case MatchBuiltinAll:
63-
matched = true
64-
break loop
71+
}
72+
// we keep the same result as old `matchlist`, so no builtin/CIDR support here, we only match wildcard patterns
73+
hl.patterns = append(hl.patterns, s)
74+
}
75+
return hl
76+
}
77+
78+
// AppendBuiltin appends more builtins to match
79+
func (hl *HostMatchList) AppendBuiltin(builtin string) {
80+
hl.builtins = append(hl.builtins, builtin)
81+
}
82+
83+
// IsEmpty checks if the checklist is empty
84+
func (hl *HostMatchList) IsEmpty() bool {
85+
return hl == nil || (len(hl.builtins) == 0 && len(hl.patterns) == 0 && len(hl.ipNets) == 0)
86+
}
87+
88+
func (hl *HostMatchList) checkPattern(host string) bool {
89+
host = strings.ToLower(strings.TrimSpace(host))
90+
for _, pattern := range hl.patterns {
91+
if matched, _ := filepath.Match(pattern, host); matched {
92+
return true
93+
}
94+
}
95+
return false
96+
}
97+
98+
func (hl *HostMatchList) checkIP(ip net.IP) bool {
99+
for _, pattern := range hl.patterns {
100+
if pattern == "*" {
101+
return true
102+
}
103+
}
104+
for _, builtin := range hl.builtins {
105+
switch builtin {
65106
case MatchBuiltinExternal:
66-
if matched = ip.IsGlobalUnicast() && !util.IsIPPrivate(ip); matched {
67-
break loop
107+
if ip.IsGlobalUnicast() && !util.IsIPPrivate(ip) {
108+
return true
68109
}
69110
case MatchBuiltinPrivate:
70-
if matched = util.IsIPPrivate(ip); matched {
71-
break loop
111+
if util.IsIPPrivate(ip) {
112+
return true
72113
}
73114
case MatchBuiltinLoopback:
74-
if matched = ip.IsLoopback(); matched {
75-
break loop
76-
}
77-
default:
78-
if matched, _ = filepath.Match(hostInList, host); matched {
79-
break loop
80-
}
81-
if matched, _ = filepath.Match(hostInList, ipStr); matched {
82-
break loop
115+
if ip.IsLoopback() {
116+
return true
83117
}
84118
}
85119
}
86-
if !matched {
87-
for _, ipNet := range hl.ipNets {
88-
if matched = ipNet.Contains(ip); matched {
89-
break
90-
}
120+
for _, ipNet := range hl.ipNets {
121+
if ipNet.Contains(ip) {
122+
return true
91123
}
92124
}
93-
return matched
125+
return false
126+
}
127+
128+
// MatchHostName checks if the host matches an allow/deny(block) list
129+
func (hl *HostMatchList) MatchHostName(host string) bool {
130+
if hl == nil {
131+
return false
132+
}
133+
if hl.checkPattern(host) {
134+
return true
135+
}
136+
if ip := net.ParseIP(host); ip != nil {
137+
return hl.checkIP(ip)
138+
}
139+
return false
140+
}
141+
142+
// MatchIPAddr checks if the IP matches an allow/deny(block) list, it's safe to pass `nil` to `ip`
143+
func (hl *HostMatchList) MatchIPAddr(ip net.IP) bool {
144+
if hl == nil {
145+
return false
146+
}
147+
host := ip.String() // nil-safe, we will get "<nil>" if ip is nil
148+
return hl.checkPattern(host) || hl.checkIP(ip)
149+
}
150+
151+
// MatchHostOrIP checks if the host or IP matches an allow/deny(block) list
152+
func (hl *HostMatchList) MatchHostOrIP(host string, ip net.IP) bool {
153+
return hl.MatchHostName(host) || hl.MatchIPAddr(ip)
94154
}

0 commit comments

Comments
 (0)