mirror of
				https://gitee.com/gitea/gitea
				synced 2025-11-04 16:40:24 +08:00 
			
		
		
		
	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:
		@@ -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)
 | 
				
			||||||
 | 
							})
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 
 | 
				
			|||||||
		Reference in New Issue
	
	Block a user