Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Only allow webhook to send requests to allowed hosts #17482

Merged
merged 12 commits into from
Nov 1, 2021
4 changes: 4 additions & 0 deletions cmd/web.go
Original file line number Diff line number Diff line change
Expand Up @@ -194,6 +194,10 @@ func listen(m http.Handler, handleRedirector bool) error {
listenAddr = net.JoinHostPort(listenAddr, setting.HTTPPort)
}
log.Info("Listen: %v://%s%s", setting.Protocol, listenAddr, setting.AppSubURL)
// This can be useful for users, many users do wrong to their config and get strange behaviors behind a reverse-proxy.
// A user may fix the configuration mistake when he sees this log.
// And this is also very helpful to maintainers to provide help to users to resolve their configuration problems.
log.Info("AppURL(ROOT_URL): %s", setting.AppURL)

if setting.LFS.StartServer {
log.Info("LFS server enabled")
Expand Down
6 changes: 6 additions & 0 deletions custom/conf/app.example.ini
Original file line number Diff line number Diff line change
Expand Up @@ -1396,6 +1396,12 @@ PATH =
;; Deliver timeout in seconds
;DELIVER_TIMEOUT = 5
;;
;; Webhook can only call allowed hosts for security reasons. Comma separated list, eg: external, 192.168.1.0/24, *.mydomain.com
;; Built-in: loopback (for localhost), private (for LAN/intranet), external (for public hosts on internet), all (for all hosts)
;; CIDR list: 1.2.3.0/8, 2001:db8::/32
;; Wildcard hosts: *.mydomain.com, 192.168.100.*
; ALLOWED_HOST_LIST = external
;;
;; Allow insecure certification
;SKIP_TLS_VERIFY = false
;;
Expand Down
8 changes: 8 additions & 0 deletions docs/content/doc/advanced/config-cheat-sheet.en-us.md
Original file line number Diff line number Diff line change
Expand Up @@ -581,6 +581,14 @@ Define allowed algorithms and their minimum key length (use -1 to disable a type

- `QUEUE_LENGTH`: **1000**: Hook task queue length. Use caution when editing this value.
- `DELIVER_TIMEOUT`: **5**: Delivery timeout (sec) for shooting webhooks.
- `ALLOWED_HOST_LIST`: **external**: Webhook can only call allowed hosts for security reasons. Comma separated list.
- Built-in networks:
- `loopback`: 127.0.0.0/8 for IPv4 and ::1/128 for IPv6, localhost is included.
- `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.
- `external`: A valid non-private unicast IP, you can access all hosts on public internet.
- `all`: All hosts are allowed.
- CIDR list: `1.2.3.0/8` for IPv4 and `2001:db8::/32` for IPv6
- Wildcard hosts: `*.mydomain.com`, `192.168.100.*`
- `SKIP_TLS_VERIFY`: **false**: Allow insecure certification.
- `PAGING_NUM`: **10**: Number of webhook history events that are shown in one page.
- `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.
Expand Down
12 changes: 1 addition & 11 deletions modules/migrations/migrate.go
Original file line number Diff line number Diff line change
Expand Up @@ -89,7 +89,7 @@ func IsMigrateURLAllowed(remoteURL string, doer *models.User) error {
return &models.ErrInvalidCloneAddr{Host: u.Host, NotResolvedIP: true}
}
for _, addr := range addrList {
if isIPPrivate(addr) || !addr.IsGlobalUnicast() {
if util.IsIPPrivate(addr) || !addr.IsGlobalUnicast() {
return &models.ErrInvalidCloneAddr{Host: u.Host, PrivateNet: addr.String(), IsPermissionDenied: true}
}
}
Expand Down Expand Up @@ -474,13 +474,3 @@ func Init() error {

return nil
}

// TODO: replace with `ip.IsPrivate()` if min go version is bumped to 1.17
func isIPPrivate(ip net.IP) bool {
if ip4 := ip.To4(); ip4 != nil {
return ip4[0] == 10 ||
(ip4[0] == 172 && ip4[1]&0xf0 == 16) ||
(ip4[0] == 192 && ip4[1] == 168)
}
return len(ip) == net.IPv6len && ip[0]&0xfe == 0xfc
}
21 changes: 13 additions & 8 deletions modules/setting/webhook.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,22 +5,26 @@
package setting

import (
"net"
"net/url"

"code.gitea.io/gitea/modules/log"
"code.gitea.io/gitea/modules/util"
)

var (
// Webhook settings
Webhook = struct {
QueueLength int
DeliverTimeout int
SkipTLSVerify bool
Types []string
PagingNum int
ProxyURL string
ProxyURLFixed *url.URL
ProxyHosts []string
QueueLength int
DeliverTimeout int
SkipTLSVerify bool
AllowedHostList []string
AllowedHostIPNets []*net.IPNet
Types []string
PagingNum int
ProxyURL string
ProxyURLFixed *url.URL
ProxyHosts []string
}{
QueueLength: 1000,
DeliverTimeout: 5,
Expand All @@ -36,6 +40,7 @@ func newWebhookService() {
Webhook.QueueLength = sec.Key("QUEUE_LENGTH").MustInt(1000)
Webhook.DeliverTimeout = sec.Key("DELIVER_TIMEOUT").MustInt(5)
Webhook.SkipTLSVerify = sec.Key("SKIP_TLS_VERIFY").MustBool()
Webhook.AllowedHostList, Webhook.AllowedHostIPNets = util.ParseHostMatchList(sec.Key("ALLOWED_HOST_LIST").MustString(util.HostListBuiltinExternal))
Webhook.Types = []string{"gitea", "gogs", "slack", "discord", "dingtalk", "telegram", "msteams", "feishu", "matrix", "wechatwork"}
Webhook.PagingNum = sec.Key("PAGING_NUM").MustInt(10)
Webhook.ProxyURL = sec.Key("PROXY_URL").MustString("")
Expand Down
93 changes: 93 additions & 0 deletions modules/util/net.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
// Copyright 2021 The Gitea Authors. All rights reserved.
wxiaoguang marked this conversation as resolved.
Show resolved Hide resolved
// Use of this source code is governed by a MIT-style
// license that can be found in the LICENSE file.

package util

import (
"net"
"path/filepath"
"strings"
)

// HostListBuiltinAll all hosts are matched
const HostListBuiltinAll = "all"

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

// HostListBuiltinPrivate 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.
const HostListBuiltinPrivate = "private"

// HostListBuiltinLoopback 127.0.0.0/8 for IPv4 and ::1/128 for IPv6, localhost is included.
const HostListBuiltinLoopback = "loopback"

// IsIPPrivate for net.IP.IsPrivate. TODO: replace with `ip.IsPrivate()` if min go version is bumped to 1.17
func IsIPPrivate(ip net.IP) bool {
if ip4 := ip.To4(); ip4 != nil {
return ip4[0] == 10 ||
(ip4[0] == 172 && ip4[1]&0xf0 == 16) ||
(ip4[0] == 192 && ip4[1] == 168)
}
return len(ip) == net.IPv6len && ip[0]&0xfe == 0xfc
}

// ParseHostMatchList parses the host list for HostOrIPMatchesList
func ParseHostMatchList(hostListStr string) (hostList []string, ipNets []*net.IPNet) {
for _, s := range strings.Split(hostListStr, ",") {
s = strings.TrimSpace(s)
if s == "" {
continue
}
_, ipNet, err := net.ParseCIDR(s)
if err == nil {
ipNets = append(ipNets, ipNet)
} else {
hostList = append(hostList, s)
}
}
return
}

// HostOrIPMatchesList checks if the host or IP matches an allow/deny(block) list
func HostOrIPMatchesList(host string, ip net.IP, hostList []string, ipNets []*net.IPNet) bool {
var matched bool
ipStr := ip.String()
loop:
for _, hostInList := range hostList {
switch hostInList {
case "":
continue
case HostListBuiltinAll:
matched = true
break loop
case HostListBuiltinExternal:
if matched = ip.IsGlobalUnicast() && !IsIPPrivate(ip); matched {
break loop
}
case HostListBuiltinPrivate:
if matched = IsIPPrivate(ip); matched {
break loop
}
case HostListBuiltinLoopback:
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 !matched {
for _, ipNet := range ipNets {
if matched = ipNet.Contains(ip); matched {
break
}
}
}
return matched
}
119 changes: 119 additions & 0 deletions modules/util/net_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
// Copyright 2021 The Gitea Authors. All rights reserved.
// Use of this source code is governed by a MIT-style
// license that can be found in the LICENSE file.

package util

import (
"net"
"testing"

"github.com/stretchr/testify/assert"
)

func TestHostOrIPMatchesList(t *testing.T) {
type tc struct {
host string
ip net.IP
expected bool
}

// for IPv6: "::1" is loopback, "fd00::/8" is private

ah, an := ParseHostMatchList("private, external, *.mydomain.com, 169.254.1.0/24")
cases := []tc{
{"", net.IPv4zero, false},
{"", net.IPv6zero, false},

{"", net.ParseIP("127.0.0.1"), false},
{"", net.ParseIP("::1"), false},

{"", net.ParseIP("10.0.1.1"), true},
{"", net.ParseIP("192.168.1.1"), true},
{"", net.ParseIP("fd00::1"), true},

{"", net.ParseIP("8.8.8.8"), true},
{"", net.ParseIP("1001::1"), true},

{"mydomain.com", net.IPv4zero, false},
{"sub.mydomain.com", net.IPv4zero, true},

{"", net.ParseIP("169.254.1.1"), true},
{"", net.ParseIP("169.254.2.2"), false},
}
for _, c := range cases {
assert.Equalf(t, c.expected, HostOrIPMatchesList(c.host, c.ip, ah, an), "case %s(%v)", c.host, c.ip)
}

ah, an = ParseHostMatchList("loopback")
cases = []tc{
{"", net.IPv4zero, false},
{"", net.ParseIP("127.0.0.1"), true},
{"", net.ParseIP("10.0.1.1"), false},
{"", net.ParseIP("192.168.1.1"), false},
{"", net.ParseIP("8.8.8.8"), false},

{"", net.ParseIP("::1"), true},
{"", net.ParseIP("fd00::1"), false},
{"", net.ParseIP("1000::1"), false},

{"mydomain.com", net.IPv4zero, false},
}
for _, c := range cases {
assert.Equalf(t, c.expected, HostOrIPMatchesList(c.host, c.ip, ah, an), "case %s(%v)", c.host, c.ip)
}

ah, an = ParseHostMatchList("private")
cases = []tc{
{"", net.IPv4zero, false},
{"", net.ParseIP("127.0.0.1"), false},
{"", net.ParseIP("10.0.1.1"), true},
{"", net.ParseIP("192.168.1.1"), true},
{"", net.ParseIP("8.8.8.8"), false},

{"", net.ParseIP("::1"), false},
{"", net.ParseIP("fd00::1"), true},
{"", net.ParseIP("1000::1"), false},

{"mydomain.com", net.IPv4zero, false},
}
for _, c := range cases {
assert.Equalf(t, c.expected, HostOrIPMatchesList(c.host, c.ip, ah, an), "case %s(%v)", c.host, c.ip)
}

ah, an = ParseHostMatchList("external")
cases = []tc{
{"", net.IPv4zero, false},
{"", net.ParseIP("127.0.0.1"), false},
{"", net.ParseIP("10.0.1.1"), false},
{"", net.ParseIP("192.168.1.1"), false},
{"", net.ParseIP("8.8.8.8"), true},

{"", net.ParseIP("::1"), false},
{"", net.ParseIP("fd00::1"), false},
{"", net.ParseIP("1000::1"), true},

{"mydomain.com", net.IPv4zero, false},
}
for _, c := range cases {
assert.Equalf(t, c.expected, HostOrIPMatchesList(c.host, c.ip, ah, an), "case %s(%v)", c.host, c.ip)
}

ah, an = ParseHostMatchList("all")
cases = []tc{
{"", net.IPv4zero, true},
{"", net.ParseIP("127.0.0.1"), true},
{"", net.ParseIP("10.0.1.1"), true},
{"", net.ParseIP("192.168.1.1"), true},
{"", net.ParseIP("8.8.8.8"), true},

{"", net.ParseIP("::1"), true},
{"", net.ParseIP("fd00::1"), true},
{"", net.ParseIP("1000::1"), true},

{"mydomain.com", net.IPv4zero, true},
}
for _, c := range cases {
assert.Equalf(t, c.expected, HostOrIPMatchesList(c.host, c.ip, ah, an), "case %s(%v)", c.host, c.ip)
}
}
28 changes: 24 additions & 4 deletions services/webhook/deliver.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,16 +19,21 @@ import (
"strconv"
"strings"
"sync"
"syscall"
"time"

"code.gitea.io/gitea/models"
"code.gitea.io/gitea/modules/graceful"
"code.gitea.io/gitea/modules/log"
"code.gitea.io/gitea/modules/proxy"
"code.gitea.io/gitea/modules/setting"
"code.gitea.io/gitea/modules/util"

"github.com/gobwas/glob"
)

var contextKeyWebhookRequest interface{} = "contextKeyWebhookRequest"

// Deliver deliver hook task
func Deliver(t *models.HookTask) error {
w, err := models.GetWebhookByID(t.HookID)
Expand Down Expand Up @@ -171,7 +176,7 @@ func Deliver(t *models.HookTask) error {
return fmt.Errorf("Webhook task skipped (webhooks disabled): [%d]", t.ID)
}

resp, err := webhookHTTPClient.Do(req)
resp, err := webhookHTTPClient.Do(req.WithContext(context.WithValue(req.Context(), contextKeyWebhookRequest, req)))
if err != nil {
t.ResponseInfo.Body = fmt.Sprintf("Delivery: %v", err)
return err
Expand Down Expand Up @@ -293,14 +298,29 @@ func InitDeliverHooks() {
timeout := time.Duration(setting.Webhook.DeliverTimeout) * time.Second

webhookHTTPClient = &http.Client{
Timeout: timeout,
Transport: &http.Transport{
TLSClientConfig: &tls.Config{InsecureSkipVerify: setting.Webhook.SkipTLSVerify},
Proxy: webhookProxy(),
Dial: func(netw, addr string) (net.Conn, error) {
return net.DialTimeout(netw, addr, timeout) // dial timeout
DialContext: func(ctx context.Context, network, addrOrHost string) (net.Conn, error) {
dialer := net.Dialer{
Timeout: timeout,
Control: func(network, ipAddr string, c syscall.RawConn) error {
// in Control func, the addr was already resolved to IP:PORT format, there is no cost to do ResolveTCPAddr here
tcpAddr, err := net.ResolveTCPAddr(network, ipAddr)
req := ctx.Value(contextKeyWebhookRequest).(*http.Request)
if err != nil {
return fmt.Errorf("webhook can only call HTTP servers via TCP, deny '%s(%s:%s)', err=%v", req.Host, network, ipAddr, err)
}
if !util.HostOrIPMatchesList(req.Host, tcpAddr.IP, setting.Webhook.AllowedHostList, setting.Webhook.AllowedHostIPNets) {
return fmt.Errorf("webhook can only call allowed HTTP servers (check your webhook.ALLOWED_HOST_LIST setting), deny '%s(%s)'", req.Host, ipAddr)
}
return nil
},
}
return dialer.DialContext(ctx, network, addrOrHost)
},
},
Timeout: timeout, // request timeout
}

go graceful.GetManager().RunWithShutdownContext(DeliverHooks)
Expand Down