package proxy // import "github.com/pomerium/pomerium/proxy" import ( "crypto/tls" "crypto/x509" "encoding/base64" "errors" "fmt" "html/template" "net" "net/http" "net/http/httputil" "net/url" "strings" "time" "github.com/pomerium/envconfig" "google.golang.org/grpc" "google.golang.org/grpc/credentials" "github.com/pomerium/pomerium/internal/cryptutil" "github.com/pomerium/pomerium/internal/log" "github.com/pomerium/pomerium/internal/middleware" "github.com/pomerium/pomerium/internal/sessions" "github.com/pomerium/pomerium/internal/templates" pb "github.com/pomerium/pomerium/proto/authenticate" ) const ( // HeaderJWT is the header key for pomerium proxy's JWT signature. HeaderJWT = "x-pomerium-jwt-assertion" // HeaderUserID represents the header key for the user that is passed to the client. HeaderUserID = "x-pomerium-authenticated-user-id" // HeaderEmail represents the header key for the email that is passed to the client. HeaderEmail = "x-pomerium-authenticated-user-email" ) // Options represents the configurations available for the proxy service. type Options struct { // Authenticate service settings AuthenticateURL *url.URL `envconfig:"AUTHENTICATE_SERVICE_URL"` AuthenticateInternalURL string `envconfig:"AUTHENTICATE_INTERNAL_URL"` // OverideCertificateName string `envconfig:"OVERIDE_CERTIFICATE_NAME"` // SigningKey is a base64 encoded private key used to add a JWT-signature to proxied requests. // See : https://www.pomerium.io/guide/signed-headers.html SigningKey string `envconfig:"SIGNING_KEY"` // SharedKey is a 32 byte random key used to authenticate access between services. SharedKey string `envconfig:"SHARED_SECRET"` // Session/Cookie management CookieName string CookieSecret string `envconfig:"COOKIE_SECRET"` CookieDomain string `envconfig:"COOKIE_DOMAIN"` CookieSecure bool `envconfig:"COOKIE_SECURE"` CookieHTTPOnly bool `envconfig:"COOKIE_HTTP_ONLY"` CookieExpire time.Duration `envconfig:"COOKIE_EXPIRE"` CookieRefresh time.Duration `envconfig:"COOKIE_REFRESH"` CookieLifetimeTTL time.Duration `envconfig:"COOKIE_LIFETIME"` // Sub-routes Routes map[string]string `envconfig:"ROUTES"` DefaultUpstreamTimeout time.Duration `envconfig:"DEFAULT_UPSTREAM_TIMEOUT"` } // NewOptions returns a new options struct var defaultOptions = &Options{ CookieName: "_pomerium_proxy", CookieHTTPOnly: true, CookieSecure: true, CookieExpire: time.Duration(168) * time.Hour, CookieRefresh: time.Duration(30) * time.Minute, CookieLifetimeTTL: time.Duration(720) * time.Hour, DefaultUpstreamTimeout: time.Duration(10) * time.Second, } // OptionsFromEnvConfig builds the IdentityProvider service's configuration // options from provided environmental variables func OptionsFromEnvConfig() (*Options, error) { o := defaultOptions if err := envconfig.Process("", o); err != nil { return nil, err } return o, nil } // Validate checks that proper configuration settings are set to create // a proper Proxy instance func (o *Options) Validate() error { if len(o.Routes) == 0 { return errors.New("missing setting: routes") } for to, from := range o.Routes { if _, err := urlParse(to); err != nil { return fmt.Errorf("could not parse origin %s as url : %q", to, err) } if _, err := urlParse(from); err != nil { return fmt.Errorf("could not parse destination %s as url : %q", to, err) } } if o.AuthenticateURL == nil { return errors.New("missing setting: authenticate-service-url") } if o.AuthenticateURL.Scheme != "https" { return errors.New("authenticate-service-url must be a valid https url") } if o.CookieSecret == "" { return errors.New("missing setting: cookie-secret") } if o.SharedKey == "" { return errors.New("missing setting: client-secret") } decodedCookieSecret, err := base64.StdEncoding.DecodeString(o.CookieSecret) if err != nil { return fmt.Errorf("cookie secret is invalid base64: %v", err) } if len(decodedCookieSecret) != 32 { return fmt.Errorf("cookie secret expects 32 bytes but got %d", len(decodedCookieSecret)) } if len(o.SigningKey) != 0 { _, err := base64.StdEncoding.DecodeString(o.SigningKey) if err != nil { return fmt.Errorf("signing key is invalid base64: %v", err) } } return nil } // Proxy stores all the information associated with proxying a request. type Proxy struct { SharedKey string // Authenticate Service Configuration AuthenticateURL *url.URL AuthenticateInternalURL string AuthenticatorClient pb.AuthenticatorClient // AuthenticateConn must be closed by Proxy's caller AuthenticateConn *grpc.ClientConn OverideCertificateName string // session cipher cryptutil.Cipher csrfStore sessions.CSRFStore sessionStore sessions.SessionStore CookieExpire time.Duration CookieRefresh time.Duration CookieLifetimeTTL time.Duration redirectURL *url.URL templates *template.Template mux map[string]http.Handler } // New takes a Proxy service from options and a validation function. // Function returns an error if options fail to validate. func New(opts *Options) (*Proxy, error) { if opts == nil { return nil, errors.New("options cannot be nil") } if err := opts.Validate(); err != nil { return nil, err } // error explicitly handled by validate decodedSecret, _ := base64.StdEncoding.DecodeString(opts.CookieSecret) cipher, err := cryptutil.NewCipher(decodedSecret) if err != nil { return nil, fmt.Errorf("cookie-secret error: %s", err.Error()) } cookieStore, err := sessions.NewCookieStore(opts.CookieName, sessions.CreateCookieCipher(decodedSecret), func(c *sessions.CookieStore) error { c.CookieDomain = opts.CookieDomain c.CookieHTTPOnly = opts.CookieHTTPOnly c.CookieExpire = opts.CookieExpire return nil }) if err != nil { return nil, err } p := &Proxy{ // these fields make up the routing mechanism mux: make(map[string]http.Handler), // session state cipher: cipher, csrfStore: cookieStore, sessionStore: cookieStore, AuthenticateURL: opts.AuthenticateURL, AuthenticateInternalURL: opts.AuthenticateInternalURL, OverideCertificateName: opts.OverideCertificateName, SharedKey: opts.SharedKey, redirectURL: &url.URL{Path: "/.pomerium/callback"}, templates: templates.New(), CookieExpire: opts.CookieExpire, CookieLifetimeTTL: opts.CookieLifetimeTTL, } for from, to := range opts.Routes { fromURL, _ := urlParse(from) toURL, _ := urlParse(to) reverseProxy := NewReverseProxy(toURL) handler, err := NewReverseProxyHandler(opts, reverseProxy, fromURL.Host, toURL.Host) if err != nil { return nil, err } p.Handle(fromURL.Host, handler) log.Info().Str("from", fromURL.Host).Str("to", toURL.String()).Msg("proxy.New: new route") } // if no port given, assume https/443 port := p.AuthenticateURL.Port() if port == "" { port = "443" } authEndpoint := fmt.Sprintf("%s:%s", p.AuthenticateURL.Host, port) cp, err := x509.SystemCertPool() if err != nil { return nil, err } if p.AuthenticateInternalURL != "" { authEndpoint = p.AuthenticateInternalURL } log.Info().Str("authEndpoint", authEndpoint).Msgf("proxy.New: grpc authenticate connection") cert := credentials.NewTLS(&tls.Config{RootCAs: cp}) if p.OverideCertificateName != "" { err = cert.OverrideServerName(p.OverideCertificateName) if err != nil { return nil, err } } grpcAuth := middleware.NewSharedSecretCred(p.SharedKey) p.AuthenticateConn, err = grpc.Dial( authEndpoint, grpc.WithTransportCredentials(cert), grpc.WithPerRPCCredentials(grpcAuth), ) if err != nil { return nil, err } p.AuthenticatorClient = pb.NewAuthenticatorClient(p.AuthenticateConn) return p, nil } // UpstreamProxy stores information necessary for proxying the request back to the upstream. type UpstreamProxy struct { name string cookieName string handler http.Handler signer cryptutil.JWTSigner } var defaultUpstreamTransport = &http.Transport{ Proxy: http.ProxyFromEnvironment, DialContext: (&net.Dialer{ Timeout: 30 * time.Second, KeepAlive: 30 * time.Second, DualStack: true, }).DialContext, MaxIdleConns: 100, IdleConnTimeout: 90 * time.Second, TLSHandshakeTimeout: 30 * time.Second, ExpectContinueTimeout: 1 * time.Second, } // deleteUpstreamCookies deletes the session cookie from the request header string. func deleteUpstreamCookies(req *http.Request, cookieName string) { headers := []string{} for _, cookie := range req.Cookies() { if cookie.Name != cookieName { headers = append(headers, cookie.String()) } } req.Header.Set("Cookie", strings.Join(headers, ";")) } func (u *UpstreamProxy) signRequest(req *http.Request) { if u.signer != nil { jwt, err := u.signer.SignJWT(req.Header.Get(HeaderUserID), req.Header.Get(HeaderEmail)) if err == nil { req.Header.Set(HeaderJWT, jwt) } } } // ServeHTTP signs the http request and deletes cookie headers // before calling the upstream's ServeHTTP function. func (u *UpstreamProxy) ServeHTTP(w http.ResponseWriter, r *http.Request) { deleteUpstreamCookies(r, u.cookieName) u.signRequest(r) u.handler.ServeHTTP(w, r) } // NewReverseProxy returns a new ReverseProxy that routes URLs to the scheme, host, and // base path provided in target. NewReverseProxy rewrites the Host header. func NewReverseProxy(to *url.URL) *httputil.ReverseProxy { proxy := httputil.NewSingleHostReverseProxy(to) proxy.Transport = defaultUpstreamTransport director := proxy.Director proxy.Director = func(req *http.Request) { // Identifies the originating IP addresses of a client connecting to // a web server through an HTTP proxy or a load balancer. req.Header.Add("X-Forwarded-Host", req.Host) director(req) req.Host = to.Host } return proxy } // NewReverseProxyHandler applies handler specific options to a given route. func NewReverseProxyHandler(opts *Options, reverseProxy *httputil.ReverseProxy, from, to string) (http.Handler, error) { up := &UpstreamProxy{ name: to, handler: reverseProxy, cookieName: opts.CookieName, } if len(opts.SigningKey) != 0 { decodedSigningKey, err := base64.StdEncoding.DecodeString(opts.SigningKey) if err != nil { return nil, err } signer, err := cryptutil.NewES256Signer(decodedSigningKey, from) if err != nil { return nil, err } up.signer = signer } timeout := opts.DefaultUpstreamTimeout timeoutMsg := fmt.Sprintf("%s failed to respond within the %s timeout period", to, timeout) return http.TimeoutHandler(up, timeout, timeoutMsg), nil } // urlParse adds a scheme if none-exists, addressesing a quirk in how // one may expect url.Parse to function when given scheme-less domain is provided. // // see: https://github.com/golang/go/issues/12585 // see: https://golang.org/pkg/net/url/#Parse func urlParse(uri string) (*url.URL, error) { if !strings.Contains(uri, "://") { uri = fmt.Sprintf("https://%s", uri) } return url.Parse(uri) }