Skip to content

Commit ca4418e

Browse files
GiteaBotwolfogre
andauthored
Support allowed hosts for webhook to work with proxy (go-gitea#27655) (go-gitea#27674)
Backport go-gitea#27655 by @wolfogre 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. Co-authored-by: Jason Song <[email protected]>
1 parent 80c0c88 commit ca4418e

File tree

3 files changed

+73
-21
lines changed

3 files changed

+73
-21
lines changed

modules/hostmatcher/http.go

+15-3
Original file line numberDiff line numberDiff line change
@@ -7,12 +7,17 @@ import (
77
"context"
88
"fmt"
99
"net"
10+
"net/url"
1011
"syscall"
1112
"time"
1213
)
1314

1415
// NewDialContext returns a DialContext for Transport, the DialContext will do allow/block list check
1516
func NewDialContext(usage string, allowList, blockList *HostMatchList) func(ctx context.Context, network, addr string) (net.Conn, error) {
17+
return NewDialContextWithProxy(usage, allowList, blockList, nil)
18+
}
19+
20+
func NewDialContextWithProxy(usage string, allowList, blockList *HostMatchList, proxy *url.URL) func(ctx context.Context, network, addr string) (net.Conn, error) {
1621
// How Go HTTP Client works with redirection:
1722
// transport.RoundTrip URL=http://domain.com, Host=domain.com
1823
// transport.DialContext addrOrHost=domain.com:80
@@ -26,11 +31,18 @@ func NewDialContext(usage string, allowList, blockList *HostMatchList) func(ctx
2631
Timeout: 30 * time.Second,
2732
KeepAlive: 30 * time.Second,
2833

29-
Control: func(network, ipAddr string, c syscall.RawConn) (err error) {
30-
var host string
31-
if host, _, err = net.SplitHostPort(addrOrHost); err != nil {
34+
Control: func(network, ipAddr string, c syscall.RawConn) error {
35+
host, port, err := net.SplitHostPort(addrOrHost)
36+
if err != nil {
3237
return err
3338
}
39+
if proxy != nil {
40+
// Always allow the host of the proxy, but only on the specified port.
41+
if host == proxy.Hostname() && port == proxy.Port() {
42+
return nil
43+
}
44+
}
45+
3446
// in Control func, the addr was already resolved to IP:PORT format, there is no cost to do ResolveTCPAddr here
3547
tcpAddr, err := net.ResolveTCPAddr(network, ipAddr)
3648
if err != nil {

services/webhook/deliver.go

+6-3
Original file line numberDiff line numberDiff line change
@@ -239,7 +239,7 @@ var (
239239
hostMatchers []glob.Glob
240240
)
241241

242-
func webhookProxy() func(req *http.Request) (*url.URL, error) {
242+
func webhookProxy(allowList *hostmatcher.HostMatchList) func(req *http.Request) (*url.URL, error) {
243243
if setting.Webhook.ProxyURL == "" {
244244
return proxy.Proxy()
245245
}
@@ -257,6 +257,9 @@ func webhookProxy() func(req *http.Request) (*url.URL, error) {
257257
return func(req *http.Request) (*url.URL, error) {
258258
for _, v := range hostMatchers {
259259
if v.Match(req.URL.Host) {
260+
if !allowList.MatchHostName(req.URL.Host) {
261+
return nil, fmt.Errorf("webhook can only call allowed HTTP servers (check your %s setting), deny '%s'", allowList.SettingKeyHint, req.URL.Host)
262+
}
260263
return http.ProxyURL(setting.Webhook.ProxyURLFixed)(req)
261264
}
262265
}
@@ -278,8 +281,8 @@ func Init() error {
278281
Timeout: timeout,
279282
Transport: &http.Transport{
280283
TLSClientConfig: &tls.Config{InsecureSkipVerify: setting.Webhook.SkipTLSVerify},
281-
Proxy: webhookProxy(),
282-
DialContext: hostmatcher.NewDialContext("webhook", allowedHostMatcher, nil),
284+
Proxy: webhookProxy(allowedHostMatcher),
285+
DialContext: hostmatcher.NewDialContextWithProxy("webhook", allowedHostMatcher, nil, setting.Webhook.ProxyURLFixed),
283286
},
284287
}
285288

services/webhook/deliver_test.go

+52-15
Original file line numberDiff line numberDiff line change
@@ -14,35 +14,72 @@ import (
1414
"code.gitea.io/gitea/models/db"
1515
"code.gitea.io/gitea/models/unittest"
1616
webhook_model "code.gitea.io/gitea/models/webhook"
17+
"code.gitea.io/gitea/modules/hostmatcher"
1718
"code.gitea.io/gitea/modules/setting"
1819
api "code.gitea.io/gitea/modules/structs"
1920
webhook_module "code.gitea.io/gitea/modules/webhook"
2021

2122
"github.com/stretchr/testify/assert"
23+
"github.com/stretchr/testify/require"
2224
)
2325

2426
func TestWebhookProxy(t *testing.T) {
27+
oldWebhook := setting.Webhook
28+
t.Cleanup(func() {
29+
setting.Webhook = oldWebhook
30+
})
31+
2532
setting.Webhook.ProxyURL = "http://localhost:8080"
2633
setting.Webhook.ProxyURLFixed, _ = url.Parse(setting.Webhook.ProxyURL)
2734
setting.Webhook.ProxyHosts = []string{"*.discordapp.com", "discordapp.com"}
2835

29-
kases := map[string]string{
30-
"https://discordapp.com/api/webhooks/xxxxxxxxx/xxxxxxxxxxxxxxxxxxx": "http://localhost:8080",
31-
"http://s.discordapp.com/assets/xxxxxx": "http://localhost:8080",
32-
"http://github.com/a/b": "",
36+
allowedHostMatcher := hostmatcher.ParseHostMatchList("webhook.ALLOWED_HOST_LIST", "discordapp.com,s.discordapp.com")
37+
38+
tests := []struct {
39+
req string
40+
want string
41+
wantErr bool
42+
}{
43+
{
44+
req: "https://discordapp.com/api/webhooks/xxxxxxxxx/xxxxxxxxxxxxxxxxxxx",
45+
want: "http://localhost:8080",
46+
wantErr: false,
47+
},
48+
{
49+
req: "http://s.discordapp.com/assets/xxxxxx",
50+
want: "http://localhost:8080",
51+
wantErr: false,
52+
},
53+
{
54+
req: "http://github.com/a/b",
55+
want: "",
56+
wantErr: false,
57+
},
58+
{
59+
req: "http://www.discordapp.com/assets/xxxxxx",
60+
want: "",
61+
wantErr: true,
62+
},
3363
}
64+
for _, tt := range tests {
65+
t.Run(tt.req, func(t *testing.T) {
66+
req, err := http.NewRequest("POST", tt.req, nil)
67+
require.NoError(t, err)
68+
69+
u, err := webhookProxy(allowedHostMatcher)(req)
70+
if tt.wantErr {
71+
assert.Error(t, err)
72+
return
73+
}
74+
75+
assert.NoError(t, err)
3476

35-
for reqURL, proxyURL := range kases {
36-
req, err := http.NewRequest("POST", reqURL, nil)
37-
assert.NoError(t, err)
38-
39-
u, err := webhookProxy()(req)
40-
assert.NoError(t, err)
41-
if proxyURL == "" {
42-
assert.Nil(t, u)
43-
} else {
44-
assert.EqualValues(t, proxyURL, u.String())
45-
}
77+
got := ""
78+
if u != nil {
79+
got = u.String()
80+
}
81+
assert.Equal(t, tt.want, got)
82+
})
4683
}
4784
}
4885

0 commit comments

Comments
 (0)