Skip to content

Commit b25fcf6

Browse files
Merge branch 'main' into chi-store-for-oauth2
2 parents d377426 + 599ff1c commit b25fcf6

File tree

9 files changed

+284
-23
lines changed

9 files changed

+284
-23
lines changed

cmd/web.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -193,6 +193,10 @@ func listen(m http.Handler, handleRedirector bool) error {
193193
listenAddr = net.JoinHostPort(listenAddr, setting.HTTPPort)
194194
}
195195
log.Info("Listen: %v://%s%s", setting.Protocol, listenAddr, setting.AppSubURL)
196+
// This can be useful for users, many users do wrong to their config and get strange behaviors behind a reverse-proxy.
197+
// A user may fix the configuration mistake when he sees this log.
198+
// And this is also very helpful to maintainers to provide help to users to resolve their configuration problems.
199+
log.Info("AppURL(ROOT_URL): %s", setting.AppURL)
196200

197201
if setting.LFS.StartServer {
198202
log.Info("LFS server enabled")

custom/conf/app.example.ini

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1396,6 +1396,12 @@ PATH =
13961396
;; Deliver timeout in seconds
13971397
;DELIVER_TIMEOUT = 5
13981398
;;
1399+
;; Webhook can only call allowed hosts for security reasons. Comma separated list, eg: external, 192.168.1.0/24, *.mydomain.com
1400+
;; Built-in: loopback (for localhost), private (for LAN/intranet), external (for public hosts on internet), * (for all hosts)
1401+
;; CIDR list: 1.2.3.0/8, 2001:db8::/32
1402+
;; Wildcard hosts: *.mydomain.com, 192.168.100.*
1403+
;ALLOWED_HOST_LIST = external
1404+
;;
13991405
;; Allow insecure certification
14001406
;SKIP_TLS_VERIFY = false
14011407
;;

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

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -581,6 +581,14 @@ Define allowed algorithms and their minimum key length (use -1 to disable a type
581581

582582
- `QUEUE_LENGTH`: **1000**: Hook task queue length. Use caution when editing this value.
583583
- `DELIVER_TIMEOUT`: **5**: Delivery timeout (sec) for shooting webhooks.
584+
- `ALLOWED_HOST_LIST`: **external**: Webhook can only call allowed hosts for security reasons. Comma separated list.
585+
- Built-in networks:
586+
- `loopback`: 127.0.0.0/8 for IPv4 and ::1/128 for IPv6, localhost is included.
587+
- `private`: RFC 1918 (10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16) and RFC 4193 (FC00::/7). Also called LAN/Intranet.
588+
- `external`: A valid non-private unicast IP, you can access all hosts on public internet.
589+
- `*`: All hosts are allowed.
590+
- CIDR list: `1.2.3.0/8` for IPv4 and `2001:db8::/32` for IPv6
591+
- Wildcard hosts: `*.mydomain.com`, `192.168.100.*`
584592
- `SKIP_TLS_VERIFY`: **false**: Allow insecure certification.
585593
- `PAGING_NUM`: **10**: Number of webhook history events that are shown in one page.
586594
- `PROXY_URL`: **\<empty\>**: Proxy server URL, support http://, https//, socks://, blank will follow environment http_proxy/https_proxy. If not given, will use global proxy setting.

modules/hostmatcher/hostmatcher.go

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
// Copyright 2021 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 hostmatcher
6+
7+
import (
8+
"net"
9+
"path/filepath"
10+
"strings"
11+
12+
"code.gitea.io/gitea/modules/util"
13+
)
14+
15+
// 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
17+
type HostMatchList struct {
18+
hosts []string
19+
ipNets []*net.IPNet
20+
}
21+
22+
// MatchBuiltinAll all hosts are matched
23+
const MatchBuiltinAll = "*"
24+
25+
// MatchBuiltinExternal A valid non-private unicast IP, all hosts on public internet are matched
26+
const MatchBuiltinExternal = "external"
27+
28+
// MatchBuiltinPrivate RFC 1918 (10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16) and RFC 4193 (FC00::/7). Also called LAN/Intranet.
29+
const MatchBuiltinPrivate = "private"
30+
31+
// MatchBuiltinLoopback 127.0.0.0/8 for IPv4 and ::1/128 for IPv6, localhost is included.
32+
const MatchBuiltinLoopback = "loopback"
33+
34+
// ParseHostMatchList parses the host list HostMatchList
35+
func ParseHostMatchList(hostList string) *HostMatchList {
36+
hl := &HostMatchList{}
37+
for _, s := range strings.Split(hostList, ",") {
38+
s = strings.ToLower(strings.TrimSpace(s))
39+
if s == "" {
40+
continue
41+
}
42+
_, ipNet, err := net.ParseCIDR(s)
43+
if err == nil {
44+
hl.ipNets = append(hl.ipNets, ipNet)
45+
} else {
46+
hl.hosts = append(hl.hosts, s)
47+
}
48+
}
49+
return hl
50+
}
51+
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+
continue
62+
case MatchBuiltinAll:
63+
matched = true
64+
break loop
65+
case MatchBuiltinExternal:
66+
if matched = ip.IsGlobalUnicast() && !util.IsIPPrivate(ip); matched {
67+
break loop
68+
}
69+
case MatchBuiltinPrivate:
70+
if matched = util.IsIPPrivate(ip); matched {
71+
break loop
72+
}
73+
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
83+
}
84+
}
85+
}
86+
if !matched {
87+
for _, ipNet := range hl.ipNets {
88+
if matched = ipNet.Contains(ip); matched {
89+
break
90+
}
91+
}
92+
}
93+
return matched
94+
}
Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
// Copyright 2021 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 hostmatcher
6+
7+
import (
8+
"net"
9+
"testing"
10+
11+
"github.com/stretchr/testify/assert"
12+
)
13+
14+
func TestHostOrIPMatchesList(t *testing.T) {
15+
type tc struct {
16+
host string
17+
ip net.IP
18+
expected bool
19+
}
20+
21+
// for IPv6: "::1" is loopback, "fd00::/8" is private
22+
23+
hl := ParseHostMatchList("private, External, *.myDomain.com, 169.254.1.0/24")
24+
cases := []tc{
25+
{"", net.IPv4zero, false},
26+
{"", net.IPv6zero, false},
27+
28+
{"", net.ParseIP("127.0.0.1"), false},
29+
{"", net.ParseIP("::1"), false},
30+
31+
{"", net.ParseIP("10.0.1.1"), true},
32+
{"", net.ParseIP("192.168.1.1"), true},
33+
{"", net.ParseIP("fd00::1"), true},
34+
35+
{"", net.ParseIP("8.8.8.8"), true},
36+
{"", net.ParseIP("1001::1"), true},
37+
38+
{"mydomain.com", net.IPv4zero, false},
39+
{"sub.mydomain.com", net.IPv4zero, true},
40+
41+
{"", net.ParseIP("169.254.1.1"), true},
42+
{"", net.ParseIP("169.254.2.2"), false},
43+
}
44+
for _, c := range cases {
45+
assert.Equalf(t, c.expected, hl.MatchesHostOrIP(c.host, c.ip), "case %s(%v)", c.host, c.ip)
46+
}
47+
48+
hl = ParseHostMatchList("loopback")
49+
cases = []tc{
50+
{"", net.IPv4zero, false},
51+
{"", net.ParseIP("127.0.0.1"), true},
52+
{"", net.ParseIP("10.0.1.1"), false},
53+
{"", net.ParseIP("192.168.1.1"), false},
54+
{"", net.ParseIP("8.8.8.8"), false},
55+
56+
{"", net.ParseIP("::1"), true},
57+
{"", net.ParseIP("fd00::1"), false},
58+
{"", net.ParseIP("1000::1"), false},
59+
60+
{"mydomain.com", net.IPv4zero, false},
61+
}
62+
for _, c := range cases {
63+
assert.Equalf(t, c.expected, hl.MatchesHostOrIP(c.host, c.ip), "case %s(%v)", c.host, c.ip)
64+
}
65+
66+
hl = ParseHostMatchList("private")
67+
cases = []tc{
68+
{"", net.IPv4zero, false},
69+
{"", net.ParseIP("127.0.0.1"), false},
70+
{"", net.ParseIP("10.0.1.1"), true},
71+
{"", net.ParseIP("192.168.1.1"), true},
72+
{"", net.ParseIP("8.8.8.8"), false},
73+
74+
{"", net.ParseIP("::1"), false},
75+
{"", net.ParseIP("fd00::1"), true},
76+
{"", net.ParseIP("1000::1"), false},
77+
78+
{"mydomain.com", net.IPv4zero, false},
79+
}
80+
for _, c := range cases {
81+
assert.Equalf(t, c.expected, hl.MatchesHostOrIP(c.host, c.ip), "case %s(%v)", c.host, c.ip)
82+
}
83+
84+
hl = ParseHostMatchList("external")
85+
cases = []tc{
86+
{"", net.IPv4zero, false},
87+
{"", net.ParseIP("127.0.0.1"), false},
88+
{"", net.ParseIP("10.0.1.1"), false},
89+
{"", net.ParseIP("192.168.1.1"), false},
90+
{"", net.ParseIP("8.8.8.8"), true},
91+
92+
{"", net.ParseIP("::1"), false},
93+
{"", net.ParseIP("fd00::1"), false},
94+
{"", net.ParseIP("1000::1"), true},
95+
96+
{"mydomain.com", net.IPv4zero, false},
97+
}
98+
for _, c := range cases {
99+
assert.Equalf(t, c.expected, hl.MatchesHostOrIP(c.host, c.ip), "case %s(%v)", c.host, c.ip)
100+
}
101+
102+
hl = ParseHostMatchList("*")
103+
cases = []tc{
104+
{"", net.IPv4zero, true},
105+
{"", net.ParseIP("127.0.0.1"), true},
106+
{"", net.ParseIP("10.0.1.1"), true},
107+
{"", net.ParseIP("192.168.1.1"), true},
108+
{"", net.ParseIP("8.8.8.8"), true},
109+
110+
{"", net.ParseIP("::1"), true},
111+
{"", net.ParseIP("fd00::1"), true},
112+
{"", net.ParseIP("1000::1"), true},
113+
114+
{"mydomain.com", net.IPv4zero, true},
115+
}
116+
for _, c := range cases {
117+
assert.Equalf(t, c.expected, hl.MatchesHostOrIP(c.host, c.ip), "case %s(%v)", c.host, c.ip)
118+
}
119+
}

