snapshot of https://github.com/mattermost/mattermost.git (master, 2026-09-16; blobs > 131072B omitted)
Some checks failed
Server CI / Postgres (shard 0) (push) Has been skipped
Server CI / Postgres (shard 3) (push) Has been skipped
BuildEnv Docker Image / build-image (push) Failing after 4s
BuildEnv Docker Image / build-image-fips (push) Failing after 4s
Migration Review & Release Notes / Generate migration review & release notes (push) Failing after 3s
Server CI / Build Environment (push) Failing after 3s
Server CI / Postgres (shard 1) (push) Has been skipped
Tools CI / check-style (mattermost-govet) (push) Failing after 4s
Server CI / Postgres (shard 2) (push) Has been skipped
Tools CI / check-style (mmgotool) (push) Failing after 3s
Server CI / Elasticsearch v8 Compatibility (push) Has been skipped
Server CI / OpenSearch v2 Compatibility (push) Has been skipped
Server CI / Postgres FIPS (shard 0) (push) Has been skipped
Tools CI / Test (mattermost-govet) (push) Failing after 3s
Server CI / Postgres FIPS (shard 1) (push) Has been skipped
Server CI / Postgres FIPS (shard 2) (push) Has been skipped
Tools CI / Test (mmgotool) (push) Failing after 3s
Server CI / Postgres FIPS (shard 3) (push) Has been skipped
Server CI / Run mmctl tests (push) Has been skipped
Server CI / Run mmctl tests (FIPS) (push) Has been skipped
Server CI / Merge Postgres Test Results (push) Has been skipped
Server CI / Merge Postgres FIPS Test Results (push) Has been skipped
Server CI / Check backport migrations (push) Has been skipped
Server CI / Check generated files (push) Has been skipped
Server CI / check-style (push) Has been skipped
Server CI / Vet API (push) Has been skipped
Server CI / Build mattermost server app (push) Has been skipped
Server CI / Server CI Complete (push) Failing after 2s
Server CI / Server CI Artifacts (push) Has been skipped
Server CI / Sentry Upload (push) Has been skipped
Server CI / Server CI Report (push) Failing after 5m1s
Server CI Nightly Race / Race Detector (push) Has been skipped
CodeQL / Analyze (go) (push) Has been cancelled
CodeQL / Analyze (javascript) (push) Has been cancelled
Webapp CI Cache Warm / Warm Webapp node_modules Cache (push) Has been cancelled
E2E CI Cache Warm / Warm E2E npm Registry Cache (push) Has been cancelled
API / build (push) Has been cancelled
Web App CI / check-types (push) Has been cancelled
Web App CI / check-lint (push) Has been cancelled
Web App CI / check-i18n (push) Has been cancelled
Web App CI / check-external-links (push) Has been cancelled
Web App CI / test (platform) (push) Has been cancelled
Web App CI / test (mattermost-redux) (push) Has been cancelled
Web App CI / test (channels shard 1/4) (push) Has been cancelled
Web App CI / test (channels shard 2/4) (push) Has been cancelled
Web App CI / test (channels shard 3/4) (push) Has been cancelled
Web App CI / test (channels shard 4/4) (push) Has been cancelled
Web App CI / upload-coverage (push) Has been cancelled
Web App CI / build (push) Has been cancelled
YAML Lint / yamllint (push) Has been cancelled
Push mirrored docker images / cd/Push mirrored docker images (push) Has been cancelled
docs-cd / Build and deploy docs (push) Has been cancelled
Docs CI / build (push) Has been cancelled
E2E Cache Warm: Cypress deps / Warm Cypress deps cache (push) Has been cancelled
Scorecards supply-chain security / Scorecard analysis (push) Has been cancelled
Server CI Weekly / Postgres FIPS (push) Has been skipped
Server CI Weekly / Run mmctl tests (FIPS) (push) Has been skipped
Server CI Weekly / Postgres with binary parameters (push) Has been skipped
Server CI Weekly / Build Environment (push) Failing after 1s
Server CI Nightly Race / Build Environment (push) Failing after 1s
Server CI Cache Warm / Warm Go Module and Build Cache (push) Failing after 2s

This commit is contained in:
upstream-mirror
2026-09-16 00:29:55 +05:00
commit 240db4e2a7
15309 changed files with 2173406 additions and 0 deletions

View File

