diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 00000000..2a5a27d2 --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,16 @@ +{ + // Use IntelliSense to learn about possible attributes. + // Hover to view descriptions of existing attributes. + // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 + "version": "0.2.0", + "configurations": [ + { + "name": "smokescreen", + "type": "go", + "request": "launch", + "mode": "auto", + "program": "./", + "args": ["--config-file", "config.yaml", "--egress-acl-file", "acl.yaml"] + } + ] +} \ No newline at end of file diff --git a/Development.md b/Development.md new file mode 100644 index 00000000..995c92d8 --- /dev/null +++ b/Development.md @@ -0,0 +1,270 @@ + +# Development and Testing + +## Testing +```bash +go test ./... +``` + +## Running locally + +This section describes how to run Smokescreen locally with different scenarios and using `curl` as a client. + +- [HTTP CONNECT Proxy](#http-connect-proxy) +- [Monitor metrics Smokescreen emits](#monitor-metrics-smokescreen-emits) +- [HTTP CONNECT Proxy over TLS](#http-connect-proxy-over-tls) +- [MITM (Man in the middle) Proxy](#mitm-man-in-the-middle-proxy) +- [MITM (Man in the middle) Proxy over TLS](#mitm-man-in-the-middle-proxy-over-tls) + +### HTTP CONNECT Proxy + +#### Configurations + +```yaml +# config.yaml +--- +allow_missing_role: true # skip mTLS client validation (use default ACL) +``` + +```yaml +# acl.yaml +--- +version: v1 +services: [] +default: + name: default + project: security + action: enforce + allowed_domains: + - api.github.com +``` + +#### Run + +```bash +# Run smokescreen (in a different shell) +go run . --config-file config.yaml --egress-acl-file acl.yaml + +# Curl +curl --proxytunnel -x localhost:4750 https://api.github.com/zen +# Curl with HTTPS_PROXY +HTTPS_PROXY=localhost:4750 curl https://api.github.com/zen +``` + +### Monitor metrics Smokescreen emits + +#### Configurations + +```yaml +# config.yaml +--- +allow_missing_role: true # skip mTLS client validation (use default ACL) +statsd_address: 127.0.0.1:8200 +``` + +```yaml +# acl.yaml +--- +version: v1 +services: [] +default: + name: default + project: security + action: enforce + allowed_domains: + - api.github.com +``` + +#### Run + +```bash +# Listen to a local port with nc (in a different shell) +nc -uklv 127.0.0.1 8200 + +# Run smokescreen (in a different shell) +go run . --config-file config.yaml --egress-acl-file acl.yaml + +# Curl +curl --proxytunnel -x localhost:4750 https://api.github.com/zen +# Curl with HTTPS_PROXY +HTTPS_PROXY=localhost:4750 curl https://api.github.com/zen +``` + +### HTTP CONNECT Proxy over TLS + +#### Set-up + +##### Generate certificates +```bash +mkdir -p mtls_setup +# Private keys for CAs +openssl genrsa -out mtls_setup/server-ca.key 2048 +openssl genrsa -out mtls_setup/client-ca.key 2048 + +# Generate client and server CA certificates +openssl req -new -x509 -nodes -days 1000 -key mtls_setup/server-ca.key -out mtls_setup/server-ca.crt \ + -subj "/C=AQ/ST=Petrel Island/L=Dumont-d'Urville +/O=Penguin/OU=Publishing house/CN=server CA" + +openssl req -new -x509 -nodes -days 1000 -key mtls_setup/client-ca.key -out mtls_setup/client-ca.crt \ + -subj "/C=MA/ST=Tarfaya/L=Tarfaya/O=Fennec/OU=Aviator/CN=Client CA" + +# Generate a certificate signing request (client CN is localhost which is used by smokescreen as the service name by default) +openssl req -newkey rsa:2048 -nodes -keyout mtls_setup/server.key -out mtls_setup/server.req \ + -subj "/C=AQ/ST=Petrel Island/L=Dumont-d'Urville/O=Chionis/OU=Publishing house/CN=server req" +openssl req -newkey rsa:2048 -nodes -keyout mtls_setup/client.key -out mtls_setup/client.req \ + -subj "/C=MA/ST=Tarfaya/L=Tarfaya/O=Addax/OU=Writer/CN=localhost" + +# Have the CA sign the certificate requests and output the certificates. +echo "authorityKeyIdentifier=keyid,issuer +basicConstraints=CA:FALSE +keyUsage = digitalSignature, nonRepudiation, keyEncipherment, dataEncipherment +subjectAltName = @alt_names + +[alt_names] +DNS.1 = localhost +" > mtls_setup/localhost.ext + +openssl x509 -req -in mtls_setup/server.req -days 1000 -CA mtls_setup/server-ca.crt -CAkey mtls_setup/server-ca.key -set_serial 01 -out mtls_setup/server.crt -extfile mtls_setup/localhost.ext + +openssl x509 -req -in mtls_setup/client.req -days 1000 -CA mtls_setup/client-ca.crt -CAkey mtls_setup/client-ca.key -set_serial 01 -out mtls_setup/client.crt +``` + +##### Configurations + +```yaml +# config.yaml +--- +tls: + cert_file: "mtls_setup/server.crt" + key_file: "mtls_setup/server.key" + client_ca_files: + - "mtls_setup/client-ca.crt" +``` + +```yaml +# acl.yaml +--- +version: v1 +services: + - name: localhost + project: github + action: enforce + allowed_domains: + - api.github.com +default: + name: default + project: security + action: enforce + allowed_domains: [] +``` + +#### Run + +```bash +# Run smokescreen (in a different shell) +go run . --config-file config.yaml --egress-acl-file acl.yaml + +# Curl +curl --proxytunnel -x https://localhost:4750 --proxy-cacert mtls_setup/server-ca.crt --proxy-cert mtls_setup/client.crt --proxy-key mtls_setup/client.key https://api.github.com/zen +# Curl with HTTPS_PROXY +HTTPS_PROXY=https://localhost:4750 curl --proxy-cacert mtls_setup/server-ca.crt --proxy-cert mtls_setup/client.crt --proxy-key mtls_setup/client.key https://api.github.com/zen +``` + +### MITM (Man in the middle) Proxy + +#### Set-up + +```yaml +# config.yaml +--- +allow_missing_role: true # skip mTLS client validation (use default ACL) +# Re-using goproxy library CA and key +mitm_ca_cert_file: "vendor/github.com/stripe/goproxy/ca.pem" +mitm_ca_key_file: "vendor/github.com/stripe/goproxy/key.pem" +``` + +```yaml +# acl.yaml +--- +version: v1 +services: [] +default: + name: default + project: security + action: enforce + allowed_domains: [] + allowed_domains_mitm: + - domain: wttr.in + add_headers: + Accept-Language: el + detailed_http_logs: true + detailed_http_logs_full_headers: + - User-Agent +``` + +#### Run + +```bash +# Run smokescreen (in a different shell) +go run . --config-file config.yaml --egress-acl-file acl.yaml + +# Curl (weather should be in Greek since we set the Accept-Language header) +curl --proxytunnel -x localhost:4750 --cacert vendor/github.com/stripe/goproxy/ca.pem https://wttr.in +# Curl with HTTPS_PROXY +HTTPS_PROXY=localhost:4750 curl --cacert vendor/github.com/stripe/goproxy/ca.pem https://wttr.in +``` + +### MITM (Man in the middle) Proxy over TLS + +#### Set-up + +Please generate the certificates from the TLS Generate certificates section. + +```yaml +# config.yaml +--- +tls: + cert_file: "mtls_setup/server.crt" + key_file: "mtls_setup/server.key" + client_ca_files: + - "mtls_setup/client-ca.crt" +# Re-using goproxy library CA and key +mitm_ca_cert_file: "vendor/github.com/stripe/goproxy/ca.pem" +mitm_ca_key_file: "vendor/github.com/stripe/goproxy/key.pem" +``` + +```yaml +# acl.yaml +--- +version: v1 +services: + - name: localhost + project: github + action: enforce + allowed_domains: [] + allowed_domains_mitm: + - domain: wttr.in + add_headers: + Accept-Language: el + detailed_http_logs: true + detailed_http_logs_full_headers: + - User-Agent +default: + name: default + project: security + action: enforce + allowed_domains: [] +``` + +#### Run + +```bash +# Run smokescreen (in a different shell) +go run . --config-file config.yaml --egress-acl-file acl.yaml + +# Curl (weather should be in Greek since we set the Accept-Language header) +curl --proxytunnel -x https://localhost:4750 --cacert vendor/github.com/stripe/goproxy/ca.pem --proxy-cacert mtls_setup/server-ca.crt --proxy-cert mtls_setup/client.crt --proxy-key mtls_setup/client.key https://wttr.in +# Curl with HTTPS_PROXY +HTTPS_PROXY=https://localhost:4750 curl --cacert vendor/github.com/stripe/goproxy/ca.pem --proxy-cacert mtls_setup/server-ca.crt --proxy-cert mtls_setup/client.crt --proxy-key mtls_setup/client.key https://wttr.in +``` diff --git a/README.md b/README.md index ef7f5cbb..b0624f8f 100644 --- a/README.md +++ b/README.md @@ -171,41 +171,7 @@ If a domain matches both the `global_allow_list` and the `global_deny_list`, the # Development and Testing -## Running locally - -To run Smokescreen locally, you can provide a minimal configuration file and use `curl` as a client. For example: - -```yaml -# config.yaml ---- -allow_missing_role: true # skip mTLS client validation -statsd_address: 127.0.0.1:8200 -``` - -If you want to see metrics Smokescreen emits, listen on a local port: - -```shellsession -$ nc -uklv 127.0.0.1 8200 -``` - -Build and run Smokescreen: - -```shellsession -$ go run . --config-file config.yaml -{"level":"info","msg":"starting","time":"2022-11-30T15:19:08-08:00"} -``` - -Make a request using `curl`: - -```shellsession -$ curl --proxytunnel -x localhost:4750 https://stripe.com/ -``` - -## Testing - -```shellsession -$ go test ./... -``` +See [Development.md](Development.md) # Contributors diff --git a/pkg/smokescreen/acl/v1/acl.go b/pkg/smokescreen/acl/v1/acl.go index f5bf3a4a..6540cdc8 100644 --- a/pkg/smokescreen/acl/v1/acl.go +++ b/pkg/smokescreen/acl/v1/acl.go @@ -25,14 +25,27 @@ type Rule struct { Project string Policy EnforcementPolicy DomainGlobs []string + DomainMitmGlobs []MitmDomain ExternalProxyGlobs []string } +type MitmDomain struct { + MitmConfig + Domain string +} + +type MitmConfig struct { + AddHeaders map[string]string + DetailedHttpLogs bool + DetailedHttpLogsFullHeaders []string +} + type Decision struct { - Reason string - Default bool - Result DecisionResult - Project string + Reason string + Default bool + Result DecisionResult + Project string + MitmConfig *MitmConfig } func New(logger *logrus.Logger, loader Loader, disabledActions []string) (*ACL, error) { @@ -68,7 +81,7 @@ func (acl *ACL) Add(svc string, r Rule) error { return err } - err = acl.ValidateDomainGlobs(svc, r.DomainGlobs) + err = acl.ValidateRuleDomainsGlobs(svc, r) if err != nil { return err } @@ -126,6 +139,15 @@ func (acl *ACL) Decide(service, host, connectProxyHost string) (Decision, error) } } + // if the host matches any of the rule's allowed domains with MITM config, allow + for _, dg := range rule.DomainMitmGlobs { + if HostMatchesGlob(host, dg.Domain) { + d.Result, d.Reason = Allow, "host matched allowed domain in rule" + d.MitmConfig = (*MitmConfig)(&dg.MitmConfig) + return d, nil + } + } + // if the host matches any of the global deny list, deny for _, dg := range acl.GlobalDenyList { if HostMatchesGlob(host, dg) { @@ -180,7 +202,7 @@ func (acl *ACL) DisablePolicies(actions []string) error { // and is not utilizing a disabled enforcement policy. func (acl *ACL) Validate() error { for svc, r := range acl.Rules { - err := acl.ValidateDomainGlobs(svc, r.DomainGlobs) + err := acl.ValidateRuleDomainsGlobs(svc, r) if err != nil { return err } @@ -192,6 +214,22 @@ func (acl *ACL) Validate() error { return nil } +func (acl *ACL) ValidateRuleDomainsGlobs(svc string, r Rule) error { + err := acl.ValidateDomainGlobs(svc, r.DomainGlobs) + if err != nil { + return err + } + mitmDomainGlobs := make([]string, len(r.DomainMitmGlobs)) + for i, d := range r.DomainMitmGlobs { + mitmDomainGlobs[i] = d.Domain + } + err = acl.ValidateDomainGlobs(svc, mitmDomainGlobs) + if err != nil { + return err + } + return nil +} + // ValidateDomainGlobs takes a slice of domain globs and verifies they conform to smokescreen's // domain glob policy. // diff --git a/pkg/smokescreen/acl/v1/acl_test.go b/pkg/smokescreen/acl/v1/acl_test.go index 9539458a..c299e700 100644 --- a/pkg/smokescreen/acl/v1/acl_test.go +++ b/pkg/smokescreen/acl/v1/acl_test.go @@ -358,3 +358,29 @@ func TestHostMatchesGlob(t *testing.T) { }) } } + +func TestMitmComfig(t *testing.T) { + a := assert.New(t) + + yl := NewYAMLLoader(path.Join("testdata", "mitm_config.yaml")) + acl, err := New(logrus.New(), yl, []string{}) + + a.NoError(err) + a.NotNil(acl) + + mitmService := "enforce-dummy-mitm-srv" + + proj, err := acl.Project(mitmService) + a.NoError(err) + a.Equal("usersec", proj) + + d, err := acl.Decide(mitmService, "example-mitm.com", "") + a.NoError(err) + a.Equal(Allow, d.Result) + a.Equal("host matched allowed domain in rule", d.Reason) + + a.NotNil(d.MitmConfig) + a.Equal(true, d.MitmConfig.DetailedHttpLogs) + a.Equal([]string{"User-Agent"}, d.MitmConfig.DetailedHttpLogsFullHeaders) + a.Equal(map[string]string{"Accept-Language": "el"}, d.MitmConfig.AddHeaders) +} diff --git a/pkg/smokescreen/acl/v1/testdata/mitm_config.yaml b/pkg/smokescreen/acl/v1/testdata/mitm_config.yaml new file mode 100644 index 00000000..b9fde18f --- /dev/null +++ b/pkg/smokescreen/acl/v1/testdata/mitm_config.yaml @@ -0,0 +1,23 @@ +--- +version: v1 +services: + - name: enforce-dummy-mitm-srv + project: usersec + action: enforce + allowed_domains: + - examplea.com + - exampleb.com + allowed_domains_mitm: + - domain: example-mitm.com + add_headers: + Accept-Language: el + detailed_http_logs: true + detailed_http_logs_full_headers: + - User-Agent + +default: + project: other + action: enforce + allowed_domains: + - default.example.com + diff --git a/pkg/smokescreen/acl/v1/yaml_loader.go b/pkg/smokescreen/acl/v1/yaml_loader.go index 78bdaa27..db39803d 100644 --- a/pkg/smokescreen/acl/v1/yaml_loader.go +++ b/pkg/smokescreen/acl/v1/yaml_loader.go @@ -26,11 +26,19 @@ type YAMLConfig struct { } type YAMLRule struct { - Name string `yaml:"name"` - Project string `yaml:"project"` // owner - Action string `yaml:"action"` - AllowedHosts []string `yaml:"allowed_domains"` - AllowedExternalProxyHosts []string `yaml:"allowed_external_proxies"` + Name string `yaml:"name"` + Project string `yaml:"project"` // owner + Action string `yaml:"action"` + AllowedHosts []string `yaml:"allowed_domains"` + AllowedHostsMitm []YAMLMitmRule `yaml:"allowed_domains_mitm"` + AllowedExternalProxyHosts []string `yaml:"allowed_external_proxies"` +} + +type YAMLMitmRule struct { + Domain string `yaml:"domain"` + AddHeaders map[string]string `yaml:"add_headers"` + DetailedHttpLogs bool `yaml:"detailed_http_logs"` + DetailedHttpLogsFullHeaders []string `yaml:"detailed_http_logs_full_headers"` } func (yc *YAMLConfig) ValidateConfig() error { @@ -78,10 +86,25 @@ func (cfg *YAMLConfig) Load() (*ACL, error) { return nil, err } + var allowedHostsMitm []MitmDomain + + for _, w := range v.AllowedHostsMitm { + mitmDomain := MitmDomain{ + MitmConfig: MitmConfig{ + AddHeaders: w.AddHeaders, + DetailedHttpLogs: w.DetailedHttpLogs, + DetailedHttpLogsFullHeaders: w.DetailedHttpLogsFullHeaders, + }, + Domain: w.Domain, + } + allowedHostsMitm = append(allowedHostsMitm, mitmDomain) + } + r := Rule{ Project: v.Project, Policy: p, DomainGlobs: v.AllowedHosts, + DomainMitmGlobs: allowedHostsMitm, ExternalProxyGlobs: v.AllowedExternalProxyHosts, } @@ -97,10 +120,25 @@ func (cfg *YAMLConfig) Load() (*ACL, error) { return nil, err } + var allowedHostsMitm []MitmDomain + + for _, w := range cfg.Default.AllowedHostsMitm { + mitmDomain := MitmDomain{ + MitmConfig: MitmConfig{ + AddHeaders: w.AddHeaders, + DetailedHttpLogs: w.DetailedHttpLogs, + DetailedHttpLogsFullHeaders: w.DetailedHttpLogsFullHeaders, + }, + Domain: w.Domain, + } + allowedHostsMitm = append(allowedHostsMitm, mitmDomain) + } + acl.DefaultRule = &Rule{ Project: cfg.Default.Project, Policy: p, DomainGlobs: cfg.Default.AllowedHosts, + DomainMitmGlobs: allowedHostsMitm, ExternalProxyGlobs: cfg.Default.AllowedExternalProxyHosts, } } diff --git a/pkg/smokescreen/config.go b/pkg/smokescreen/config.go index bf60aa4a..bb074774 100644 --- a/pkg/smokescreen/config.go +++ b/pkg/smokescreen/config.go @@ -73,7 +73,7 @@ type Config struct { TransportMaxIdleConns int TransportMaxIdleConnsPerHost int - // These are the http and https address for the upstream proxy + // These are the http and https address for the upstream proxy UpstreamHttpProxyAddr string UpstreamHttpsProxyAddr string @@ -99,6 +99,8 @@ type Config struct { // If smokescreen denies a request, this handler is not called. // If the handler returns an error, smokescreen will deny the request. PostDecisionRequestHandler func(*http.Request) error + // MitmCa is used to provide a custom CA for MITM + MitmCa *tls.Certificate } type missingRoleError struct { diff --git a/pkg/smokescreen/config_loader.go b/pkg/smokescreen/config_loader.go index b3f608be..b64c05a7 100644 --- a/pkg/smokescreen/config_loader.go +++ b/pkg/smokescreen/config_loader.go @@ -1,6 +1,8 @@ package smokescreen import ( + "crypto/tls" + "crypto/x509" "errors" "fmt" "io/ioutil" @@ -48,7 +50,9 @@ type yamlConfig struct { Tls *yamlConfigTls // Currently not configurable via YAML: RoleFromRequest, Log, DisabledAclPolicyActions - UnsafeAllowPrivateRanges bool `yaml:"unsafe_allow_private_ranges"` + UnsafeAllowPrivateRanges bool `yaml:"unsafe_allow_private_ranges"` + MitmCaCertFile string `yaml:"mitm_ca_cert_file"` + MitmCaKeyFile string `yaml:"mitm_ca_key_file"` } func (c *Config) UnmarshalYAML(unmarshal func(interface{}) error) error { @@ -151,6 +155,23 @@ func (c *Config) UnmarshalYAML(unmarshal func(interface{}) error) error { c.TimeConnect = yc.TimeConnect c.UnsafeAllowPrivateRanges = yc.UnsafeAllowPrivateRanges + if yc.MitmCaCertFile != "" || yc.MitmCaKeyFile != "" { + if yc.MitmCaCertFile == "" { + return errors.New("mitm_ca_cert_file required when mitm_ca_key_file is set") + } + if yc.MitmCaKeyFile == "" { + return errors.New("mitm_ca_key_file required when mitm_ca_cert_file is set") + } + mitmCa, err := tls.LoadX509KeyPair(yc.MitmCaCertFile, yc.MitmCaKeyFile) + if err != nil { + return fmt.Errorf("could not load mitmCa: %v", err) + } + if mitmCa.Leaf, err = x509.ParseCertificate(mitmCa.Certificate[0]); err != nil { + return fmt.Errorf("could not populate x509 Leaf value: %v", err) + } + c.MitmCa = &mitmCa + } + return nil } diff --git a/pkg/smokescreen/smokescreen.go b/pkg/smokescreen/smokescreen.go index 831e1dcf..592ef3dc 100644 --- a/pkg/smokescreen/smokescreen.go +++ b/pkg/smokescreen/smokescreen.go @@ -60,6 +60,9 @@ const ( CanonicalProxyDecision = "CANONICAL-PROXY-DECISION" LogFieldConnEstablishMS = "conn_establish_time_ms" LogFieldDNSLookupTime = "dns_lookup_time_ms" + LogMitmReqUrl = "mitm_req_url" + LogMitmReqMethod = "mitm_req_method" + LogMitmReqHeaders = "mitm_req_headers" ) type ipType int @@ -69,6 +72,7 @@ type ACLDecision struct { ResolvedAddr *net.TCPAddr allow bool enforceWouldDeny bool + MitmConfig *acl.MitmConfig } type SmokescreenContext struct { @@ -314,9 +318,20 @@ func dialContext(ctx context.Context, network, addr string) (net.Conn, error) { } sctx.logger = sctx.logger.WithFields(fields) - // Only wrap CONNECT conns with an InstrumentedConn. Connections used for traditional HTTP proxy + // Only wrap CONNECT conns and MITM http conns with an InstrumentedConn. Connections used for traditional HTTP proxy // requests are pooled and reused by net.Transport. - if sctx.proxyType == connectProxy { + if sctx.proxyType == connectProxy || pctx.ConnectAction == goproxy.ConnectMitm { + // If we have a MITM and option is enabled, we can add detailed Request log fields + if pctx.ConnectAction == goproxy.ConnectMitm && sctx.Decision.MitmConfig != nil && sctx.Decision.MitmConfig.DetailedHttpLogs { + fields := logrus.Fields{ + LogMitmReqUrl: pctx.Req.URL.String(), + LogMitmReqMethod: pctx.Req.Method, + LogMitmReqHeaders: redactHeaders(pctx.Req.Header, sctx.Decision.MitmConfig.DetailedHttpLogsFullHeaders), + } + + sctx.logger = sctx.logger.WithFields(fields) + + } ic := sctx.cfg.ConnTracker.NewInstrumentedConnWithTimeout(conn, sctx.cfg.IdleTimeout, sctx.logger, d.role, d.outboundHost, sctx.proxyType) pctx.ConnErrorHandler = ic.Error conn = ic @@ -455,6 +470,13 @@ func BuildProxy(config *Config) *goproxy.ProxyHttpServer { // Handle traditional HTTP proxy proxy.OnRequest().DoFunc(func(req *http.Request, pctx *goproxy.ProxyCtx) (*http.Request, *http.Response) { + // Set this on every request as every request mints a new goproxy.ProxyCtx + pctx.RoundTripper = rtFn + + // For MITM requests intended for the remote host, the sole requirement was to configure the RoundTripper + if pctx.ConnectAction == goproxy.ConnectMitm { + return req, nil + } // We are intentionally *not* setting pctx.HTTPErrorHandler because with traditional HTTP // proxy requests we are able to specify the request during the call to OnResponse(). @@ -469,9 +491,7 @@ func BuildProxy(config *Config) *goproxy.ProxyHttpServer { req.Header.Del(traceHeader) }() - // Set this on every request as every request mints a new goproxy.ProxyCtx - pctx.RoundTripper = rtFn - + sctx.logger.WithField("url", req.RequestURI).Debug("received HTTP proxy request") // Build an address parsable by net.ResolveTCPAddr destination, err := hostport.NewWithScheme(req.Host, req.URL.Scheme, false) if err != nil { @@ -479,7 +499,6 @@ func BuildProxy(config *Config) *goproxy.ProxyHttpServer { return req, rejectResponse(pctx, pctx.Error) } - sctx.logger.WithField("url", req.RequestURI).Debug("received HTTP proxy request") sctx.Decision, sctx.lookupTime, pctx.Error = checkIfRequestShouldBeProxied(config, req, destination) // Returning any kind of response in this handler is goproxy's way of short circuiting @@ -512,15 +531,15 @@ func BuildProxy(config *Config) *goproxy.ProxyHttpServer { // Defer logging the proxy event here because logProxy relies // on state set in handleConnect - defer logProxy(config, pctx) + defer logProxy(pctx) defer pctx.Req.Header.Del(traceHeader) - destination, err := handleConnect(config, pctx) + connectAction, destination, err := handleConnect(config, pctx) if err != nil { pctx.Resp = rejectResponse(pctx, err) return goproxy.RejectConnect, "" } - return goproxy.OkConnect, destination + return connectAction, destination }) // Strangely, goproxy can invoke this same function twice for a single HTTP request. @@ -552,9 +571,17 @@ func BuildProxy(config *Config) *goproxy.ProxyHttpServer { return rejectResponse(pctx, pctx.Error) } - // In case of an error, this function is called a second time to filter the - // response we generate so this logger will be called once. - logProxy(config, pctx) + if pctx.ConnectAction == goproxy.ConnectMitm { + // If the connection is a MITM + // 1 we don't want to log as it will be done in HandleConnectFunc + // 2 we want to close idle connections as they are not closed by default + // and CANONICAL-PROXY-CN-CLOSE is called on InstrumentedConn.Close + proxy.Tr.CloseIdleConnections() + } else { + // In case of an error, this function is called a second time to filter the + // response we generate so this logger will be called once. + logProxy(pctx) + } return resp }) @@ -573,32 +600,11 @@ func BuildProxy(config *Config) *goproxy.ProxyHttpServer { return proxy } -func logProxy(config *Config, pctx *goproxy.ProxyCtx) { +func logProxy(pctx *goproxy.ProxyCtx) { sctx := pctx.UserData.(*SmokescreenContext) fields := logrus.Fields{} - - // attempt to retrieve information about the host originating the proxy request - if pctx.Req.TLS != nil && len(pctx.Req.TLS.PeerCertificates) > 0 { - fields[LogFieldInRemoteX509CN] = pctx.Req.TLS.PeerCertificates[0].Subject.CommonName - var ouEntries = pctx.Req.TLS.PeerCertificates[0].Subject.OrganizationalUnit - if len(ouEntries) > 0 { - fields[LogFieldInRemoteX509OU] = ouEntries[0] - } - } - decision := sctx.Decision - if sctx.Decision != nil { - fields[LogFieldRole] = decision.role - fields[LogFieldProject] = decision.project - } - - // add the above fields to all future log messages sent using this smokescreen context's logger - sctx.logger = sctx.logger.WithFields(fields) - - // start a new set of fields used only in this log message - fields = logrus.Fields{} - // If a lookup takes less than 1ms it will be rounded down to zero. This can separated from // actual failures where the default zero value will also have the error field set. fields[LogFieldDNSLookupTime] = sctx.lookupTime.Milliseconds() @@ -630,14 +636,35 @@ func logProxy(config *Config, pctx *goproxy.ProxyCtx) { logMethod(CanonicalProxyDecision) } -func handleConnect(config *Config, pctx *goproxy.ProxyCtx) (string, error) { +func extractContextLogFields(pctx *goproxy.ProxyCtx, sctx *SmokescreenContext) logrus.Fields { + fields := logrus.Fields{} + + // attempt to retrieve information about the host originating the proxy request + if pctx.Req.TLS != nil && len(pctx.Req.TLS.PeerCertificates) > 0 { + fields[LogFieldInRemoteX509CN] = pctx.Req.TLS.PeerCertificates[0].Subject.CommonName + var ouEntries = pctx.Req.TLS.PeerCertificates[0].Subject.OrganizationalUnit + if len(ouEntries) > 0 { + fields[LogFieldInRemoteX509OU] = ouEntries[0] + } + } + + // Retrieve information from the ACL decision + decision := sctx.Decision + if sctx.Decision != nil { + fields[LogFieldRole] = decision.role + fields[LogFieldProject] = decision.project + } + return fields +} + +func handleConnect(config *Config, pctx *goproxy.ProxyCtx) (*goproxy.ConnectAction, string, error) { sctx := pctx.UserData.(*SmokescreenContext) // Check if requesting role is allowed to talk to remote destination, err := hostport.New(pctx.Req.Host, false) if err != nil { pctx.Error = denyError{err} - return "", pctx.Error + return nil, "", pctx.Error } // checkIfRequestShouldBeProxied can return an error if either the resolved address is disallowed, @@ -646,10 +673,14 @@ func handleConnect(config *Config, pctx *goproxy.ProxyCtx) (string, error) { sctx.Decision, sctx.lookupTime, pctx.Error = checkIfRequestShouldBeProxied(config, pctx.Req, destination) if pctx.Error != nil { // DNS resolution failure - return "", pctx.Error + return nil, "", pctx.Error } + + // add context fields to all future log messages sent using this smokescreen context's logger + sctx.logger = sctx.logger.WithFields(extractContextLogFields(pctx, sctx)) + if !sctx.Decision.allow { - return "", denyError{errors.New(sctx.Decision.reason)} + return nil, "", denyError{errors.New(sctx.Decision.reason)} } // Call the custom request handler if it exists @@ -657,11 +688,40 @@ func handleConnect(config *Config, pctx *goproxy.ProxyCtx) (string, error) { err = config.PostDecisionRequestHandler(pctx.Req) if err != nil { pctx.Error = denyError{err} - return "", pctx.Error + return nil, "", pctx.Error + } + } + + connectAction := goproxy.OkConnect + // If the ACLDecision matched a MITM rule + if sctx.Decision.MitmConfig != nil { + if config.MitmCa == nil { + deny := denyError{errors.New("ACLDecision specified MITM but Smokescreen doesn't have MITM enabled")} + sctx.Decision.allow = false + sctx.Decision.MitmConfig = nil + sctx.Decision.reason = deny.Error() + return nil, "", deny + } + mitm := sctx.Decision.MitmConfig + + var mitmMutateRequest func(req *http.Request, ctx *goproxy.ProxyCtx) + + if len(mitm.AddHeaders) > 0 { + mitmMutateRequest = func(req *http.Request, ctx *goproxy.ProxyCtx) { + for k, v := range mitm.AddHeaders { + req.Header.Set(k, v) + } + } + } + + connectAction = &goproxy.ConnectAction{ + Action: goproxy.ConnectMitm, + TLSConfig: goproxy.TLSConfigFromCA(config.MitmCa), + MitmMutateRequest: mitmMutateRequest, } } - return destination.String(), nil + return connectAction, destination.String(), nil } func findListener(ip string, defaultPort uint16) (net.Listener, error) { @@ -964,6 +1024,7 @@ func checkACLsForRequest(config *Config, req *http.Request, destination hostport ACLDecision, err := config.EgressACL.Decide(role, destination.Host, connectProxyHost) decision.project = ACLDecision.Project decision.reason = ACLDecision.Reason + decision.MitmConfig = ACLDecision.MitmConfig if err != nil { config.Log.WithFields(logrus.Fields{ "error": err, @@ -1007,3 +1068,32 @@ func checkACLsForRequest(config *Config, req *http.Request, destination hostport return decision } + +func redactHeaders(originalHeaders http.Header, allowedHeaders []string) http.Header { + // Create a new map to store the redacted headers + redactedHeaders := make(http.Header) + + // Convert allowedHeaders to a map for faster lookup + allowedHeadersMap := make(map[string]bool) + for _, h := range allowedHeaders { + allowedHeadersMap[strings.ToLower(h)] = true + } + + // Iterate through the original headers + for key, values := range originalHeaders { + lowerKey := strings.ToLower(key) + if allowedHeadersMap[lowerKey] { + // If the header is in the allowed list, copy it as is + redactedHeaders[key] = values + } else { + // If not, redact the values + redactedValues := make([]string, len(values)) + for i := range values { + redactedValues[i] = "[REDACTED]" + } + redactedHeaders[key] = redactedValues + } + } + + return redactedHeaders +} diff --git a/pkg/smokescreen/smokescreen_test.go b/pkg/smokescreen/smokescreen_test.go index f9a0981f..7281ede1 100644 --- a/pkg/smokescreen/smokescreen_test.go +++ b/pkg/smokescreen/smokescreen_test.go @@ -6,6 +6,7 @@ package smokescreen import ( "context" "crypto/tls" + "crypto/x509" "errors" "fmt" "io" @@ -15,6 +16,7 @@ import ( "net/http" "net/http/httptest" "net/url" + "strings" "sync/atomic" "testing" "time" @@ -23,6 +25,7 @@ import ( logrustest "github.com/sirupsen/logrus/hooks/test" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "github.com/stripe/goproxy" "github.com/stripe/smokescreen/pkg/smokescreen/conntrack" "github.com/stripe/smokescreen/pkg/smokescreen/metrics" ) @@ -1387,6 +1390,108 @@ func TestCONNECTProxyACLs(t *testing.T) { r.Equal("host matched allowed domain in rule", second_entry.Data["decision_reason"]) }) } + +func TestMitm(t *testing.T) { + t.Run("CONNECT proxy", func(t *testing.T) { + a := assert.New(t) + r := require.New(t) + + cfg, err := testConfig("test-mitm") + r.NoError(err) + // We use the default test certificates from Goproxy + mitmCa, err := tls.X509KeyPair(goproxy.CA_CERT, goproxy.CA_KEY) + r.NoError(err) + mitmCa.Leaf, err = x509.ParseCertificate(mitmCa.Certificate[0]) + r.NoError(err) + cfg.MitmCa = &mitmCa + r.NoError(err) + err = cfg.SetAllowAddresses([]string{"127.0.0.1"}) + r.NoError(err) + + clientCh := make(chan bool) + serverCh := make(chan bool) + h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + serverCh <- true + <-serverCh + // This handlers returns a body with a string containing all the request headers it received. + var sb strings.Builder + for name, values := range r.Header { + for _, value := range values { + sb.WriteString(name) + sb.WriteString(": ") + sb.WriteString(value) + sb.WriteString(";") + } + } + io.WriteString(w, sb.String()) + w.Write([]byte(sb.String())) + }) + + logHook := proxyLogHook(cfg) + l, err := net.Listen("tcp", "localhost:0") + r.NoError(err) + cfg.Listener = l + + proxy := proxyServer(cfg) + remote := httptest.NewTLSServer(h) + client, err := proxyClient(proxy.URL) + r.NoError(err) + + req, err := http.NewRequest("GET", remote.URL, nil) + r.NoError(err) + + go func() { + resp, err := client.Do(req) + r.NoError(err) + body, err := ioutil.ReadAll(resp.Body) + r.NoError(err) + resp.Body.Close() + // We check the response body to see if the Mitm-Header-Inject header was injected by the Mitm handler + a.Contains(string(body), "Accept-Language: el") + clientCh <- true + }() + + <-serverCh + count := 0 + cfg.ConnTracker.Range(func(k, v interface{}) bool { + count++ + return true + }) + a.Equal(1, count, "connTracker should contain one tracked connection") + + serverCh <- true + <-clientCh + + // Metrics should show one successful connection and a corresponding successful + // DNS request along with its timing metric. + tmc, ok := cfg.MetricsClient.(*metrics.MockMetricsClient) + r.True(ok) + i, err := tmc.GetCount("cn.atpt.total", map[string]string{"success": "true"}) + r.NoError(err) + r.Equal(i, uint64(1)) + lookups, err := tmc.GetCount("resolver.attempts_total", make(map[string]string)) + r.NoError(err) + r.Equal(lookups, uint64(1)) + ltime, err := tmc.GetCount("resolver.lookup_time", make(map[string]string)) + r.NoError(err) + r.Equal(ltime, uint64(1)) + + proxyDecision := findCanonicalProxyDecision(logHook.AllEntries()) + r.NotNil(proxyDecision) + r.Contains(proxyDecision.Data, "proxy_type") + r.Equal("connect", proxyDecision.Data["proxy_type"]) + // check proxyclose log entry has information about the request headers + proxyClose := findCanonicalProxyClose(logHook.AllEntries()) + r.NotNil(proxyClose) + r.Equal("GET", proxyClose.Data["mitm_req_method"]) + r.Contains(proxyClose.Data["mitm_req_url"], "https://127.0.0.1") + mitmReqHeaders, ok := proxyClose.Data["mitm_req_headers"].(http.Header) + r.True(ok) + r.Equal("[REDACTED]", mitmReqHeaders.Get("Accept-Language")) + r.Equal("Go-http-client/1.1", mitmReqHeaders.Get("User-Agent")) + }) +} + func findCanonicalProxyDecision(logs []*logrus.Entry) *logrus.Entry { for _, entry := range logs { if entry.Message == CanonicalProxyDecision { diff --git a/pkg/smokescreen/testdata/acl.yaml b/pkg/smokescreen/testdata/acl.yaml index 5e7230a9..c0de1c7a 100644 --- a/pkg/smokescreen/testdata/acl.yaml +++ b/pkg/smokescreen/testdata/acl.yaml @@ -33,6 +33,16 @@ services: - myproxy.com - myproxy2.com - thisisaproxy.com + - name: test-mitm + project: security + action: enforce + allowed_domains_mitm: + - domain: 127.0.0.1 + add_headers: + Accept-Language: el + detailed_http_logs: true + detailed_http_logs_full_headers: + - User-Agent global_deny_list: - stripe.com