diff --git a/cmd/tokenizer/main.go b/cmd/tokenizer/main.go index 52ebd67..728d674 100644 --- a/cmd/tokenizer/main.go +++ b/cmd/tokenizer/main.go @@ -2,6 +2,8 @@ package main import ( "context" + "crypto/tls" + "crypto/x509" "errors" "flag" "fmt" @@ -109,6 +111,25 @@ func runServe() { opts = append(opts, tokenizer.WithFlysrcParser(parser)) } + // MITM CA certificate for HTTPS interception + mitmCertPath := os.Getenv("MITM_CA_CERT_PATH") + mitmKeyPath := os.Getenv("MITM_CA_KEY_PATH") + if mitmCertPath != "" && mitmKeyPath != "" { + cert, err := tls.LoadX509KeyPair(mitmCertPath, mitmKeyPath) + if err != nil { + logrus.WithError(err).Fatal("failed to load MITM CA certificate") + } + // Parse the x509 certificate - goproxy needs this for TLSConfigFromCA + if cert.Leaf == nil && len(cert.Certificate) > 0 { + cert.Leaf, err = x509.ParseCertificate(cert.Certificate[0]) + if err != nil { + logrus.WithError(err).Fatal("failed to parse MITM CA certificate") + } + } + opts = append(opts, tokenizer.MitmCACert(cert)) + logrus.Info("HTTPS MITM enabled - clients must trust the CA certificate") + } + tkz := tokenizer.NewTokenizer(key, opts...) if len(os.Getenv("DEBUG")) != 0 { @@ -218,12 +239,17 @@ Flags: Configuration — tokenizer is configured using the following environment variables: - OPEN_KEY - Hex encoded curve25519 private key. You can provide 32 - random, hex encoded bytes. The log output will contain - the associated public key. - LISTEN_ADDRESS - The host:port address to listen at. Default: ":8080" - FILTERED_HEADERS - Comma separated list of headers to filter from client - requests. + OPEN_KEY - Hex encoded curve25519 private key. You can provide 32 + random, hex encoded bytes. The log output will contain + the associated public key. + LISTEN_ADDRESS - The host:port address to listen at. Default: ":8080" + FILTERED_HEADERS - Comma separated list of headers to filter from client + requests. + MITM_CA_CERT_PATH - Path to CA certificate for HTTPS MITM proxying. + When set along with MITM_CA_KEY_PATH, enables interception + of HTTPS CONNECT requests to inject credentials. + Clients must trust this CA certificate. + MITM_CA_KEY_PATH - Path to CA private key for HTTPS MITM proxying. `) } diff --git a/go.mod b/go.mod index b07fdf8..e309a89 100644 --- a/go.mod +++ b/go.mod @@ -6,6 +6,7 @@ require ( github.com/alecthomas/assert/v2 v2.11.0 github.com/aws/aws-sdk-go-v2 v1.30.3 github.com/elazarl/goproxy v0.0.0-20230731152917-f99041a5c027 + github.com/icholy/replace v0.6.0 github.com/sirupsen/logrus v1.9.3 github.com/superfly/flysrc-go v0.0.3 github.com/superfly/macaroon v0.2.14-0.20240819201738-61a02aa53648 @@ -19,7 +20,6 @@ require ( github.com/google/uuid v1.3.0 // indirect github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect github.com/hexops/gotextdiff v1.0.3 // indirect - github.com/icholy/replace v0.6.0 // indirect github.com/vmihailenco/msgpack/v5 v5.3.5 // indirect github.com/vmihailenco/tagparser/v2 v2.0.0 // indirect golang.org/x/sys v0.38.0 // indirect diff --git a/go.sum b/go.sum index ffffba4..83e2d6a 100644 --- a/go.sum +++ b/go.sum @@ -14,6 +14,8 @@ github.com/elazarl/goproxy v0.0.0-20230731152917-f99041a5c027/go.mod h1:Ro8st/El github.com/elazarl/goproxy/ext v0.0.0-20190711103511-473e67f1d7d2 h1:dWB6v3RcOy03t/bUadywsbyrQwCqZeNIEX6M1OtSZOM= github.com/elazarl/goproxy/ext v0.0.0-20190711103511-473e67f1d7d2/go.mod h1:gNh8nYJoAm43RfaxurUnxr+N1PwuFV3ZMl/efxlIlY8= github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= +github.com/google/go-cmp v0.5.8 h1:e6P7q2lk1O+qJJb4BtCQXlK8vWEO8V1ZeuEdJNOqZyg= +github.com/google/go-cmp v0.5.8/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I= github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= @@ -22,6 +24,7 @@ github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUq github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg= github.com/icholy/replace v0.6.0 h1:EBiD2pGqZIOJAbEaf/5GVRaD/Pmbb4n+K3LrBdXd4dw= github.com/icholy/replace v0.6.0/go.mod h1:zzi8pxElj2t/5wHHHYmH45D+KxytX/t4w3ClY5nlK+g= +github.com/pkg/errors v0.8.1 h1:iURUrRGxPUNPdy5/HRSm+Yj6okJ6UtLINN0Q9M4+h3I= github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= @@ -62,4 +65,5 @@ golang.org/x/tools v0.0.0-20190624222133-a101b041ded4/go.mod h1:/rFqwRUd4F7ZHNgw gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c h1:dUUwHk2QECo/6vqA44rthZ8ie2QXMNeKRTHCNY2nXvo= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gotest.tools/v3 v3.0.2 h1:kG1BFyqVHuQoVQiR1bWGnfz/fmHvvuiSPIV7rvl360E= gotest.tools/v3 v3.0.2/go.mod h1:3SzNCllyD9/Y+b5r9JIKQ474KzkZyqLqEfYqMsX94Bk= diff --git a/tokenizer.go b/tokenizer.go index 5a863ee..8453a2c 100644 --- a/tokenizer.go +++ b/tokenizer.go @@ -13,6 +13,7 @@ import ( "io" "net" "net/http" + "net/url" "strings" "syscall" "time" @@ -48,6 +49,10 @@ type tokenizer struct { // OpenProxy dictates whether requests without any sealed secrets are allowed. OpenProxy bool + // MitmEnabled enables HTTPS MITM proxying with CA certificate. + // When enabled, the proxy can intercept HTTPS connections to inject credentials. + MitmEnabled bool + // RequireFlySrc will reject requests without a fly-src when set. RequireFlySrc bool @@ -77,6 +82,20 @@ func OpenProxy() Option { } } +// MitmCACert configures the CA certificate for HTTPS MITM proxying. +// When configured, the proxy can intercept HTTPS CONNECT requests and +// inject credentials into them. Clients must trust this CA certificate. +func MitmCACert(cert tls.Certificate) Option { + return func(t *tokenizer) { + t.MitmEnabled = true + goproxy.GoproxyCa = cert + goproxy.OkConnect = &goproxy.ConnectAction{Action: goproxy.ConnectMitm, TLSConfig: goproxy.TLSConfigFromCA(&cert)} + goproxy.MitmConnect = &goproxy.ConnectAction{Action: goproxy.ConnectMitm, TLSConfig: goproxy.TLSConfigFromCA(&cert)} + goproxy.HTTPMitmConnect = &goproxy.ConnectAction{Action: goproxy.ConnectHTTPMitm, TLSConfig: goproxy.TLSConfigFromCA(&cert)} + goproxy.RejectConnect = &goproxy.ConnectAction{Action: goproxy.ConnectReject, TLSConfig: nil} + } +} + // RequireFlySrc specifies that requests without a fly-src will be rejected. func RequireFlySrc() Option { return func(t *tokenizer) { @@ -168,7 +187,7 @@ func NewTokenizer(openKey string, opts ...Option) *tokenizer { }) proxy.Tr = &http.Transport{ - Dial: dialFunc(tkz.tokenizerHostnames), + Dial: tkz.dialFunc(), // probably not necessary, but I don't want to worry about desync/smuggling DisableKeepAlives: true, } @@ -217,7 +236,7 @@ func (t *tokenizer) HandleConnect(host string, ctx *goproxy.ProxyCtx) (*goproxy. } _, port, _ := strings.Cut(host, ":") - if port == "443" { + if port == "443" && !t.MitmEnabled { pud.connLog.Warn("attempt to proxy to https downstream") ctx.Resp = errorResponse(ErrBadRequest) return goproxy.RejectConnect, "" @@ -236,6 +255,11 @@ func (t *tokenizer) HandleConnect(host string, ctx *goproxy.ProxyCtx) (*goproxy. pud.connectProcessors = connectProcessors ctx.UserData = pud + // For HTTPS (port 443) with MITM enabled, use MitmConnect to do TLS interception + // For HTTP tunnels, use HTTPMitmConnect to read plaintext HTTP + if port == "443" && t.MitmEnabled { + return goproxy.MitmConnect, host + } return goproxy.HTTPMitmConnect, host } @@ -268,7 +292,7 @@ func (t *tokenizer) HandleRequest(req *http.Request, ctx *goproxy.ProxyCtx) (*ht if !ok || !pud.requestStart.IsZero() || pud.reqLog != nil { logrus.Warn("bad proxyUserData") - return nil, errorResponse(ErrInternal) + return req, errorResponse(ErrInternal) } pud.requestStart = time.Now() @@ -283,7 +307,7 @@ func (t *tokenizer) HandleRequest(req *http.Request, ctx *goproxy.ProxyCtx) (*ht if err != nil { if t.RequireFlySrc { pud.reqLog.Warn(err.Error()) - return nil, errorResponse(ErrBadRequest) + return req, errorResponse(ErrBadRequest) } } else { pud.reqLog = pud.reqLog.WithFields(logrus.Fields{ @@ -309,7 +333,7 @@ func (t *tokenizer) HandleRequest(req *http.Request, ctx *goproxy.ProxyCtx) (*ht if len(processors) == 0 && !t.OpenProxy { pud.reqLog.Warn("no processors") - return nil, errorResponse(ErrBadRequest) + return req, errorResponse(ErrBadRequest) } pud.reqLog = pud.reqLog.WithField("processors", len(processors)) @@ -317,7 +341,7 @@ func (t *tokenizer) HandleRequest(req *http.Request, ctx *goproxy.ProxyCtx) (*ht for _, processor := range processors { if err := processor(req); err != nil { pud.reqLog.WithError(err).Warn("run processor") - return nil, errorResponse(ErrBadRequest) + return req, errorResponse(ErrBadRequest) } } @@ -459,11 +483,20 @@ func errorResponse(err error) *http.Response { } return &http.Response{ + Status: http.StatusText(status), ProtoMajor: 1, ProtoMinor: 1, StatusCode: status, Body: io.NopCloser(bytes.NewReader([]byte(err.Error()))), Header: make(http.Header), + Request: &http.Request{ + Method: "CONNECT", + URL: &url.URL{}, + Proto: "HTTP/1.1", + ProtoMajor: 1, + ProtoMinor: 1, + Header: make(http.Header), + }, } } @@ -475,7 +508,8 @@ func errorResponse(err error) *http.Response { // our proxy can't do passthrough TLS. // - It forces the upstream connection to be TLS. We want the actual upstream // connection to be over TLS because security. -func dialFunc(badAddrs []string) func(string, string) (net.Conn, error) { +func (t *tokenizer) dialFunc() func(string, string) (net.Conn, error) { + badAddrs := t.tokenizerHostnames _, fdaaNet, err := net.ParseCIDR("fdaa::/8") if err != nil { panic(err) @@ -527,7 +561,12 @@ func dialFunc(badAddrs []string) func(string, string) (net.Conn, error) { } switch port { case "443": - return nil, fmt.Errorf("%w: proxied request must be HTTP", ErrBadRequest) + if !t.MitmEnabled { + return nil, fmt.Errorf("%w: proxied request must be HTTP", ErrBadRequest) + } + addr = fmt.Sprintf("%s:%s", hostname, port) + + return netDialer.Dial(network, addr) case "80", "": port = "443" }