cyberark/secretless-broker

View on GitHub
internal/plugin/connectors/http/proxy_service.go

Summary

Maintainability
A
0 mins
Test Coverage
C
71%
package http

import (
    "crypto/tls"
    "crypto/x509"
    "fmt"
    "io"
    "io/ioutil"
    "net"
    gohttp "net/http"
    "os"
    "regexp"

    "github.com/cyberark/secretless-broker/pkg/secretless/plugin/connector/http"
    validation "github.com/go-ozzo/ozzo-validation"

    "github.com/cyberark/secretless-broker/internal"
    "github.com/cyberark/secretless-broker/pkg/secretless/log"
)

// Subservice handles specific traffic within an HTTP proxy service, using
// traffic filtering rules and a devoted Connector.
type Subservice struct {

    // NOTE: This existence of both "ConnectorID" and "Authenticate" here
    // indicates a deeper problem: The concept of "connector" probably should
    // have included both ID and "connector function" together, as a single
    // entity.  That feels like the right abstraction, though the costs of not
    // having it are minimal so far. This is something we should keep an eye on
    // and refactor if it comes up again.

    Connector                http.Connector
    ConnectorID              string
    RetrieveCredentials      internal.CredentialsRetriever
    AuthenticateURLsMatching []*regexp.Regexp
}

// Matches returns true if any of the patterns in the Subservice's
// AuthenticateURLsMatching match the given url.
func (sub *Subservice) Matches(url string) bool {
    for _, pattern := range sub.AuthenticateURLsMatching {
        if pattern.MatchString(url) {
            return true
        }
    }
    return false
}

// NewProxyService create a new HTTP proxy service.
func NewProxyService(
    subservices []Subservice,
    sharedListener net.Listener,
    logger log.Logger,
) (internal.Service, error) {

    // Parameter validation

    errors := validation.Errors{}
    if len(subservices) == 0 {
        errors["subservices"] = fmt.Errorf("subservices cannot be empty")
    }
    if sharedListener == nil {
        errors["sharedListener"] = fmt.Errorf("sharedListener cannot be nil")
    }
    if logger == nil {
        errors["logger"] = fmt.Errorf("logger cannot be nil")
    }
    if err := errors.Filter(); err != nil {
        return nil, err
    }
    // prevents IDE "possible nil dereference" warnings below -- better way?
    logger = logger.(log.Logger)

    // Create the http.Transport

    // TODO: Explanation of why we have to do this.  Add ability for user
    //   to override the default pool.
    caCertPool, err := x509.SystemCertPool()
    if err != nil {
        msg := "Error '%s' loading system cert pool; using an empty cert pool"
        logger.Warnf(msg, err)
        caCertPool = x509.NewCertPool()
    }

    if caBundle, ok := os.LookupEnv("SECRETLESS_HTTP_CA_BUNDLE"); ok {
        // Read in the cert file
        certs, err := ioutil.ReadFile(caBundle)
        if err != nil {
            return nil, fmt.Errorf("failed to append SECRETLESS_HTTP_CA_BUNDLE to RootCAs: %v", err)
        }

        // Append our cert to the system pool
        if ok := caCertPool.AppendCertsFromPEM(certs); !ok {
            logger.Warnf("No certs appended, using system certs only")
        }
    }

    transport := &gohttp.Transport{
        TLSClientConfig: &tls.Config{
            RootCAs:    caCertPool,
            MinVersion: tls.VersionTLS12,
        },
    }

    return &proxyService{
        transport:   transport,
        subservices: subservices,
        listener:    sharedListener,
        logger:      logger,
        done:        false,
    }, nil
}

type proxyService struct {
    transport   *gohttp.Transport
    done        bool
    listener    net.Listener
    logger      log.Logger
    subservices []Subservice
}

func (proxy *proxyService) matchingSubservices(r *gohttp.Request) []Subservice {
    var matchingSubs []Subservice
    for _, sub := range proxy.subservices {
        if sub.Matches(r.URL.String()) {
            matchingSubs = append(matchingSubs, sub)
        }
    }
    return matchingSubs
}

