Skip to content

Commit

Permalink
Use hostmatcher to replace matchlist, improve security (#17605)
Browse files Browse the repository at this point in the history
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.
  • Loading branch information
wxiaoguang committed Nov 20, 2021
1 parent c96be0c commit 013fb73
Show file tree
Hide file tree
Showing 33 changed files with 376 additions and 292 deletions.
2 changes: 1 addition & 1 deletion custom/conf/app.example.ini
Original file line number Diff line number Diff line change
Expand Up @@ -2114,7 +2114,7 @@ PATH =
;ALLOWED_DOMAINS =
;;
;; Blocklist for migrating, default is blank. Multiple domains could be separated by commas.
;; When ALLOWED_DOMAINS is not blank, this option will be ignored.
;; When ALLOWED_DOMAINS is not blank, this option has a higher priority to deny domains.
;BLOCKED_DOMAINS =
;;
;; Allow private addresses defined by RFC 1918, RFC 1122, RFC 4632 and RFC 4291 (false by default)
Expand Down
2 changes: 1 addition & 1 deletion docs/content/doc/advanced/config-cheat-sheet.en-us.md
Original file line number Diff line number Diff line change
Expand Up @@ -1045,7 +1045,7 @@ Task queue configuration has been moved to `queue.task`. However, the below conf
- `MAX_ATTEMPTS`: **3**: Max attempts per http/https request on migrations.
- `RETRY_BACKOFF`: **3**: Backoff time per http/https request retry (seconds)
- `ALLOWED_DOMAINS`: **\<empty\>**: Domains allowlist for migrating repositories, default is blank. It means everything will be allowed. Multiple domains could be separated by commas.
- `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.
- `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.
- `ALLOW_LOCALNETWORKS`: **false**: Allow private addresses defined by RFC 1918, RFC 1122, RFC 4632 and RFC 4291
- `SKIP_TLS_VERIFY`: **false**: Allow skip tls verify

Expand Down
2 changes: 1 addition & 1 deletion docs/content/doc/advanced/config-cheat-sheet.zh-cn.md
Original file line number Diff line number Diff line change
Expand Up @@ -335,7 +335,7 @@ IS_INPUT_FILE = false
- `MAX_ATTEMPTS`: **3**: 在迁移过程中的 http/https 请求重试次数。
- `RETRY_BACKOFF`: **3**: 等待下一次重试的时间,单位秒。
- `ALLOWED_DOMAINS`: **\<empty\>**: 迁移仓库的域名白名单,默认为空,表示允许从任意域名迁移仓库,多个域名用逗号分隔。
- `BLOCKED_DOMAINS`: **\<empty\>**: 迁移仓库的域名黑名单,默认为空,多个域名用逗号分隔。如果 `ALLOWED_DOMAINS` 不为空,此选项将会被忽略
- `BLOCKED_DOMAINS`: **\<empty\>**: 迁移仓库的域名黑名单,默认为空,多个域名用逗号分隔。如果 `ALLOWED_DOMAINS` 不为空,此选项有更高的优先级拒绝这里的域名
- `ALLOW_LOCALNETWORKS`: **false**: Allow private addresses defined by RFC 1918
- `SKIP_TLS_VERIFY`: **false**: 允许忽略 TLS 认证

Expand Down
3 changes: 3 additions & 0 deletions integrations/api_repo_lfs_migrate_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import (
"code.gitea.io/gitea/modules/lfs"
"code.gitea.io/gitea/modules/setting"
api "code.gitea.io/gitea/modules/structs"
"code.gitea.io/gitea/services/migrations"

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

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

setting.ImportLocalPaths = oldImportLocalPaths
setting.Migrations.AllowLocalNetworks = oldAllowLocalNetworks
assert.NoError(t, migrations.Init()) // reset old migration settings
}
4 changes: 2 additions & 2 deletions integrations/api_repo_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -331,10 +331,10 @@ func TestAPIRepoMigrate(t *testing.T) {
switch respJSON["message"] {
case "Remote visit addressed rate limitation.":
t.Log("test hit github rate limitation")
case "You are not allowed to import from private IPs.":
case "You can not import from disallowed hosts.":
assert.EqualValues(t, "private-ip", testCase.repoName)
default:
t.Errorf("unexpected error '%v' on url '%s'", respJSON["message"], testCase.cloneURL)
assert.Fail(t, "unexpected error '%v' on url '%s'", respJSON["message"], testCase.cloneURL)
}
} else {
assert.EqualValues(t, testCase.expectedStatus, resp.Code)
Expand Down
2 changes: 1 addition & 1 deletion integrations/mirror_pull_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ func TestMirrorPull(t *testing.T) {

ctx := context.Background()

mirror, err := repository.MigrateRepositoryGitData(ctx, user, mirrorRepo, opts)
mirror, err := repository.MigrateRepositoryGitData(ctx, user, mirrorRepo, opts, nil)
assert.NoError(t, err)

gitRepo, err := git.OpenRepository(repoPath)
Expand Down
2 changes: 2 additions & 0 deletions integrations/mirror_push_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import (
"code.gitea.io/gitea/modules/git"
"code.gitea.io/gitea/modules/repository"
"code.gitea.io/gitea/modules/setting"
"code.gitea.io/gitea/services/migrations"
mirror_service "code.gitea.io/gitea/services/mirror"

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

setting.Migrations.AllowLocalNetworks = true
assert.NoError(t, migrations.Init())

user := unittest.AssertExistsAndLoadBean(t, &models.User{ID: 2}).(*models.User)
srcRepo := unittest.AssertExistsAndLoadBean(t, &models.Repository{ID: 1}).(*models.Repository)
Expand Down
4 changes: 0 additions & 4 deletions models/error.go
Original file line number Diff line number Diff line change
Expand Up @@ -797,7 +797,6 @@ type ErrInvalidCloneAddr struct {
IsPermissionDenied bool
LocalPath bool
NotResolvedIP bool
PrivateNet string
}

// IsErrInvalidCloneAddr checks if an error is a ErrInvalidCloneAddr.
Expand All @@ -810,9 +809,6 @@ func (err *ErrInvalidCloneAddr) Error() string {
if err.NotResolvedIP {
return fmt.Sprintf("migration/cloning from '%s' is not allowed: unknown hostname", err.Host)
}
if len(err.PrivateNet) != 0 {
return fmt.Sprintf("migration/cloning from '%s' is not allowed: the host resolve to a private ip address '%s'", err.Host, err.PrivateNet)
}
if err.IsInvalidPath {
return fmt.Sprintf("migration/cloning from '%s' is not allowed: the provided path is invalid", err.Host)
}
Expand Down
138 changes: 99 additions & 39 deletions modules/hostmatcher/hostmatcher.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,15 +13,18 @@ import (
)

// HostMatchList is used to check if a host or IP is in a list.
// If you only need to do wildcard matching, consider to use modules/matchlist
type HostMatchList struct {
hosts []string
SettingKeyHint string
SettingValue string

// builtins networks
builtins []string
// patterns for host names (with wildcard support)
patterns []string
// ipNets is the CIDR network list
ipNets []*net.IPNet
}

// MatchBuiltinAll all hosts are matched
const MatchBuiltinAll = "*"

// MatchBuiltinExternal A valid non-private unicast IP, all hosts on public internet are matched
const MatchBuiltinExternal = "external"

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

func isBuiltin(s string) bool {
return s == MatchBuiltinExternal || s == MatchBuiltinPrivate || s == MatchBuiltinLoopback
}

// ParseHostMatchList parses the host list HostMatchList
func ParseHostMatchList(hostList string) *HostMatchList {
hl := &HostMatchList{}
func ParseHostMatchList(settingKeyHint string, hostList string) *HostMatchList {
hl := &HostMatchList{SettingKeyHint: settingKeyHint, SettingValue: hostList}
for _, s := range strings.Split(hostList, ",") {
s = strings.ToLower(strings.TrimSpace(s))
if s == "" {
Expand All @@ -42,53 +49,106 @@ func ParseHostMatchList(hostList string) *HostMatchList {
_, ipNet, err := net.ParseCIDR(s)
if err == nil {
hl.ipNets = append(hl.ipNets, ipNet)
} else if isBuiltin(s) {
hl.builtins = append(hl.builtins, s)
} else {
hl.hosts = append(hl.hosts, s)
hl.patterns = append(hl.patterns, s)
}
}
return hl
}

// MatchesHostOrIP checks if the host or IP matches an allow/deny(block) list
func (hl *HostMatchList) MatchesHostOrIP(host string, ip net.IP) bool {
var matched bool
host = strings.ToLower(host)
ipStr := ip.String()
loop:
for _, hostInList := range hl.hosts {
switch hostInList {
case "":
// ParseSimpleMatchList parse a simple matchlist (no built-in networks, no CIDR support, only wildcard pattern match)
func ParseSimpleMatchList(settingKeyHint string, matchList string) *HostMatchList {
hl := &HostMatchList{
SettingKeyHint: settingKeyHint,
SettingValue: matchList,
}
for _, s := range strings.Split(matchList, ",") {
s = strings.ToLower(strings.TrimSpace(s))
if s == "" {
continue
case MatchBuiltinAll:
matched = true
break loop
}
// we keep the same result as old `matchlist`, so no builtin/CIDR support here, we only match wildcard patterns
hl.patterns = append(hl.patterns, s)
}
return hl
}

// AppendBuiltin appends more builtins to match
func (hl *HostMatchList) AppendBuiltin(builtin string) {
hl.builtins = append(hl.builtins, builtin)
}

// IsEmpty checks if the checklist is empty
func (hl *HostMatchList) IsEmpty() bool {
return hl == nil || (len(hl.builtins) == 0 && len(hl.patterns) == 0 && len(hl.ipNets) == 0)
}

func (hl *HostMatchList) checkPattern(host string) bool {
host = strings.ToLower(strings.TrimSpace(host))
for _, pattern := range hl.patterns {
if matched, _ := filepath.Match(pattern, host); matched {
return true
}
}
return false
}

func (hl *HostMatchList) checkIP(ip net.IP) bool {
for _, pattern := range hl.patterns {
if pattern == "*" {
return true
}
}
for _, builtin := range hl.builtins {
switch builtin {
case MatchBuiltinExternal:
if matched = ip.IsGlobalUnicast() && !util.IsIPPrivate(ip); matched {
break loop
if ip.IsGlobalUnicast() && !util.IsIPPrivate(ip) {
return true
}
case MatchBuiltinPrivate:
if matched = util.IsIPPrivate(ip); matched {
break loop
if util.IsIPPrivate(ip) {
return true
}
case MatchBuiltinLoopback:
if matched = ip.IsLoopback(); matched {
break loop
}
default:
if matched, _ = filepath.Match(hostInList, host); matched {
break loop
}
if matched, _ = filepath.Match(hostInList, ipStr); matched {
break loop
if ip.IsLoopback() {
return true
}
}
}
if !matched {
for _, ipNet := range hl.ipNets {
if matched = ipNet.Contains(ip); matched {
break
}
for _, ipNet := range hl.ipNets {
if ipNet.Contains(ip) {
return true
}
}
return matched
return false
}

// MatchHostName checks if the host matches an allow/deny(block) list
func (hl *HostMatchList) MatchHostName(host string) bool {
if hl == nil {
return false
}
if hl.checkPattern(host) {
return true
}
if ip := net.ParseIP(host); ip != nil {
return hl.checkIP(ip)
}
return false
}

// MatchIPAddr checks if the IP matches an allow/deny(block) list, it's safe to pass `nil` to `ip`
func (hl *HostMatchList) MatchIPAddr(ip net.IP) bool {
if hl == nil {
return false
}
host := ip.String() // nil-safe, we will get "<nil>" if ip is nil
return hl.checkPattern(host) || hl.checkIP(ip)
}

// MatchHostOrIP checks if the host or IP matches an allow/deny(block) list
func (hl *HostMatchList) MatchHostOrIP(host string, ip net.IP) bool {
return hl.MatchHostName(host) || hl.MatchIPAddr(ip)
}
Loading

0 comments on commit 013fb73

Please sign in to comment.