modules/migrations/migrate.go

Lines changed: 1 addition & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -89,7 +89,7 @@ func IsMigrateURLAllowed(remoteURL string, doer *models.User) error {
8989
return &models.ErrInvalidCloneAddr{Host: u.Host, NotResolvedIP: true}
9090
}
9191
for _, addr := range addrList {
92-
if isIPPrivate(addr) || !addr.IsGlobalUnicast() {
92+
if util.IsIPPrivate(addr) || !addr.IsGlobalUnicast() {
9393
return &models.ErrInvalidCloneAddr{Host: u.Host, PrivateNet: addr.String(), IsPermissionDenied: true}
9494
}
9595
}
@@ -474,13 +474,3 @@ func Init() error {
474474

475475
return nil
476476
}
477-
478-
// TODO: replace with `ip.IsPrivate()` if min go version is bumped to 1.17
479-
func isIPPrivate(ip net.IP) bool {
480-
if ip4 := ip.To4(); ip4 != nil {
481-
return ip4[0] == 10 ||
482-
(ip4[0] == 172 && ip4[1]&0xf0 == 16) ||
483-
(ip4[0] == 192 && ip4[1] == 168)
484-
}
485-
return len(ip) == net.IPv6len && ip[0]&0xfe == 0xfc
486-
}

modules/setting/webhook.go

Lines changed: 11 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -7,20 +7,22 @@ package setting
77
import (
88
"net/url"
99

10+
"code.gitea.io/gitea/modules/hostmatcher"
1011
"code.gitea.io/gitea/modules/log"
1112
)
1213

1314
var (
1415
// Webhook settings
1516
Webhook = struct {
16-
QueueLength int
17-
DeliverTimeout int
18-
SkipTLSVerify bool
19-
Types []string
20-
PagingNum int
21-
ProxyURL string
22-
ProxyURLFixed *url.URL
23-
ProxyHosts []string
17+
QueueLength int
18+
DeliverTimeout int
19+
SkipTLSVerify bool
20+
AllowedHostList *hostmatcher.HostMatchList
21+
Types []string
22+
PagingNum int
23+
ProxyURL string
24+
ProxyURLFixed *url.URL
25+
ProxyHosts []string
2426
}{
2527
QueueLength: 1000,
2628
DeliverTimeout: 5,
@@ -36,6 +38,7 @@ func newWebhookService() {
3638
Webhook.QueueLength = sec.Key("QUEUE_LENGTH").MustInt(1000)
3739
Webhook.DeliverTimeout = sec.Key("DELIVER_TIMEOUT").MustInt(5)
3840
Webhook.SkipTLSVerify = sec.Key("SKIP_TLS_VERIFY").MustBool()
41+
Webhook.AllowedHostList = hostmatcher.ParseHostMatchList(sec.Key("ALLOWED_HOST_LIST").MustString(hostmatcher.MatchBuiltinExternal))
3942
Webhook.Types = []string{"gitea", "gogs", "slack", "discord", "dingtalk", "telegram", "msteams", "feishu", "matrix", "wechatwork"}
4043
Webhook.PagingNum = sec.Key("PAGING_NUM").MustInt(10)
4144
Webhook.ProxyURL = sec.Key("PROXY_URL").MustString("")

modules/util/net.go

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
// Copyright 2021 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 util
6+
7+
import (
8+
"net"
9+
)
10+
11+
// IsIPPrivate for net.IP.IsPrivate. TODO: replace with `ip.IsPrivate()` if min go version is bumped to 1.17
12+
func IsIPPrivate(ip net.IP) bool {
13+
if ip4 := ip.To4(); ip4 != nil {
14+
return ip4[0] == 10 ||
15+
(ip4[0] == 172 && ip4[1]&0xf0 == 16) ||
16+
(ip4[0] == 192 && ip4[1] == 168)
17+
}
18+
return len(ip) == net.IPv6len && ip[0]&0xfe == 0xfc
19+
}

services/webhook/deliver.go

Lines changed: 22 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import (
1919
"strconv"
2020
"strings"
2121
"sync"
22+
"syscall"
2223
"time"
2324

2425
"code.gitea.io/gitea/models"
@@ -29,6 +30,8 @@ import (
2930
"github.com/gobwas/glob"
3031
)
3132

33+
var contextKeyWebhookRequest interface{} = "contextKeyWebhookRequest"
34+
3235
// Deliver deliver hook task
3336
func Deliver(t *models.HookTask) error {
3437
w, err := models.GetWebhookByID(t.HookID)
@@ -171,7 +174,7 @@ func Deliver(t *models.HookTask) error {
171174
return fmt.Errorf("Webhook task skipped (webhooks disabled): [%d]", t.ID)
172175
}
173176

174-
resp, err := webhookHTTPClient.Do(req)
177+
resp, err := webhookHTTPClient.Do(req.WithContext(context.WithValue(req.Context(), contextKeyWebhookRequest, req)))
175178
if err != nil {
176179
t.ResponseInfo.Body = fmt.Sprintf("Delivery: %v", err)
177180
return err
@@ -293,14 +296,29 @@ func InitDeliverHooks() {
293296
timeout := time.Duration(setting.Webhook.DeliverTimeout) * time.Second
294297

295298
webhookHTTPClient = &http.Client{
299+
Timeout: timeout,
296300
Transport: &http.Transport{
297301
TLSClientConfig: &tls.Config{InsecureSkipVerify: setting.Webhook.SkipTLSVerify},
298302
Proxy: webhookProxy(),
299-
Dial: func(netw, addr string) (net.Conn, error) {
300-
return net.DialTimeout(netw, addr, timeout) // dial timeout
303+
DialContext: func(ctx context.Context, network, addrOrHost string) (net.Conn, error) {
304+
dialer := net.Dialer{
305+
Timeout: timeout,
306+
Control: func(network, ipAddr string, c syscall.RawConn) error {
307+
// in Control func, the addr was already resolved to IP:PORT format, there is no cost to do ResolveTCPAddr here
308+
tcpAddr, err := net.ResolveTCPAddr(network, ipAddr)
309+
req := ctx.Value(contextKeyWebhookRequest).(*http.Request)
310+
if err != nil {
311+
return fmt.Errorf("webhook can only call HTTP servers via TCP, deny '%s(%s:%s)', err=%v", req.Host, network, ipAddr, err)
312+
}
313+
if !setting.Webhook.AllowedHostList.MatchesHostOrIP(req.Host, tcpAddr.IP) {
314+
return fmt.Errorf("webhook can only call allowed HTTP servers (check your webhook.ALLOWED_HOST_LIST setting), deny '%s(%s)'", req.Host, ipAddr)
315+
}
316+
return nil
317+
},
318+
}
319+
return dialer.DialContext(ctx, network, addrOrHost)
301320
},
302321
},
303-
Timeout: timeout, // request timeout
304322
}
305323

306324
go graceful.GetManager().RunWithShutdownContext(DeliverHooks)

0 commit comments

Comments
 (0)