@@ -0,0 +1,221 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package httpservice
import (
"context"
"crypto/tls"
"errors"
"fmt"
"net"
"net/http"
"net/netip"
"net/url"
"strings"
"time"
"golang.org/x/net/http/httpproxy"
)
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 {
// Canonicalize IPv4-mapped IPv6 addresses (e.g., ::ffff:127.0.0.1) to their
// native IPv4 form so that IPv4 CIDR ranges match correctly.
if ip4 := ip.To4(); ip4 != nil {
ip = ip4
}
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{
// Strings taken from https://github.com/doyensec/safeurl/blob/main/ip.go
"10.0.0.0/8", /* Private network - RFC 1918 */
"172.16.0.0/12", /* Private network - RFC 1918 */
"192.168.0.0/16", /* Private network - RFC 1918 */
"127.0.0.0/8", /* Loopback - RFC 1122, Section 3.2.1.3 */
"0.0.0.0/8", /* Current network (only valid as source address) - RFC 1122, Section 3.2.1.3 */
"169.254.0.0/16", /* Link-local - RFC 3927 */
"192.0.0.0/24", /* IETF Protocol Assignments - RFC 5736 */
"192.0.2.0/24", /* TEST-NET-1, documentation and examples - RFC 5737 */
"198.51.100.0/24", /* TEST-NET-2, documentation and examples - RFC 5737 */
"203.0.113.0/24", /* TEST-NET-3, documentation and examples - RFC 5737 */
"192.88.99.0/24", /* IPv6 to IPv4 relay (includes 2002::/16) - RFC 3068 */
"198.18.0.0/15", /* Network benchmark tests - RFC 2544 */
"224.0.0.0/4", /* IP multicast (former Class D network) - RFC 3171 */
"240.0.0.0/4", /* Reserved (former Class E network) - RFC 1112, Section 4 */
"255.255.255.255/32", /* Broadcast - RFC 919, Section 7 */
"100.64.0.0/10", /* Shared Address Space - RFC 6598 */
// ipv6 sourced from https://www.iana.org/assignments/iana-ipv6-special-registry/iana-ipv6-special-registry.xhtml
"::/128", /* Unspecified Address - RFC 4291 */
"::1/128", /* Loopback - RFC 4291 */
"100::/64", /* Discard prefix - RFC 6666 */
"2001::/23", /* IETF Protocol Assignments - RFC 2928 */
"2001:2::/48", /* Benchmarking - RFC5180 */
"2001:db8::/32", /* Addresses used in documentation and example source code - RFC 3849 */
"2001::/32", /* Teredo tunneling - RFC4380 - RFC8190 */
"fc00::/7", /* Unique local address - RFC 4193 - RFC 8190 */
"fe80::/10", /* Link-local address - RFC 4291 */
"ff00::/8", /* Multicast - RFC 3513 */
"2002::/16", /* 6to4 - RFC 3056 */
"64:ff9b::/96", /* IPv4/IPv6 translation - RFC 6052 */
"2001:10::/28", /* Deprecated (previously ORCHID) - RFC 4843 */
"2001:20::/28", /* ORCHIDv2 - RFC7343 */
} {
_, 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")
// dialContextFilter wraps a dial function to filter connections based on host and IP validation.
// It first checks if the host is allowed, then resolves the hostname to IPs and validates each one.
// Returns detailed error messages when connections are rejected for security reasons.
func dialContextFilter(dial DialContextFunction, allowHost func(host string) bool, allowIP func(ip net.IP) error) 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 firstDialErr error
var forbiddenReasons []string
for _, ip := range ips {
select {
case <-ctx.Done():
return nil, ctx.Err()
default:
}
if allowIP == nil {
forbiddenReasons = append(forbiddenReasons, fmt.Sprintf("IP %s is not allowed", ip))
continue
}
if err := allowIP(ip); err != nil {
forbiddenReasons = append(forbiddenReasons, err.Error())
continue
}
conn, err := dial(ctx, network, net.JoinHostPort(ip.String(), port))
if err == nil {
return conn, nil
}
if firstDialErr == nil {
firstDialErr = err
}
}
if firstDialErr == nil {
// If we didn't find an allowed IP address, return an error explaining why
if len(forbiddenReasons) > 0 {
return nil, fmt.Errorf("%s: %s", ErrAddressForbidden.Error(), strings.Join(forbiddenReasons, "; "))
}
return nil, ErrAddressForbidden
}
return nil, firstDialErr
}
}
func getProxyFn() func(r *http.Request) (*url.URL, error) {
proxyFromEnvFn := httpproxy.FromEnvironment().ProxyFunc()
return func(r *http.Request) (*url.URL, error) {
// TODO: Consider removing this code once MM-61938 is fixed upstream.
if r.URL != nil {
if addr, err := netip.ParseAddr(r.URL.Hostname()); err == nil && addr.Is6() && addr.Zone() != "" {
return nil, fmt.Errorf("invalid IPv6 address in URL: %q", addr)
}
}
return proxyFromEnvFn(r.URL)
}
}
// NewTransport creates a new MattermostTransport with detailed error messages for IP check failures
func NewTransport(enableInsecureConnections bool, allowHost func(host string) bool, allowIP func(ip net.IP) error) *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: getProxyFn(),
DialContext: dialContext,
MaxIdleConns: 100,
IdleConnTimeout: 90 * time.Second,
TLSHandshakeTimeout: ConnectTimeout,
ExpectContinueTimeout: 1 * time.Second,
TLSClientConfig: &tls.Config{
InsecureSkipVerify: enableInsecureConnections,
},
},
}
}