// selectSubservice finds a subservice matching the request, and issues warnings
// when there are no matches or more than one match.
func (proxy *proxyService) selectSubservice(r *gohttp.Request) *Subservice {
    matchingSubs := proxy.matchingSubservices(r)

    // No match: Warn!
    if len(matchingSubs) == 0 {
        msg := "No subservices matched request '%s'"
        proxy.logger.Warnf(msg, r.URL.Host)
        return nil
    }

    // Multiple matches: Warn!
    if len(matchingSubs) > 1 {
        msg := "Multiple subservices matched request '%s': %v\n"
        proxy.logger.Warnf(msg, r.URL.Host, matchingSubs)
    }

    // Select first (or only) match
    subservice := matchingSubs[0]
    msg := "Using connector '%s' for request %s"
    proxy.logger.Debugf(msg, subservice.ConnectorID, r.URL.Host)
    return &subservice
}

// ServeHTTP exists to implement the go_http.Handler interface
func (proxy *proxyService) ServeHTTP(w gohttp.ResponseWriter, r *gohttp.Request) {
    logger := proxy.logger

    // Log request
    logMsg := "Got request %v %v %v %v"
    logger.Debugf(logMsg, r.URL.Path, r.Host, r.Method, r.URL.Hostname())

    // Validate request

    if !proxy.validateProxyServerRules(w, r) {
        return
    }

    // Remove headers intended only for proxy server.

    r.Header.Del("Proxy-Connection")
    r.Header.Del("Proxy-Authenticate")
    r.Header.Del("Proxy-Authorization")

    // Select a subservice

    subservice := proxy.selectSubservice(r)

    // No match: Send request with no authentication.
    if subservice == nil {
        proxy.handleRequest(w, r)
        return
    }

    // Get current credential values

    creds, err := subservice.RetrieveCredentials()
    defer internal.ZeroizeCredentials(creds)

    if err != nil {
        gohttp.Error(w, err.Error(), 500)
        return
    }

    // Authenticate request

    err = subservice.Connector.Connect(r, creds)
    if err != nil {
        gohttp.Error(w, err.Error(), 500)
        return
    }

    // Send request to target service

    proxy.handleRequest(w, r)
}

// validateProxyServerRules ensures that the request being made is a valid
// request for a proxy server.
func (proxy *proxyService) validateProxyServerRules(
    w gohttp.ResponseWriter, r *gohttp.Request,
) bool {

    if r.Method == "CONNECT" {
        gohttp.Error(w, "CONNECT is not supported.", 405)
        return false
    }

    if !r.URL.IsAbs() {
        errMsg := "This is a proxy server. Non-proxy requests aren't allowed."
        gohttp.Error(w, errMsg, 500)
        return false
    }

    return true
}

// handleRequest sends the request to the target service and writes the response
// back to the client.
func (proxy *proxyService) handleRequest(
    w gohttp.ResponseWriter, r *gohttp.Request,
) {
    logger := proxy.logger

    // Per the stdlib docs, "It is an error to set this field in an HTTP client
    // request". Therefore, we ensure it is empty in case the client set it.
    r.RequestURI = ""

    // Send request to target service

    resp, err := proxy.transport.RoundTrip(r)
    if err != nil {
        logger.Debugf("Error: %v\n", err)
        gohttp.Error(w, err.Error(), 503)
        return
    }

    // Send response to client (everything below)

    logger.Debugf("Received response status: %s\n", resp.Status)

    copyHeaders(w.Header(), resp.Header)

    w.WriteHeader(resp.StatusCode)

    _, err = io.Copy(w, resp.Body)
    if err != nil {
        logger.Errorf("Can't write response to body: %s\n", err)
    }

    err = resp.Body.Close()
    if err != nil {
        logger.Debugf("Can't close response body %v\n", err)
    }
}

// Start initiates the net.Listener to listen for incoming connections
func (proxy *proxyService) Start() error {
    logger := proxy.logger

    logger.Infof("Starting service")

    if proxy.done {
        return fmt.Errorf("cannot call Start on stopped ProxyService")
    }

    // We need a Go routine here because http.Serve() is blocking, but this
    // Start() method shouldn't be.
    go func() {
        err := gohttp.Serve(proxy.listener, proxy)
        if err != nil && !proxy.done {
            logger.Errorf("proxy service failed on server: %s", err)
            return
        }
    }()

    return nil
}

// Stop terminates proxyService by closing the listening net.Listener
func (proxy *proxyService) Stop() error {
    proxy.logger.Infof("Stopping service")
    proxy.done = true
    return proxy.listener.Close()
}

// Attribution: https://github.com/elazarl/goproxy/blob/de25c6ed252fdc01e23dae49d6a86742bd790b12/proxy.go#L74
func copyHeaders(dst, src gohttp.Header) {
    for k := range dst {
        dst.Del(k)
    }
    for k, vs := range src {
        for _, v := range vs {
            dst.Add(k, v)
        }
    }
}