Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 15 additions & 2 deletions authclient/authclient.go
Original file line number Diff line number Diff line change
Expand Up @@ -42,10 +42,19 @@ func (client *AuthClient) CheckBearerToken(ctx context.Context, serverURL *url.U
req.Header.Set("Accept", "application/json")
req.Header.Set("Authorization", "Bearer "+bearerToken)

_, err = httputil.Fetch(ctx, client.cfg.tlsConfig, req)
opts, err := client.fetchOpts(serverURL)
if err != nil {
return err
}
_, err = httputil.Fetch(ctx, client.cfg.tlsConfig, req, opts...)
return err
}

// fetchOpts resolves the forward proxy for serverURL, if any.
func (client *AuthClient) fetchOpts(serverURL *url.URL) ([]httputil.FetchOption, error) {
return httputil.ProxyFetchOptions(client.cfg.forwardProxy, serverURL)
}

// GetJWT retrieves a JWT from Pomerium.
func (client *AuthClient) GetJWT(ctx context.Context, serverURL *url.URL, onOpenBrowser func(string)) (rawJWT string, err error) {
if client.cfg.serviceAccount != "" {
Expand Down Expand Up @@ -136,7 +145,11 @@ func (client *AuthClient) runOpenBrowser(ctx context.Context, li net.Listener, s
return err
}

bs, err := httputil.Fetch(ctx, client.cfg.tlsConfig, req)
opts, err := client.fetchOpts(serverURL)
if err != nil {
return err
}
bs, err := httputil.Fetch(ctx, client.cfg.tlsConfig, req, opts...)
if err != nil {
return err
}
Expand Down
32 changes: 32 additions & 0 deletions authclient/authclient_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,11 @@ package authclient

import (
"context"
"crypto/tls"
"crypto/x509"
"net"
"net/http"
"net/http/httptest"
"net/url"
"os"
"path/filepath"
Expand All @@ -13,8 +16,37 @@ import (
"github.com/go-chi/chi/v5"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"

"github.com/pomerium/cli/internal/testutil"
)

// Auth requests route through the forward proxy.
func TestCheckBearerTokenViaProxy(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()

srv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusOK)
}))
defer srv.Close()

pool := x509.NewCertPool()
pool.AddCert(srv.Certificate())

proxy := testutil.NewConnectProxy(t)

ac := New(
WithForwardProxy(proxy.Addr),
WithTLSConfig(&tls.Config{RootCAs: pool}))

serverURL, err := url.Parse(srv.URL)
require.NoError(t, err)

require.NoError(t, ac.CheckBearerToken(ctx, serverURL, "token"))
assert.Positive(t, proxy.Conns(), "auth request should route through the forward proxy")
assert.Equal(t, serverURL.Host, proxy.Target())
}