View File

@@ -0,0 +1,416 @@
// 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) error { return nil }
rejectIP := func(_ net.IP) error { return fmt.Errorf("IP not allowed") }
testCases := []struct {
description string
allowHost func(string) bool
allowIP func(net.IP) error
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.Contains(t, err.(*url.Error).Err.Error(), "address forbidden")
}
})
}
})
}
func TestNewTransportForInternalConnections(t *testing.T) {
// httptest servers listen on a loopback address, which is a reserved range.
mockHTTP := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
}))
defer mockHTTP.Close()
u, err := url.Parse(mockHTTP.URL)
require.NoError(t, err)
host := u.Hostname()
t.Run("blocks reserved IP ranges when the allowlist is empty", func(t *testing.T) {
c := NewHTTPClient(NewTransportForInternalConnections(false, ""))
_, err := c.Get(mockHTTP.URL)
require.IsType(t, &url.Error{}, err)
require.Contains(t, err.(*url.Error).Err.Error(), "address forbidden")
})
t.Run("allows a host named in the allowlist", func(t *testing.T) {
c := NewHTTPClient(NewTransportForInternalConnections(false, host))
resp, err := c.Get(mockHTTP.URL)
require.NoError(t, err)
resp.Body.Close()
})
t.Run("allows a CIDR range named in the allowlist", func(t *testing.T) {
c := NewHTTPClient(NewTransportForInternalConnections(false, "127.0.0.0/8 ::1/128"))
resp, err := c.Get(mockHTTP.URL)
require.NoError(t, err)
resp.Body.Close()
})
}
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))
t.Run("invalid IPv6 address in proxied URL", func(t *testing.T) {
c := NewHTTPClient(NewTransport(true, nil, nil))
t.Setenv("HTTP_PROXY", "http://proxy.example.org")
t.Setenv("HTTPS_PROXY", "https://proxy.example.org")
t.Setenv("NO_PROXY", ".example.com")
_, err := c.Get("http://[fe80::8e87:5021:6f6c:605e%25eth0]")
require.EqualError(t, err, `Get "http://[fe80::8e87:5021:6f6c:605e%25eth0]": invalid IPv6 address in URL: "fe80::8e87:5021:6f6c:605e%eth0"`)
})
}
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) error {
if IsReservedIP(ip) {
return fmt.Errorf("IP %s is reserved", ip)
}
return nil
})
_, err := filter(context.Background(), "", tc.Addr)
if tc.IsValid {
require.NoError(t, err)
require.True(t, didDial)
} else {
require.Error(t, err)
require.Contains(t, err.Error(), "address forbidden")
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},
// IPv4-mapped IPv6 addresses should be detected as reserved
{"::ffff:127.0.0.1", net.ParseIP("::ffff:127.0.0.1"), true},
{"::ffff:192.168.1.1", net.ParseIP("::ffff:192.168.1.1"), true},
{"::ffff:10.0.0.1", net.ParseIP("::ffff:10.0.0.1"), true},
{"::ffff:169.254.169.254", net.ParseIP("::ffff:169.254.169.254"), true},
{"::ffff:8.8.8.8", net.ParseIP("::ffff:8.8.8.8"), false},
// Pure IPv6 reserved addresses
{"::1", net.ParseIP("::1"), true},
{"fe80::1", net.ParseIP("fe80::1"), true},
// Public IPv6
{"2607:f8b0:4004:800::200e", net.ParseIP("2607:f8b0:4004:800::200e"), 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, err := IsOwnIP(tt.ip)
require.NoError(t, err)
assert.Equal(t, tt.want, got)
})
}
}
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)
}
func TestGetProxyFn(t *testing.T) {
t.Setenv("HTTP_PROXY", "http://proxy.example.org")
t.Setenv("HTTPS_PROXY", "https://proxy.example.org")
t.Setenv("NO_PROXY", ".example.com")
for _, tc := range []struct {
name string
input string
output string
err string
}{
{
name: "empty",
},
{
name: "no proxy",
input: "http://test.example.com",
},
{
name: "localhost",
input: "http://localhost",
},
{
name: "hostname",
input: "http://example.org",
output: "http://proxy.example.org",
},
{
name: "hostname with port",
input: "http://example.org:4545",
output: "http://proxy.example.org",
},
{
name: "https",
input: "https://example.org",
output: "https://proxy.example.org",
},
{
name: "ipv4",
input: "http://10.0.0.45",
output: "http://proxy.example.org",
},
{
name: "ipv4 with port",
input: "http://10.0.0.45:4545",
output: "http://proxy.example.org",
},
{
name: "ipv6",
input: "http://[fe80::8e87:5021:6f6c:605e]",
output: "http://proxy.example.org",
},
{
name: "ipv6 with port",
input: "http://[fe80::8e87:5021:6f6c:605e]:4545",
output: "http://proxy.example.org",
},
{
name: "ipv6 with zone",
input: "http://[fe80::8e87:5021:6f6c:605e%25eth0]",
output: "http://proxy.example.org",
err: `invalid IPv6 address in URL: "fe80::8e87:5021:6f6c:605e%eth0"`,
},
{
name: "ipv6 with zone and port",
input: "http://[fe80::8e87:5021:6f6c:605e%25eth0]:4545",
output: "http://proxy.example.org",
err: `invalid IPv6 address in URL: "fe80::8e87:5021:6f6c:605e%eth0"`,
},
} {
t.Run(tc.name, func(t *testing.T) {
inURL, err := url.Parse(tc.input)
require.NoError(t, err)
outURL, err := getProxyFn()(&http.Request{
URL: inURL,
})
if tc.err != "" { // error case
require.EqualError(t, err, tc.err)
return
}
require.NoError(t, err)
if tc.output == "" { // not proxied case
require.Nil(t, outURL)
} else { // proxied case
require.NotNil(t, outURL)
require.Equal(t, tc.output, outURL.String())
}
})
}
}

