Fix http proxy basic auth (#13140)

* Fix http proxy basic auth

* Update docs about HTTP CONNECT env var formats
This commit is contained in:
Gavin Frazar
2022-06-23 00:27:29 +00:00
committed by GitHub
parent 5e8cfb345c
commit 1858aafa15
11 changed files with 266 additions and 47 deletions
+2 -2
View File
@@ -59,8 +59,8 @@ func newDirectDialer(keepAlivePeriod, dialTimeout time.Duration) ContextDialer {
func NewDialer(keepAlivePeriod, dialTimeout time.Duration) ContextDialer {
return ContextDialerFunc(func(ctx context.Context, network, addr string) (net.Conn, error) {
dialer := newDirectDialer(keepAlivePeriod, dialTimeout)
if proxyAddr := proxy.GetProxyAddress(addr); proxyAddr != nil {
return DialProxyWithDialer(ctx, proxyAddr.Host, addr, dialer)
if proxyURL := proxy.GetProxyURL(addr); proxyURL != nil {
return DialProxyWithDialer(ctx, proxyURL, addr, dialer)
}
return dialer.DialContext(ctx, network, addr)
})
+21 -6
View File
@@ -19,6 +19,7 @@ package client
import (
"bufio"
"context"
"encoding/base64"
"net"
"net/http"
"net/url"
@@ -28,23 +29,37 @@ import (
)
// DialProxy creates a connection to a server via an HTTP Proxy.
func DialProxy(ctx context.Context, proxyAddr, addr string) (net.Conn, error) {
return DialProxyWithDialer(ctx, proxyAddr, addr, &net.Dialer{})
func DialProxy(ctx context.Context, proxyURL *url.URL, addr string) (net.Conn, error) {
return DialProxyWithDialer(ctx, proxyURL, addr, &net.Dialer{})
}
// DialProxyWithDialer creates a connection to a server via an HTTP Proxy using a specified dialer.
func DialProxyWithDialer(ctx context.Context, proxyAddr, addr string, dialer ContextDialer) (net.Conn, error) {
conn, err := dialer.DialContext(ctx, "tcp", proxyAddr)
func DialProxyWithDialer(ctx context.Context, proxyURL *url.URL, addr string, dialer ContextDialer) (net.Conn, error) {
if proxyURL == nil {
return nil, trace.BadParameter("missing proxy url")
}
conn, err := dialer.DialContext(ctx, "tcp", proxyURL.Host)
if err != nil {
log.Warnf("Unable to dial to proxy: %v: %v.", proxyAddr, err)
log.Warnf("Unable to dial to proxy: %v: %v.", proxyURL.Host, err)
return nil, trace.ConvertSystemError(err)
}
header := make(http.Header)
if proxyURL.User != nil {
// dont use User.String() because it performs url encoding (rfc 1738),
// which we don't want in our header
password, _ := proxyURL.User.Password()
// empty user/pass is permitted by the spec. The minimum required is a single colon.
// see: https://datatracker.ietf.org/doc/html/rfc1945#section-11
creds := proxyURL.User.Username() + ":" + password
basicAuth := "Basic " + base64.StdEncoding.EncodeToString([]byte(creds))
header.Add("Proxy-Authorization", basicAuth)
}
connectReq := &http.Request{
Method: http.MethodConnect,
URL: &url.URL{Opaque: addr},
Host: addr,
Header: make(http.Header),
Header: header,
}
if err := connectReq.Write(conn); err != nil {
+2 -2
View File
@@ -25,8 +25,8 @@ import (
"golang.org/x/net/http/httpproxy"
)
// GetProxyAddress gets the HTTP proxy address to use for a given address, if any.
func GetProxyAddress(dialAddr string) *url.URL {
// GetProxyURL gets the HTTP proxy address to use for a given address, if any.
func GetProxyURL(dialAddr string) *url.URL {
addrURL, err := parse(dialAddr)
if err != nil || addrURL == nil {
return nil
+61 -10
View File
@@ -21,8 +21,10 @@ import (
"fmt"
"net/http"
"net/url"
"strings"
"testing"
"github.com/gravitational/trace"
"github.com/stretchr/testify/require"
"golang.org/x/net/http/httpproxy"
)
@@ -106,22 +108,71 @@ func TestGetProxyAddress(t *testing.T) {
},
}
// used to augment test cases with auth credentials
authTests := []struct {
info string
user string
password string
}{
{info: "no credentials", user: "", password: ""},
{info: "plain password", user: "alice", password: "password"},
{info: "special characters in password", user: "alice", password: " !@#$%^&*()_+-=[]{};:,.<>/?`~\"\\ abc123"},
}
for i, tt := range tests {
t.Run(fmt.Sprintf("%v: %v", i, tt.info), func(t *testing.T) {
for _, env := range tt.env {
t.Setenv(env.name, env.val)
}
p := GetProxyAddress(tt.targetAddr)
if tt.proxyAddr == "" {
require.Nil(t, p)
} else {
for j, authTest := range authTests {
t.Run(fmt.Sprintf("%v %v: %v with %v", i, j, tt.info, authTest.info), func(t *testing.T) {
for _, env := range tt.env {
switch strings.ToLower(env.name) {
case "http_proxy", "https_proxy":
// add auth test credentials into http(s)_proxy env vars
val, err := buildProxyAddr(env.val, authTest.user, authTest.password)
require.NoError(t, err)
t.Setenv(env.name, val)
case "no_proxy":
t.Setenv(env.name, env.val)
}
}
p := GetProxyURL(tt.targetAddr)
// is a proxy expected?
if tt.proxyAddr == "" {
require.Nil(t, p)
return
}
require.NotNil(t, p)
require.Equal(t, tt.proxyAddr, p.Host)
}
})
// are auth credentials expected?
if authTest.user == "" && authTest.password == "" {
require.Nil(t, p.User)
return
}
require.NotNil(t, p.User)
require.Equal(t, authTest.user, p.User.Username())
password, _ := p.User.Password()
require.Equal(t, authTest.password, password)
})
}
}
}
func buildProxyAddr(addr, user, pass string) (string, error) {
if user == "" && pass == "" {
return addr, nil
}
userInfo := url.UserPassword(user, pass)
if strings.HasPrefix(addr, "http") {
u, err := url.Parse(addr)
if err != nil {
return "", trace.Wrap(err)
}
u.User = userInfo
return u.String(), nil
}
return fmt.Sprintf("%v@%v", userInfo.String(), addr), nil
}
func TestProxyAwareRoundTripper(t *testing.T) {
t.Setenv("HTTP_PROXY", "http://localhost:8888")
transport := &http.Transport{
+1 -1
View File
@@ -71,7 +71,7 @@ Environment="NO_PROXY=localhost,127.0.0.1,192.168.0.0/16,172.16.0.0/12,10.0.0.0/
When Teleport builds and establishes the reverse tunnel to the main cluster, it will funnel all traffic through the proxy. Specifically, if using the default configuration, Teleport will tunnel ports `3024` (SSH, reverse tunnel) and `3080` (HTTPS, establishing trust) through the proxy.
The value of `HTTPS_PROXY` or `HTTP_PROXY` should be in the format
`scheme://host:port` where scheme is either `https` or `http` . If the value is
`scheme://[user[:password]@]host:port` where scheme is either `https` or `http` . If the value is
`host:port` , Teleport will prepend `http` .
<Admonition
+1 -1
View File
@@ -181,7 +181,7 @@ func getLocalIP() (string, error) {
default:
continue
}
if !ip.IsLoopback() {
if !ip.IsLoopback() && ip.IsPrivate() {
return ip.String(), nil
}
}
+77 -3
View File
@@ -15,16 +15,18 @@
package helpers
import (
"fmt"
"io"
"net"
"net/http"
"net/url"
"sync"
"time"
"github.com/gravitational/trace"
)
type ProxyServer struct {
type ProxyHandler struct {
sync.Mutex
count int
}
@@ -32,7 +34,7 @@ type ProxyServer struct {
// ServeHTTP only accepts the CONNECT verb and will tunnel your connection to
// the specified host. Also tracks the number of connections that it proxies for
// debugging purposes.
func (p *ProxyServer) ServeHTTP(w http.ResponseWriter, r *http.Request) {
func (p *ProxyHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
// Validate http connect parameters.
if r.Method != http.MethodConnect {
trace.WriteError(w, trace.BadParameter("%v not supported", r.Method))
@@ -91,8 +93,80 @@ func (p *ProxyServer) ServeHTTP(w http.ResponseWriter, r *http.Request) {
}
// Count returns the number of connections that have been proxied.
func (p *ProxyServer) Count() int {
func (p *ProxyHandler) Count() int {
p.Lock()
defer p.Unlock()
return p.count
}
type ProxyAuthorizer struct {
next http.Handler
sync.Mutex
lastError error
authDB map[string]string
}
func NewProxyAuthorizer(handler http.Handler, authDB map[string]string) *ProxyAuthorizer {
return &ProxyAuthorizer{next: handler, authDB: authDB}
}
func (p *ProxyAuthorizer) ServeHTTP(w http.ResponseWriter, r *http.Request) {
auth := r.Header.Get("Proxy-Authorization")
if auth == "" {
err := trace.AccessDenied("missing Proxy-Authorization header")
p.SetError(err)
trace.WriteError(w, err)
return
}
user, password, ok := parseProxyAuth(auth)
if !ok {
err := trace.AccessDenied("bad Proxy-Authorization header")
p.SetError(err)
trace.WriteError(w, err)
return
}
if p.isAuthorized(user, password) {
p.SetError(nil)
p.next.ServeHTTP(w, r)
return
}
err := trace.AccessDenied("bad credentials")
p.SetError(err)
trace.WriteError(w, err)
}
func (p *ProxyAuthorizer) SetError(err error) {
p.Lock()
defer p.Unlock()
p.lastError = err
}
func (p *ProxyAuthorizer) LastError() error {
p.Lock()
defer p.Unlock()
return p.lastError
}
func (p *ProxyAuthorizer) isAuthorized(user, pass string) bool {
p.Lock()
defer p.Unlock()
expectedPass, ok := p.authDB[user]
return ok && pass == expectedPass
}
// parse "Proxy-Authorization" header by leveraging the stdlib basic auth parsing for "Authorization" header
func parseProxyAuth(proxyAuth string) (user, password string, ok bool) {
fakeHeader := make(http.Header)
fakeHeader.Add("Authorization", proxyAuth)
fakeReq := &http.Request{
Header: fakeHeader,
}
return fakeReq.BasicAuth()
}
func MakeProxyAddr(user, pass, host string) string {
userPass := url.UserPassword(user, pass).String()
return fmt.Sprintf("%v@%v", userPass, host)
}
+4 -4
View File
@@ -1620,8 +1620,8 @@ func testTwoClustersTunnel(t *testing.T, suite *integrationTestSuite) {
func twoClustersTunnel(t *testing.T, suite *integrationTestSuite, now time.Time, proxyRecordMode string, execCountSiteA, execCountSiteB int) {
// start the http proxy, we need to make sure this was not used
ps := &helpers.ProxyServer{}
ts := httptest.NewServer(ps)
ph := &helpers.ProxyHandler{}
ts := httptest.NewServer(ph)
defer ts.Close()
// clear out any proxy environment variables
@@ -1677,7 +1677,7 @@ func twoClustersTunnel(t *testing.T, suite *integrationTestSuite, now time.Time,
)
// make sure the direct dialer was used and not the proxy dialer
require.Zero(t, ps.Count())
require.Zero(t, ph.Count())
// if we got here, it means two sites are cross-connected. lets execute SSH commands
sshPort := a.GetPortSSHInt()
@@ -1788,7 +1788,7 @@ func testTwoClustersProxy(t *testing.T, suite *integrationTestSuite) {
defer tr.Stop()
// start the http proxy
ps := &helpers.ProxyServer{}
ps := &helpers.ProxyHandler{}
ts := httptest.NewServer(ps)
defer ts.Close()
+88 -10
View File
@@ -30,6 +30,7 @@ import (
"github.com/google/uuid"
"github.com/gravitational/teleport/api/breaker"
"github.com/gravitational/teleport/integration/helpers"
"github.com/gravitational/trace"
"github.com/stretchr/testify/require"
"go.mongodb.org/mongo-driver/bson"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
@@ -206,8 +207,8 @@ func TestALPNSNIProxyTrustedClusterNode(t *testing.T) {
// on a single proxy port setup.
func TestALPNSNIHTTPSProxy(t *testing.T) {
// start the http proxy
ps := &helpers.ProxyServer{}
ts := httptest.NewServer(ps)
ph := &helpers.ProxyHandler{}
ts := httptest.NewServer(ph)
defer ts.Close()
// set the http_proxy environment variable
@@ -237,15 +238,15 @@ func TestALPNSNIHTTPSProxy(t *testing.T) {
require.Eventually(t, waitForClusters(suite.leaf.Tunnel, 1), 10*time.Second, 1*time.Second,
"Two clusters do not see each other: tunnels are not working.")
require.Greater(t, ps.Count(), 0, "proxy did not intercept any connection")
require.Greater(t, ph.Count(), 0, "proxy did not intercept any connection")
}
// TestMultiPortHTTPSProxy tests if the reverse tunnel uses http_proxy
// on a multiple proxy port setup.
func TestMultiPortHTTPSProxy(t *testing.T) {
// start the http proxy
ps := &helpers.ProxyServer{}
ts := httptest.NewServer(ps)
ph := &helpers.ProxyHandler{}
ts := httptest.NewServer(ph)
defer ts.Close()
// set the http_proxy environment variable
@@ -275,7 +276,7 @@ func TestMultiPortHTTPSProxy(t *testing.T) {
require.Eventually(t, waitForClusters(suite.leaf.Tunnel, 1), 10*time.Second, 1*time.Second,
"Two clusters do not see each other: tunnels are not working.")
require.Greater(t, ps.Count(), 0, "proxy did not intercept any connection")
require.Greater(t, ph.Count(), 0, "proxy did not intercept any connection")
}
// TestAlpnSniProxyKube tests Kubernetes access with custom Kube API mock where traffic is forwarded via
@@ -774,8 +775,8 @@ func TestALPNProxyHTTPProxyNoProxyDial(t *testing.T) {
defer rc.StopAll()
// Create and start http_proxy server.
ps := &helpers.ProxyServer{}
ts := httptest.NewServer(ps)
ph := &helpers.ProxyHandler{}
ts := httptest.NewServer(ph)
defer ts.Close()
u, err := url.Parse(ts.URL)
@@ -797,7 +798,7 @@ func TestALPNProxyHTTPProxyNoProxyDial(t *testing.T) {
err = waitForNodeCount(ctx, rc, "root.example.com", 1)
require.NoError(t, err)
require.Zero(t, ps.Count())
require.Zero(t, ph.Count())
// Unset the no_proxy=127.0.0.1 env variable. After that a new node
// should take into account the http_proxy address and connection should go through the http_proxy.
@@ -807,5 +808,82 @@ func TestALPNProxyHTTPProxyNoProxyDial(t *testing.T) {
err = waitForNodeCount(ctx, rc, "root.example.com", 2)
require.NoError(t, err)
require.NotZero(t, ps.Count())
require.NotZero(t, ph.Count())
}
// TestALPNProxyHTTPProxyBasicAuthDial tests if a node joining to root cluster
// takes into account http_proxy with basic auth credentials in the address
func TestALPNProxyHTTPProxyBasicAuthDial(t *testing.T) {
lib.SetInsecureDevMode(true)
defer lib.SetInsecureDevMode(false)
rcAddr, err := getLocalIP()
require.NoError(t, err)
rc := helpers.NewInstance(helpers.InstanceConfig{
ClusterName: "root.example.com",
HostID: uuid.New().String(),
NodeName: rcAddr,
Log: utils.NewLoggerForTests(),
Ports: helpers.SingleProxyPortSetup(),
})
username := mustGetCurrentUser(t).Username
rc.AddUser(username, []string{username})
rcConf := service.MakeDefaultConfig()
rcConf.DataDir = t.TempDir()
rcConf.Auth.Enabled = true
rcConf.Auth.NetworkingConfig.SetProxyListenerMode(types.ProxyListenerMode_Multiplex)
rcConf.Auth.Preference.SetSecondFactor("off")
rcConf.Proxy.Enabled = true
rcConf.Proxy.DisableWebInterface = true
rcConf.SSH.Enabled = false
rcConf.CircuitBreakerConfig = breaker.NoopBreakerConfig()
err = rc.CreateEx(t, nil, rcConf)
require.NoError(t, err)
err = rc.Start()
require.NoError(t, err)
defer rc.StopAll()
ctx, cancel := context.WithDeadline(context.Background(), time.Now().Add(time.Second*30))
defer cancel()
validUser := "aladdin"
validPass := "open sesame"
// Create and start http_proxy server.
ph := &helpers.ProxyHandler{}
authorizer := helpers.NewProxyAuthorizer(ph, map[string]string{validUser: validPass})
ts := httptest.NewServer(authorizer)
defer ts.Close()
proxyURL, err := url.Parse(ts.URL)
require.NoError(t, err)
rcProxyAddr := net.JoinHostPort(rcAddr, rc.GetPortWeb())
// proxy url is just the host with no auth credentials
t.Setenv("http_proxy", proxyURL.Host)
_, err = rc.StartNode(makeNodeConfig("first-root-node", rcProxyAddr))
require.Error(t, err)
require.ErrorIs(t, authorizer.LastError(), trace.AccessDenied("missing Proxy-Authorization header"))
require.Zero(t, ph.Count())
// proxy url is user:password@host with incorrect password
t.Setenv("http_proxy", helpers.MakeProxyAddr(validUser, "incorrectPassword", proxyURL.Host))
_, err = rc.StartNode(makeNodeConfig("second-root-node", rcProxyAddr))
require.Error(t, err)
require.ErrorIs(t, authorizer.LastError(), trace.AccessDenied("bad credentials"))
require.Zero(t, ph.Count())
// proxy url is user:password@host with correct password
t.Setenv("http_proxy", helpers.MakeProxyAddr(validUser, validPass, proxyURL.Host))
_, err = rc.StartNode(makeNodeConfig("third-root-node", rcProxyAddr))
require.NoError(t, err)
err = waitForNodeCount(ctx, rc, "root.example.com", 1)
require.NoError(t, err)
require.NoError(t, authorizer.LastError())
require.NotZero(t, ph.Count())
}
+1 -1
View File
@@ -1078,7 +1078,7 @@ func (proxy *ProxyClient) loadTLS(clusterName string) (*tls.Config, error) {
return tlsConfig.Clone(), nil
}
// ConnectToAuthServiceThroughALPNSNIProxy uses ALPN proxy service to connect to remove/local auth
// ConnectToAuthServiceThroughALPNSNIProxy uses ALPN proxy service to connect to remote/local auth
// service and returns auth client. For routing purposes, TLS ServerName is set to destination auth service
// cluster name with ALPN values set to teleport-auth protocol.
func (proxy *ProxyClient) ConnectToAuthServiceThroughALPNSNIProxy(ctx context.Context, clusterName string) (auth.ClientI, error) {
+8 -7
View File
@@ -20,6 +20,7 @@ import (
"context"
"crypto/tls"
"net"
"net/url"
"time"
"github.com/gravitational/teleport"
@@ -161,7 +162,7 @@ func (d directDial) DialTimeout(ctx context.Context, network, address string, ti
type proxyDial struct {
// proxyHost is the HTTPS proxy address.
proxyHost string
proxyURL *url.URL
// insecure is whether to skip certificate validation.
insecure bool
// tlsRoutingEnabled indicates that proxy is running in TLSRouting mode.
@@ -189,7 +190,7 @@ func (d proxyDial) DialTimeout(ctx context.Context, network, address string, tim
defer cancel()
ctx = timeoutCtx
}
conn, err := apiclient.DialProxy(ctx, d.proxyHost, address)
conn, err := apiclient.DialProxy(ctx, d.proxyURL, address)
if err != nil {
return nil, trace.Wrap(err)
}
@@ -216,7 +217,7 @@ func (d proxyDial) DialTimeout(ctx context.Context, network, address string, tim
// SSH connection.
func (d proxyDial) Dial(ctx context.Context, network string, addr string, config *ssh.ClientConfig) (*tracessh.Client, error) {
// Build a proxy connection first.
pconn, err := apiclient.DialProxy(ctx, d.proxyHost, addr)
pconn, err := apiclient.DialProxy(ctx, d.proxyURL, addr)
if err != nil {
return nil, trace.Wrap(err)
}
@@ -283,7 +284,7 @@ func WithInsecureSkipTLSVerify(insecure bool) DialerOptionFunc {
// server directly.
func DialerFromEnvironment(addr string, opts ...DialerOptionFunc) Dialer {
// Try and get proxy addr from the environment.
proxyAddr := apiproxy.GetProxyAddress(addr)
proxyURL := apiproxy.GetProxyURL(addr)
var options dialerOptions
for _, opt := range opts {
@@ -292,7 +293,7 @@ func DialerFromEnvironment(addr string, opts ...DialerOptionFunc) Dialer {
// If no proxy settings are in environment return regular ssh dialer,
// otherwise return a proxy dialer.
if proxyAddr == nil {
if proxyURL == nil {
log.Debugf("No proxy set in environment, returning direct dialer.")
return directDial{
tlsConfig: options.tlsConfig,
@@ -300,9 +301,9 @@ func DialerFromEnvironment(addr string, opts ...DialerOptionFunc) Dialer {
insecure: options.insecureSkipTLSVerify,
}
}
log.Debugf("Found proxy %q in environment, returning proxy dialer.", proxyAddr)
log.Debugf("Found proxy %q in environment, returning proxy dialer.", proxyURL)
return proxyDial{
proxyHost: proxyAddr.Host,
proxyURL: proxyURL,
insecure: options.insecureSkipTLSVerify,
tlsRoutingEnabled: options.tlsRoutingEnabled,
tlsConfig: options.tlsConfig,