Support allowed hosts for webhook to work with proxy (#27655)
When `webhook.PROXY_URL` has been set, the old code will check if the proxy host is in `ALLOWED_HOST_LIST` or reject requests through the proxy. It requires users to add the proxy host to `ALLOWED_HOST_LIST`. However, it actually allows all requests to any port on the host, when the proxy host is probably an internal address. But things may be even worse. `ALLOWED_HOST_LIST` doesn't really work when requests are sent to the allowed proxy, and the proxy could forward them to any hosts. This PR fixes it by: - If the proxy has been set, always allow connectioins to the host and port. - Check `ALLOWED_HOST_LIST` before forwarding.
This commit is contained in:
parent
8abc1aae4a
commit
4e98224a45
3 changed files with 72 additions and 20 deletions
|
@ -7,12 +7,17 @@ import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net"
|
"net"
|
||||||
|
"net/url"
|
||||||
"syscall"
|
"syscall"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
// NewDialContext returns a DialContext for Transport, the DialContext will do allow/block list check
|
// NewDialContext returns a DialContext for Transport, the DialContext will do allow/block list check
|
||||||
func NewDialContext(usage string, allowList, blockList *HostMatchList) func(ctx context.Context, network, addr string) (net.Conn, error) {
|
func NewDialContext(usage string, allowList, blockList *HostMatchList) func(ctx context.Context, network, addr string) (net.Conn, error) {
|
||||||
|
return NewDialContextWithProxy(usage, allowList, blockList, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewDialContextWithProxy(usage string, allowList, blockList *HostMatchList, proxy *url.URL) func(ctx context.Context, network, addr string) (net.Conn, error) {
|
||||||
// How Go HTTP Client works with redirection:
|
// How Go HTTP Client works with redirection:
|
||||||
// transport.RoundTrip URL=http://domain.com, Host=domain.com
|
// transport.RoundTrip URL=http://domain.com, Host=domain.com
|
||||||
// transport.DialContext addrOrHost=domain.com:80
|
// transport.DialContext addrOrHost=domain.com:80
|
||||||
|
@ -26,11 +31,18 @@ func NewDialContext(usage string, allowList, blockList *HostMatchList) func(ctx
|
||||||
Timeout: 30 * time.Second,
|
Timeout: 30 * time.Second,
|
||||||
KeepAlive: 30 * time.Second,
|
KeepAlive: 30 * time.Second,
|
||||||
|
|
||||||
Control: func(network, ipAddr string, c syscall.RawConn) (err error) {
|
Control: func(network, ipAddr string, c syscall.RawConn) error {
|
||||||
var host string
|
host, port, err := net.SplitHostPort(addrOrHost)
|
||||||
if host, _, err = net.SplitHostPort(addrOrHost); err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
if proxy != nil {
|
||||||
|
// Always allow the host of the proxy, but only on the specified port.
|
||||||
|
if host == proxy.Hostname() && port == proxy.Port() {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// in Control func, the addr was already resolved to IP:PORT format, there is no cost to do ResolveTCPAddr here
|
// 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)
|
tcpAddr, err := net.ResolveTCPAddr(network, ipAddr)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
|
@ -239,7 +239,7 @@ var (
|
||||||
hostMatchers []glob.Glob
|
hostMatchers []glob.Glob
|
||||||
)
|
)
|
||||||
|
|
||||||
func webhookProxy() func(req *http.Request) (*url.URL, error) {
|
func webhookProxy(allowList *hostmatcher.HostMatchList) func(req *http.Request) (*url.URL, error) {
|
||||||
if setting.Webhook.ProxyURL == "" {
|
if setting.Webhook.ProxyURL == "" {
|
||||||
return proxy.Proxy()
|
return proxy.Proxy()
|
||||||
}
|
}
|
||||||
|
@ -257,6 +257,9 @@ func webhookProxy() func(req *http.Request) (*url.URL, error) {
|
||||||
return func(req *http.Request) (*url.URL, error) {
|
return func(req *http.Request) (*url.URL, error) {
|
||||||
for _, v := range hostMatchers {
|
for _, v := range hostMatchers {
|
||||||
if v.Match(req.URL.Host) {
|
if v.Match(req.URL.Host) {
|
||||||
|
if !allowList.MatchHostName(req.URL.Host) {
|
||||||
|
return nil, fmt.Errorf("webhook can only call allowed HTTP servers (check your %s setting), deny '%s'", allowList.SettingKeyHint, req.URL.Host)
|
||||||
|
}
|
||||||
return http.ProxyURL(setting.Webhook.ProxyURLFixed)(req)
|
return http.ProxyURL(setting.Webhook.ProxyURLFixed)(req)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -278,8 +281,8 @@ func Init() error {
|
||||||
Timeout: timeout,
|
Timeout: timeout,
|
||||||
Transport: &http.Transport{
|
Transport: &http.Transport{
|
||||||
TLSClientConfig: &tls.Config{InsecureSkipVerify: setting.Webhook.SkipTLSVerify},
|
TLSClientConfig: &tls.Config{InsecureSkipVerify: setting.Webhook.SkipTLSVerify},
|
||||||
Proxy: webhookProxy(),
|
Proxy: webhookProxy(allowedHostMatcher),
|
||||||
DialContext: hostmatcher.NewDialContext("webhook", allowedHostMatcher, nil),
|
DialContext: hostmatcher.NewDialContextWithProxy("webhook", allowedHostMatcher, nil, setting.Webhook.ProxyURLFixed),
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -14,35 +14,72 @@ import (
|
||||||
"code.gitea.io/gitea/models/db"
|
"code.gitea.io/gitea/models/db"
|
||||||
"code.gitea.io/gitea/models/unittest"
|
"code.gitea.io/gitea/models/unittest"
|
||||||
webhook_model "code.gitea.io/gitea/models/webhook"
|
webhook_model "code.gitea.io/gitea/models/webhook"
|
||||||
|
"code.gitea.io/gitea/modules/hostmatcher"
|
||||||
"code.gitea.io/gitea/modules/setting"
|
"code.gitea.io/gitea/modules/setting"
|
||||||
api "code.gitea.io/gitea/modules/structs"
|
api "code.gitea.io/gitea/modules/structs"
|
||||||
webhook_module "code.gitea.io/gitea/modules/webhook"
|
webhook_module "code.gitea.io/gitea/modules/webhook"
|
||||||
|
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestWebhookProxy(t *testing.T) {
|
func TestWebhookProxy(t *testing.T) {
|
||||||
|
oldWebhook := setting.Webhook
|
||||||
|
t.Cleanup(func() {
|
||||||
|
setting.Webhook = oldWebhook
|
||||||
|
})
|
||||||
|
|
||||||
setting.Webhook.ProxyURL = "http://localhost:8080"
|
setting.Webhook.ProxyURL = "http://localhost:8080"
|
||||||
setting.Webhook.ProxyURLFixed, _ = url.Parse(setting.Webhook.ProxyURL)
|
setting.Webhook.ProxyURLFixed, _ = url.Parse(setting.Webhook.ProxyURL)
|
||||||
setting.Webhook.ProxyHosts = []string{"*.discordapp.com", "discordapp.com"}
|
setting.Webhook.ProxyHosts = []string{"*.discordapp.com", "discordapp.com"}
|
||||||
|
|
||||||
kases := map[string]string{
|
allowedHostMatcher := hostmatcher.ParseHostMatchList("webhook.ALLOWED_HOST_LIST", "discordapp.com,s.discordapp.com")
|
||||||
"https://discordapp.com/api/webhooks/xxxxxxxxx/xxxxxxxxxxxxxxxxxxx": "http://localhost:8080",
|
|
||||||
"http://s.discordapp.com/assets/xxxxxx": "http://localhost:8080",
|
tests := []struct {
|
||||||
"http://github.com/a/b": "",
|
req string
|
||||||
|
want string
|
||||||
|
wantErr bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
req: "https://discordapp.com/api/webhooks/xxxxxxxxx/xxxxxxxxxxxxxxxxxxx",
|
||||||
|
want: "http://localhost:8080",
|
||||||
|
wantErr: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
req: "http://s.discordapp.com/assets/xxxxxx",
|
||||||
|
want: "http://localhost:8080",
|
||||||
|
wantErr: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
req: "http://github.com/a/b",
|
||||||
|
want: "",
|
||||||
|
wantErr: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
req: "http://www.discordapp.com/assets/xxxxxx",
|
||||||
|
want: "",
|
||||||
|
wantErr: true,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.req, func(t *testing.T) {
|
||||||
|
req, err := http.NewRequest("POST", tt.req, nil)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
u, err := webhookProxy(allowedHostMatcher)(req)
|
||||||
|
if tt.wantErr {
|
||||||
|
assert.Error(t, err)
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
for reqURL, proxyURL := range kases {
|
|
||||||
req, err := http.NewRequest("POST", reqURL, nil)
|
|
||||||
assert.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
|
|
||||||
u, err := webhookProxy()(req)
|
got := ""
|
||||||
assert.NoError(t, err)
|
if u != nil {
|
||||||
if proxyURL == "" {
|
got = u.String()
|
||||||
assert.Nil(t, u)
|
|
||||||
} else {
|
|
||||||
assert.EqualValues(t, proxyURL, u.String())
|
|
||||||
}
|
}
|
||||||
|
assert.Equal(t, tt.want, got)
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
Loading…
Reference in a new issue