View File

@@ -0,0 +1,144 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package httpservice
import (
"fmt"
"net"
"net/http"
"slices"
"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,
}
}
// isAllowedInternalHost reports whether host appears verbatim in a
// space/comma-separated AllowedUntrustedInternalConnections value.
func isAllowedInternalHost(host, allowedUntrustedInternalConnections string) bool {
return slices.Contains(strings.FieldsFunc(allowedUntrustedInternalConnections, splitFields), host)
}
// checkInternalIP returns nil if ip may be dialed, or an error if it is a
// reserved-range or self-assigned IP not covered by a CIDR entry in the
// space/comma-separated AllowedUntrustedInternalConnections value.
func checkInternalIP(ip net.IP, allowedUntrustedInternalConnections string) error {
reservedIP := IsReservedIP(ip)
ownIP, err := IsOwnIP(ip)
if err != nil {
// If there is an error getting the self-assigned IPs, default to the secure option
return fmt.Errorf("unable to determine if IP is own IP: %w", err)
}
// If it's not a reserved IP and it's not self-assigned IP, accept the IP
if !reservedIP && !ownIP {
return nil
}
// Otherwise it needs to be explicitly added to AllowedUntrustedInternalConnections
for _, allowed := range strings.FieldsFunc(allowedUntrustedInternalConnections, splitFields) {
if _, ipRange, err := net.ParseCIDR(allowed); err == nil && ipRange.Contains(ip) {
return nil
}
}
if reservedIP {
return fmt.Errorf("IP %s is in a reserved range and not in AllowedUntrustedInternalConnections", ip)
}
return fmt.Errorf("IP %s is a self-assigned IP and not in AllowedUntrustedInternalConnections", ip)
}
// NewTransportForInternalConnections returns a transport that applies the same
// AllowedUntrustedInternalConnections filtering as MakeTransport, but takes the
// allowlist as a static value instead of reading it from a config service. It is
// for callers that have the allowlist string but not an HTTPService.
func NewTransportForInternalConnections(insecure bool, allowedUntrustedInternalConnections string) *MattermostTransport {
allowHost := func(host string) bool {
return isAllowedInternalHost(host, allowedUntrustedInternalConnections)
}
allowIP := func(ip net.IP) error {
return checkInternalIP(ip, allowedUntrustedInternalConnections)
}
return NewTransport(insecure, allowHost, allowIP)
}
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)
}
// The allowlist is read inside the closures so a long-lived client picks up
// config changes on each dial.
allowHost := func(host string) bool {
return isAllowedInternalHost(host, model.SafeDereference(h.configService.Config().ServiceSettings.AllowedUntrustedInternalConnections))
}
allowIP := func(ip net.IP) error {
return checkInternalIP(ip, model.SafeDereference(h.configService.Config().ServiceSettings.AllowedUntrustedInternalConnections))
}
return NewTransport(insecure, allowHost, allowIP)
}

