Move HTTP service to public for plugin use (#27284)

* Move httpservice for use in plugins

* Adapt httpservice for plugin use

* Fix lint
Этот коммит содержится в:
Christopher Speller
2024-06-05 09:58:04 -07:00
коммит произвёл GitHub
родитель bff2989d95
Коммит 04181247f8
24 изменённых файлов: 42 добавлений и 24 удалений

160
server/public/shared/httpservice/client.go Обычный файл
Просмотреть файл

@@ -0,0 +1,160 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package httpservice
import (
"context"
"crypto/tls"
"errors"
"net"
"net/http"
"time"
)
const (
ConnectTimeout = 3 * time.Second
RequestTimeout = 30 * time.Second
)
var reservedIPRanges []*net.IPNet
// IsReservedIP checks whether the target IP belongs to reserved IP address ranges to avoid SSRF attacks to the internal
// network of the Mattermost server
func IsReservedIP(ip net.IP) bool {
for _, ipRange := range reservedIPRanges {
if ipRange.Contains(ip) {
return true
}
}
return false
}
// IsOwnIP handles the special case that a request might be made to the public IP of the host which on Linux is routed
// directly via the loopback IP to any listening sockets, effectively bypassing host-based firewalls such as firewalld
func IsOwnIP(ip net.IP) (bool, error) {
interfaces, err := net.Interfaces()
if err != nil {
return false, err
}
for _, interf := range interfaces {
addresses, err := interf.Addrs()
if err != nil {
return false, err
}
for _, addr := range addresses {
var selfIP net.IP
switch v := addr.(type) {
case *net.IPNet:
selfIP = v.IP
case *net.IPAddr:
selfIP = v.IP
}
if ip.Equal(selfIP) {
return true, nil
}
}
}
return false, nil
}
var defaultUserAgent string
func init() {
for _, cidr := range []string{
// See https://tools.ietf.org/html/rfc6890
"0.0.0.0/8", // This host on this network
"10.0.0.0/8", // Private-Use
"127.0.0.0/8", // Loopback
"169.254.0.0/16", // Link Local
"172.16.0.0/12", // Private-Use Networks
"192.168.0.0/16", // Private-Use Networks
"::/128", // Unspecified Address
"::1/128", // Loopback Address
"fc00::/7", // Unique-Local
"fe80::/10", // Linked-Scoped Unicast
} {
_, parsed, err := net.ParseCIDR(cidr)
if err != nil {
panic(err)
}
reservedIPRanges = append(reservedIPRanges, parsed)
}
defaultUserAgent = "Mattermost-Bot/1.1"
}
type DialContextFunction func(ctx context.Context, network, addr string) (net.Conn, error)
var ErrAddressForbidden = errors.New("address forbidden, you may need to set AllowedUntrustedInternalConnections to allow an integration access to your internal network")
func dialContextFilter(dial DialContextFunction, allowHost func(host string) bool, allowIP func(ip net.IP) bool) DialContextFunction {
return func(ctx context.Context, network, addr string) (net.Conn, error) {
host, port, err := net.SplitHostPort(addr)
if err != nil {
return nil, err
}
if allowHost != nil && allowHost(host) {
return dial(ctx, network, addr)
}
ips, err := net.LookupIP(host)
if err != nil {
return nil, err
}
var firstErr error
for _, ip := range ips {
select {
case <-ctx.Done():
return nil, ctx.Err()
default:
}
if allowIP == nil || !allowIP(ip) {
continue
}
conn, err := dial(ctx, network, net.JoinHostPort(ip.String(), port))
if err == nil {
return conn, nil
}
if firstErr == nil {
firstErr = err
}
}
if firstErr == nil {
return nil, ErrAddressForbidden
}
return nil, firstErr
}
}
func NewTransport(enableInsecureConnections bool, allowHost func(host string) bool, allowIP func(ip net.IP) bool) *MattermostTransport {
dialContext := (&net.Dialer{
Timeout: ConnectTimeout,
KeepAlive: 30 * time.Second,
}).DialContext
if allowHost != nil || allowIP != nil {
dialContext = dialContextFilter(dialContext, allowHost, allowIP)
}
return &MattermostTransport{
&http.Transport{
Proxy: http.ProxyFromEnvironment,
DialContext: dialContext,
MaxIdleConns: 100,
IdleConnTimeout: 90 * time.Second,
TLSHandshakeTimeout: ConnectTimeout,
ExpectContinueTimeout: 1 * time.Second,
TLSClientConfig: &tls.Config{
InsecureSkipVerify: enableInsecureConnections,
},
},
}
}

Просмотреть файл

@@ -0,0 +1,263 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package httpservice
import (
"context"
"fmt"
"io"
"net"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestHTTPClient(t *testing.T) {
mockHTTP := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
}))
defer mockHTTP.Close()
mockSelfSignedHTTPS := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
}))
defer mockSelfSignedHTTPS.Close()
t.Run("insecure connections", func(t *testing.T) {
disableInsecureConnections := false
enableInsecureConnections := true
testCases := []struct {
description string
enableInsecureConnections bool
url string
expectedAllowed bool
}{
{"allow HTTP even when insecure disabled", disableInsecureConnections, mockHTTP.URL, true},
{"allow HTTP when insecure enabled", enableInsecureConnections, mockHTTP.URL, true},
{"reject self-signed HTTPS even when insecure disabled", disableInsecureConnections, mockSelfSignedHTTPS.URL, false},
{"allow self-signed HTTPS when insecure enabled", enableInsecureConnections, mockSelfSignedHTTPS.URL, true},
}
for _, testCase := range testCases {
t.Run(testCase.description, func(t *testing.T) {
c := NewHTTPClient(NewTransport(testCase.enableInsecureConnections, nil, nil))
if _, err := c.Get(testCase.url); testCase.expectedAllowed {
require.NoError(t, err)
} else {
require.Error(t, err)
}
})
}
})
t.Run("checks", func(t *testing.T) {
allowHost := func(_ string) bool { return true }
rejectHost := func(_ string) bool { return false }
allowIP := func(_ net.IP) bool { return true }
rejectIP := func(_ net.IP) bool { return false }
testCases := []struct {
description string
allowHost func(string) bool
allowIP func(net.IP) bool
expectedAllowed bool
}{
{"allow with no checks", nil, nil, true},
{"reject without host check when ip rejected", nil, rejectIP, false},
{"allow without host check when ip allowed", nil, allowIP, true},
{"reject when host rejected since no ip check", rejectHost, nil, false},
{"reject when host and ip rejected", rejectHost, rejectIP, false},
{"allow when host rejected since ip allowed", rejectHost, allowIP, true},
{"allow when host allowed even without ip check", allowHost, nil, true},
{"allow when host allowed even if ip rejected", allowHost, rejectIP, true},
{"allow when host and ip allowed", allowHost, allowIP, true},
}
for _, testCase := range testCases {
t.Run(testCase.description, func(t *testing.T) {
c := NewHTTPClient(NewTransport(false, testCase.allowHost, testCase.allowIP))
if _, err := c.Get(mockHTTP.URL); testCase.expectedAllowed {
require.NoError(t, err)
} else {
require.IsType(t, &url.Error{}, err)
require.Equal(t, ErrAddressForbidden, err.(*url.Error).Err)
}
})
}
})
}
func TestHTTPClientWithProxy(t *testing.T) {
proxy := createProxyServer()
defer proxy.Close()
c := NewHTTPClient(NewTransport(true, nil, nil))
purl, _ := url.Parse(proxy.URL)
c.Transport.(*MattermostTransport).Transport.(*http.Transport).Proxy = http.ProxyURL(purl)
resp, err := c.Get("http://acme.com")
require.NoError(t, err)
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
require.NoError(t, err)
require.Equal(t, "proxy", string(body))
}
func createProxyServer() *httptest.Server {
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(200)
w.Header().Set("Content-Type", "text/plain; charset=us-ascii")
fmt.Fprint(w, "proxy")
}))
}
func TestDialContextFilter(t *testing.T) {
for _, tc := range []struct {
Addr string
IsValid bool
}{
{
Addr: "google.com:80",
IsValid: true,
},
{
Addr: "8.8.8.8:53",
IsValid: true,
},
{
Addr: "127.0.0.1:80",
},
{
Addr: "10.0.0.1:80",
IsValid: true,
},
} {
didDial := false
filter := dialContextFilter(func(ctx context.Context, network, addr string) (net.Conn, error) {
didDial = true
return nil, nil
}, func(host string) bool { return host == "10.0.0.1" }, func(ip net.IP) bool { return !IsReservedIP(ip) })
_, err := filter(context.Background(), "", tc.Addr)
if tc.IsValid {
require.NoError(t, err)
require.True(t, didDial)
} else {
require.Error(t, err)
require.Equal(t, err, ErrAddressForbidden)
require.False(t, didDial)
}
}
}
func TestUserAgentIsSet(t *testing.T) {
testUserAgent := "test-user-agent"
defaultUserAgent = testUserAgent
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
ua := req.UserAgent()
assert.NotEqual(t, "", ua, "expected user-agent to be non-empty")
assert.Equalf(t, testUserAgent, ua, "expected user-agent to be %q but was %q", testUserAgent, ua)
}))
defer ts.Close()
client := NewHTTPClient(NewTransport(true, nil, nil))
req, err := http.NewRequest("GET", ts.URL, nil)
require.NoError(t, err, "NewRequest failed", err)
_, err = client.Do(req)
require.NoError(t, err, "Do failed", err)
}
func NewHTTPClient(transport http.RoundTripper) *http.Client {
return &http.Client{
Transport: transport,
}
}
func TestIsReservedIP(t *testing.T) {
tests := []struct {
name string
ip net.IP
want bool
}{
{"127.8.3.5", net.IPv4(127, 8, 3, 5), true},
{"192.168.0.1", net.IPv4(192, 168, 0, 1), true},
{"169.254.0.6", net.IPv4(169, 254, 0, 6), true},
{"127.120.6.3", net.IPv4(127, 120, 6, 3), true},
{"8.8.8.8", net.IPv4(8, 8, 8, 8), false},
{"9.9.9.9", net.IPv4(9, 9, 9, 8), false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := IsReservedIP(tt.ip)
assert.Equalf(t, tt.want, got, "IsReservedIP() = %v, want %v", got, tt.want)
})
}
}
func TestIsOwnIP(t *testing.T) {
tests := []struct {
name string
ip net.IP
want bool
}{
{"127.0.0.1", net.IPv4(127, 0, 0, 1), true},
{"8.8.8.8", net.IPv4(8, 0, 0, 8), false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, _ := IsOwnIP(tt.ip)
assert.Equalf(t, tt.want, got, "IsOwnIP() = %v, want %v for IP %s", got, tt.want, tt.ip.String())
})
}
}
func TestSplitHostnames(t *testing.T) {
var config string
var hostnames []string
config = ""
hostnames = strings.FieldsFunc(config, splitFields)
require.Equal(t, []string{}, hostnames)
config = "127.0.0.1 localhost"
hostnames = strings.FieldsFunc(config, splitFields)
require.Equal(t, []string{"127.0.0.1", "localhost"}, hostnames)
config = "127.0.0.1,localhost"
hostnames = strings.FieldsFunc(config, splitFields)
require.Equal(t, []string{"127.0.0.1", "localhost"}, hostnames)
config = "127.0.0.1,,localhost"
hostnames = strings.FieldsFunc(config, splitFields)
require.Equal(t, []string{"127.0.0.1", "localhost"}, hostnames)
config = "127.0.0.1 localhost"
hostnames = strings.FieldsFunc(config, splitFields)
require.Equal(t, []string{"127.0.0.1", "localhost"}, hostnames)
config = "127.0.0.1 , localhost"
hostnames = strings.FieldsFunc(config, splitFields)
require.Equal(t, []string{"127.0.0.1", "localhost"}, hostnames)
config = "127.0.0.1 localhost "
hostnames = strings.FieldsFunc(config, splitFields)
require.Equal(t, []string{"127.0.0.1", "localhost"}, hostnames)
config = " 127.0.0.1 ,,localhost , , ,,"
hostnames = strings.FieldsFunc(config, splitFields)
require.Equal(t, []string{"127.0.0.1", "localhost"}, hostnames)
config = "127.0.0.1 localhost, 192.168.1.0"
hostnames = strings.FieldsFunc(config, splitFields)
require.Equal(t, []string{"127.0.0.1", "localhost", "192.168.1.0"}, hostnames)
}