func TestAuthClient(t *testing.T) {
t.Parallel()

Expand Down
9 changes: 9 additions & 0 deletions authclient/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ type config struct {
serviceAccount string
serviceAccountFile string
tlsConfig *tls.Config
forwardProxy string
}

func getConfig(options ...Option) *config {
Expand Down Expand Up @@ -38,6 +39,14 @@ func WithBrowserCommand(browserCommand string) Option {
}
}

// WithForwardProxy sets an explicit forward proxy override (host:port or URL).
// An empty value falls back to the standard proxy environment variables.
func WithForwardProxy(forwardProxy string) Option {
return func(cfg *config) {
cfg.forwardProxy = forwardProxy
}
}

// WithServiceAccount sets the service account in the config.
func WithServiceAccount(serviceAccount string) Option {
return func(cfg *config) {
Expand Down
23 changes: 21 additions & 2 deletions cmd/pomerium-cli/tcp.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,14 +9,17 @@ import (
"os/signal"
"syscall"

"github.com/rs/zerolog/log"
"github.com/spf13/cobra"

"github.com/pomerium/cli/internal/httputil"
"github.com/pomerium/cli/tunnel"
)

var tcpCmdOptions struct {
listen string
pomeriumURL string
listen string
pomeriumURL string
forwardProxy string
}

func init() {
Expand All @@ -28,6 +31,8 @@ func init() {
"local address to start a listener on")
flags.StringVar(&tcpCmdOptions.pomeriumURL, "pomerium-url", "",
"the URL of the pomerium server to connect to")
flags.StringVar(&tcpCmdOptions.forwardProxy, "forward-proxy", "",
"forward proxy for the tunnel and auth (host:port or http/https/socks5/socks5h URL); overrides proxy environment variables and ignores NO_PROXY")
rootCmd.AddCommand(tcpCmd)
}

Expand All @@ -42,6 +47,19 @@ var tcpCmd = &cobra.Command{
}
cacheLastURL(proxyURL.String())

// Validate an explicit --forward-proxy before binding the listener so a
// bad value fails as a normal CLI error. Environment proxies are
// resolved later, at request time.
fwdProxyURL, err := httputil.ValidateForwardProxyFlag(tcpCmdOptions.forwardProxy)
if err != nil {
return err
}
if fwdProxyURL != nil {
log.Info().
Str("forward_proxy", fwdProxyURL.Redacted()).
Msg("routing tunnel and auth through forward proxy (--forward-proxy); HTTP/3 disabled")
}

var tlsConfig *tls.Config
if proxyURL.Scheme == "https" {
tlsConfig, err = getTLSConfig()
Expand All @@ -65,6 +83,7 @@ var tcpCmd = &cobra.Command{
tunnel.WithServiceAccount(serviceAccountOptions.serviceAccount),
tunnel.WithServiceAccountFile(serviceAccountOptions.serviceAccountFile),
tunnel.WithTLSConfig(tlsConfig),
tunnel.WithForwardProxy(tcpCmdOptions.forwardProxy),
)

if tcpCmdOptions.listen == "-" {
Expand Down
34 changes: 33 additions & 1 deletion internal/httputil/fetch.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,21 +6,53 @@ import (
"errors"
"fmt"
"io"
"net"
"net/http"
"net/url"
"time"
)

// ErrUnauthenticated indicates the user needs to authenticate.
var ErrUnauthenticated = errors.New("unauthenticated")

type fetchConfig struct {
proxyURL *url.URL
}

// A FetchOption modifies the behavior of Fetch.
type FetchOption func(*fetchConfig)

// WithProxyURL routes the request through the given forward proxy. When unset,
// the default transport still honors HTTP_PROXY/HTTPS_PROXY.
func WithProxyURL(u *url.URL) FetchOption {
return func(c *fetchConfig) {
c.proxyURL = u
}
}

// Fetch fetches the http request.
func Fetch(ctx context.Context, tlsConfig *tls.Config, req *http.Request) ([]byte, error) {
func Fetch(ctx context.Context, tlsConfig *tls.Config, req *http.Request, opts ...FetchOption) ([]byte, error) {
var cfg fetchConfig
for _, o := range opts {
o(&cfg)
}

ctx, clearTimeout := context.WithTimeout(ctx, 10*time.Second)
defer clearTimeout()
req = req.WithContext(ctx)

transport := http.DefaultTransport.(*http.Transport).Clone()
transport.TLSClientConfig = tlsConfig
if cfg.proxyURL != nil {
// Dial the proxy ourselves so the proxy hop's TLS is validated against
// system trust (matching the tunnel path), while the target's TLS still
// uses tlsConfig. Setting transport.Proxy would instead validate an https
// proxy against tlsConfig and re-read the proxy environment.
transport.Proxy = nil
transport.DialContext = func(ctx context.Context, _, addr string) (net.Conn, error) {
return DialThroughProxy(ctx, cfg.proxyURL, addr)
}
}
hc := &http.Client{
Transport: transport,
}
Expand Down
155 changes: 155 additions & 0 deletions internal/httputil/fetch_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,155 @@
package httputil

import (
"context"
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"crypto/tls"
"crypto/x509"
"crypto/x509/pkix"
"encoding/base64"
"math/big"
"net"
"net/http"
"net/http/httptest"
"net/url"
"testing"
"time"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"

"github.com/pomerium/cli/internal/testutil"
)

// One CONNECT hop to the target; the target's TLS is still validated against
// tlsConfig over the tunnel.
func TestFetchHTTPSTargetThroughProxy(t *testing.T) {
target := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
_, _ = w.Write([]byte("hello through proxy"))
}))
defer target.Close()

pool := x509.NewCertPool()
pool.AddCert(target.Certificate())

proxy := testutil.NewConnectProxy(t)

ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()

req, err := http.NewRequest(http.MethodGet, target.URL, nil)
require.NoError(t, err)

body, err := Fetch(ctx, &tls.Config{RootCAs: pool}, req,
WithProxyURL(&url.URL{Scheme: "http", Host: proxy.Addr}))
require.NoError(t, err)
assert.Equal(t, "hello through proxy", string(body))

// Exactly one CONNECT, addressed to the target host:port (not the proxy).
assert.Equal(t, int64(1), proxy.Conns(), "expected a single CONNECT hop")
targetURL, err := url.Parse(target.URL)
require.NoError(t, err)
assert.Equal(t, targetURL.Host, proxy.Target())
}

// With transport.Proxy nil, net/http no longer injects Proxy-Authorization, so
// the header must come from dialHTTPConnect using the proxy URL's userinfo.
func TestFetchProxyBasicAuth(t *testing.T) {
target := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
_, _ = w.Write([]byte("ok"))
}))
defer target.Close()

pool := x509.NewCertPool()
pool.AddCert(target.Certificate())

proxy := testutil.NewConnectProxy(t)

ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()

req, err := http.NewRequest(http.MethodGet, target.URL, nil)
require.NoError(t, err)

_, err = Fetch(ctx, &tls.Config{RootCAs: pool}, req,
WithProxyURL(&url.URL{Scheme: "http", User: url.UserPassword("alice", "s3cret"), Host: proxy.Addr}))
require.NoError(t, err)

want := "Basic " + base64.StdEncoding.EncodeToString([]byte("alice:s3cret"))
assert.Equal(t, want, proxy.ProxyAuth(), "proxy credentials must reach the proxy via CONNECT")
}

// An https forward proxy is validated against system trust, not the edge
// tlsConfig pool: the proxy cert here is trusted by the edge pool but not by
// system trust, so the dial must fail.
func TestFetchHTTPSProxyValidatesAgainstSystemTrust(t *testing.T) {
cert, leaf := selfSignedCert(t, "127.0.0.1")
proxyAddr := startTLSProxy(t, cert)

pool := x509.NewCertPool()
pool.AddCert(leaf)

ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()

req, err := http.NewRequest(http.MethodGet, "https://edge.example.com/path", nil)
require.NoError(t, err)

_, err = Fetch(ctx, &tls.Config{RootCAs: pool}, req,
WithProxyURL(&url.URL{Scheme: "https", Host: proxyAddr}))
require.Error(t, err)
assert.Contains(t, err.Error(), "system-trusted")
}

func selfSignedCert(t *testing.T, host string) (tls.Certificate, *x509.Certificate) {
t.Helper()
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
require.NoError(t, err)
tmpl := &x509.Certificate{
SerialNumber: big.NewInt(1),
Subject: pkix.Name{CommonName: host},
NotBefore: time.Now().Add(-time.Hour),
NotAfter: time.Now().Add(time.Hour),
KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageCertSign,
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
BasicConstraintsValid: true,
IsCA: true,
}
if ip := net.ParseIP(host); ip != nil {
tmpl.IPAddresses = []net.IP{ip}
} else {
tmpl.DNSNames = []string{host}
}
der, err := x509.CreateCertificate(rand.Reader, tmpl, tmpl, &key.PublicKey, key)
require.NoError(t, err)
leaf, err := x509.ParseCertificate(der)
require.NoError(t, err)
return tls.Certificate{Certificate: [][]byte{der}, PrivateKey: key, Leaf: leaf}, leaf
}

// startTLSProxy starts a TLS listener that presents cert and drives the TLS
// handshake on each connection. A client that does not trust cert fails during
// the handshake, before any CONNECT is exchanged.
func startTLSProxy(t *testing.T, cert tls.Certificate) string {
t.Helper()
li, err := tls.Listen("tcp", "127.0.0.1:0", &tls.Config{Certificates: []tls.Certificate{cert}})
require.NoError(t, err)
t.Cleanup(func() { _ = li.Close() })
go func() {
for {
conn, err := li.Accept()
if err != nil {
return
}
go func() {
defer conn.Close()
if tc, ok := conn.(*tls.Conn); ok {
_ = tc.Handshake()
}
}()
}
}()
return li.Addr().String()
}
Loading
Loading