View File

@@ -0,0 +1,116 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package httpservice
import (
"context"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/mattermost/mattermost/server/public/model"
)
type testConfigService struct {
config *model.Config
}
func (s *testConfigService) Config() *model.Config {
return s.config
}
// newTestHTTPService returns a service with the given default request timeout that is allowed to
// dial the loopback addresses used by httptest.
func newTestHTTPService(requestTimeout time.Duration) *HTTPServiceImpl {
config := &model.Config{}
config.SetDefaults()
config.ServiceSettings.AllowedUntrustedInternalConnections = new("127.0.0.1")
return &HTTPServiceImpl{
configService: &testConfigService{config: config},
RequestTimeout: requestTimeout,
}
}
func newSlowServer(t *testing.T, delay time.Duration) *httptest.Server {
t.Helper()
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
select {
case <-time.After(delay):
w.WriteHeader(http.StatusOK)
case <-r.Context().Done():
}
}))
t.Cleanup(server.Close)
return server
}
func doGet(t *testing.T, client *http.Client, rawURL string, contextTimeout time.Duration) (*http.Response, error) {
t.Helper()
ctx, cancel := context.WithTimeout(context.Background(), contextTimeout)
defer cancel()
req, err := http.NewRequestWithContext(ctx, http.MethodGet, rawURL, nil)
require.NoError(t, err)
return client.Do(req)
}
func TestMakeClientTimeout(t *testing.T) {
config := &model.Config{}
config.SetDefaults()
assert.Equal(t, RequestTimeout, MakeHTTPService(&testConfigService{config: config}).MakeClient(false).Timeout,
"the production constructor applies the package default")
service := newTestHTTPService(RequestTimeout)
assert.Equal(t, RequestTimeout, service.MakeClient(false).Timeout)
// Requests go through the transport applying the Mattermost user agent and the
// AllowedUntrustedInternalConnections checks regardless of trustURLs.
for _, trustURLs := range []bool{true, false} {
assert.IsType(t, &MattermostTransport{}, service.MakeClient(trustURLs).Transport)
}
}
// MM-68319 in miniature: a caller whose deadline is longer than the default request timeout only
// reaches a slow endpoint if its client's own timeout is cleared, as the outgoing integration
// call sites do.
func TestClientWithoutTimeoutOutlivesDefaultTimeout(t *testing.T) {
const defaultTimeout = 100 * time.Millisecond
const handlerDelay = 5 * defaultTimeout
server := newSlowServer(t, handlerDelay)
service := newTestHTTPService(defaultTimeout)
clientWithoutTimeout := func() *http.Client {
c := service.MakeClient(false)
c.Timeout = 0
return c
}
t.Run("the default request timeout caps a longer context deadline", func(t *testing.T) {
_, err := doGet(t, service.MakeClient(false), server.URL, 100*defaultTimeout)
require.ErrorIs(t, err, context.DeadlineExceeded)
})
t.Run("a client without a timeout runs to the context deadline", func(t *testing.T) {
resp, err := doGet(t, clientWithoutTimeout(), server.URL, 100*defaultTimeout)
require.NoError(t, err)
defer resp.Body.Close()
assert.Equal(t, http.StatusOK, resp.StatusCode)
})
t.Run("a client without a timeout still honours a shorter context deadline", func(t *testing.T) {
_, err := doGet(t, clientWithoutTimeout(), server.URL, defaultTimeout)
require.ErrorIs(t, err, context.DeadlineExceeded)
})
}

View File

@@ -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)
}