Просмотреть файл

@@ -0,0 +1,119 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package httpservice
import (
"net"
"net/http"
"strings"
"time"
"unicode"
"github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/public/plugin"
)
// HTTPService wraps the functionality for making http requests to provide some improvements to the default client
// behaviour.
type HTTPService interface {
// MakeClient returns an http client constructed with a RoundTripper as returned by MakeTransport.
MakeClient(trustURLs bool) *http.Client
// MakeTransport returns a RoundTripper that is suitable for making requests to external resources. The default
// implementation provides:
// - A shorter timeout for dial and TLS handshake (defined as constant "ConnectTimeout")
// - A timeout for end-to-end requests
// - A Mattermost-specific user agent header
// - Additional security for untrusted and insecure connections
MakeTransport(trustURLs bool) *MattermostTransport
}
type getConfig interface {
Config() *model.Config
}
type HTTPServiceImpl struct {
configService getConfig
RequestTimeout time.Duration
}
func splitFields(c rune) bool {
return unicode.IsSpace(c) || c == ','
}
func MakeHTTPService(configService getConfig) HTTPService {
return &HTTPServiceImpl{
configService,
RequestTimeout,
}
}
type pluginAPIConfigServiceAdapter struct {
pluginAPIConfigService plugin.API
}
func (p *pluginAPIConfigServiceAdapter) Config() *model.Config {
return p.pluginAPIConfigService.GetConfig()
}
func MakeHTTPServicePlugin(configService plugin.API) HTTPService {
return MakeHTTPService(&pluginAPIConfigServiceAdapter{configService})
}
func (h *HTTPServiceImpl) MakeClient(trustURLs bool) *http.Client {
return &http.Client{
Transport: h.MakeTransport(trustURLs),
Timeout: h.RequestTimeout,
}
}
func (h *HTTPServiceImpl) MakeTransport(trustURLs bool) *MattermostTransport {
insecure := h.configService.Config().ServiceSettings.EnableInsecureOutgoingConnections != nil && *h.configService.Config().ServiceSettings.EnableInsecureOutgoingConnections
if trustURLs {
return NewTransport(insecure, nil, nil)
}
allowHost := func(host string) bool {
if h.configService.Config().ServiceSettings.AllowedUntrustedInternalConnections == nil {
return false
}
for _, allowed := range strings.FieldsFunc(*h.configService.Config().ServiceSettings.AllowedUntrustedInternalConnections, splitFields) {
if host == allowed {
return true
}
}
return false
}
allowIP := func(ip net.IP) bool {
reservedIP := IsReservedIP(ip)
ownIP, err := IsOwnIP(ip)
// If there is an error getting the self-assigned IPs, default to the secure option
if err != nil {
return false
}
// If it's not a reserved IP and it's not self-assigned IP, accept the IP
if !reservedIP && !ownIP {
return true
}
if h.configService.Config().ServiceSettings.AllowedUntrustedInternalConnections == nil {
return false
}
// In the case it's the self-assigned IP, enforce that it needs to be explicitly added to the AllowedUntrustedInternalConnections
for _, allowed := range strings.FieldsFunc(*h.configService.Config().ServiceSettings.AllowedUntrustedInternalConnections, splitFields) {
if _, ipRange, err := net.ParseCIDR(allowed); err == nil && ipRange.Contains(ip) {
return true
}
}
return false
}
return NewTransport(insecure, allowHost, allowIP)
}

Просмотреть файл

@@ -0,0 +1,21 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package httpservice
import (
"net/http"
)
// MattermostTransport is an implementation of http.RoundTripper that ensures each request contains a custom user agent
// string to indicate that the request is coming from a Mattermost instance.
type MattermostTransport struct {
// Transport is the underlying http.RoundTripper that is actually used to make the request
Transport http.RoundTripper
}
func (t *MattermostTransport) RoundTrip(req *http.Request) (*http.Response, error) {
req.Header.Set("User-Agent", defaultUserAgent)
return t.Transport.RoundTrip(req)
}