CVE-2022-29188 (Go)

Smokescreen is an HTTP proxy. The primary use case for Smokescreen is to prevent server-side request forgery (SSRF) attacks in which external attackers leverage the behavior of applications to connect to or scan internal infrastructure. Smokescreen also offers an option to deny access to additional (e.g., external) URLs by way of a deny list. There was an issue in Smokescreen that made it possible to bypass the deny list feature by surrounding the hostname with square brackets (e.g. `[example.com]`). This only impacted the HTTP proxy functionality of Smokescreen. HTTPS requests were not impacted. Smokescreen version 0.0.4 contains a patch for this issue.

Weakness:
CWE-918: name: Server-Side Request Forgery (SSRF)
description: The web server receives a URL or similar request from an upstream component and retrieves the contents of this URL, but it does not sufficiently ensure that the request is being sent to the expected destination.

Repository: https://github.com/stripe/smokescreen

Vulnerable code:
file_path: pkg/smokescreen/smokescreen.go
snippet: func BuildProxy(config *Config) *goproxy.ProxyHttpServer {
	proxy := goproxy.NewProxyHttpServer()
	proxy.Verbose = false
	configureTransport(proxy.Tr, config)

	// dialContext will be invoked for both CONNECT and traditional proxy requests
	proxy.Tr.DialContext = dialContext

	// Use a custom goproxy.RoundTripperFunc to ensure that the correct context is attached to the request.
	// This is only used for non-CONNECT HTTP proxy requests. For connect requests, goproxy automatically
	// attaches goproxy.ProxyCtx prior to calling dialContext.
	rtFn := goproxy.RoundTripperFunc(func(req *http.Request, pctx *goproxy.ProxyCtx) (*http.Response, error) {
		ctx := context.WithValue(req.Context(), goproxy.ProxyContextKey, pctx)
		return proxy.Tr.RoundTrip(req.WithContext(ctx))
	})

	// Associate a timeout with the CONNECT proxy client connection
	if config.IdleTimeout != 0 {
		proxy.ConnectClientConnHandler = func(conn net.Conn) net.Conn {
			return NewTimeoutConn(conn, config.IdleTimeout)
		}
	}

	// Handle traditional HTTP proxy
	proxy.OnRequest().DoFunc(func(req *http.Request, pctx *goproxy.ProxyCtx) (*http.Request, *http.Response) {

		// 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().
		sctx := newContext(config, httpProxy, req)

		// Attach smokescreenContext to goproxy.ProxyCtx
		pctx.UserData = sctx

		// Delete Smokescreen specific headers before goproxy forwards the request
		defer func() {
			req.Header.Del(roleHeader)
			req.Header.Del(traceHeader)
		}()

		// Set this on every request as every request mints a new goproxy.ProxyCtx
		pctx.RoundTripper = rtFn

		// Build an address parsable by net.ResolveTCPAddr
		remoteHost := req.Host
		if strings.LastIndex(remoteHost, ":") <= strings.LastIndex(remoteHost, "]") {
			switch req.URL.Scheme {
			case "http":
				remoteHost = net.JoinHostPort(remoteHost, "80")
			case "https":
				remoteHost = net.JoinHostPort(remoteHost, "443")
			default:
				remoteHost = net.JoinHostPort(remoteHost, "0")
			}
		}

		sctx.logger.WithField("url", req.RequestURI).Debug("received HTTP proxy request")

		sctx.decision, sctx.lookupTime, pctx.Error = checkIfRequestShouldBeProxied(config, req, remoteHost)

		// Returning any kind of response in this handler is goproxy's way of short circuiting
		// the request. The original request will never be sent, and goproxy will invoke our
		// response filter attached via the OnResponse() handler.
		if pctx.Error != nil {
			return req, rejectResponse(pctx, pctx.Error)
		}
		if !sctx.decision.allow {
			return req, rejectResponse(pctx, denyError{errors.New(sctx.decision.reason)})
		}

		// Proceed with proxying the request
		return req, nil
	})

	// Handle CONNECT proxy to TLS & other TCP protocols destination
	proxy.OnRequest().HandleConnectFunc(func(host string, pctx *goproxy.ProxyCtx) (*goproxy.ConnectAction, string) {
		pctx.UserData = newContext(config, connectProxy, pctx.Req)
		pctx.HTTPErrorHandler = HTTPErrorHandler

		// Defer logging the proxy event here because logProxy relies
		// on state set in handleConnect
		defer logProxy(config, pctx)
		defer pctx.Req.Header.Del(traceHeader)

		err := handleConnect(config, pctx)
		if err != nil {
			pctx.Resp = rejectResponse(pctx, err)
			return goproxy.RejectConnect, ""
		}
		return goproxy.OkConnect, host
	})

	// Strangely, goproxy can invoke this same function twice for a single HTTP request.
	//
	// If a proxy request is rejected due to an ACL denial, the response passed to this
	// function was created by Smokescreen's call to rejectResponse() in the OnRequest()
	// handler. This only happens once. This is also the behavior for an allowed request
	// which is completed successfully.
	//
	// If a proxy request is allowed, but the RoundTripper returns an error fulfulling
	// the HTTP request, goproxy will invoke this OnResponse() filter twice. First this
	// function will be called with a nil response, and as a result this function will
	// return a response to send back to the proxy client using rejectResponse(). This
	// function will be called again with the previously returned response, which will
	// simply trigger the logHTTP function and return.
	proxy.OnResponse().DoFunc(func(resp *http.Response, pctx *goproxy.ProxyCtx) *http.Response {
		sctx := pctx.UserData.(*smokescreenContext)

		if resp != nil && resp.Header.Get(errorHeader) != "" {
			if pctx.Error == nil && sctx.decision.allow {
				resp.Header.Del(errorHeader)
			}
		}

		if resp == nil && pctx.Error != nil {
			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)
		return resp
	})
	return proxy
}

file_path: pkg/smokescreen/smokescreen.go
snippet: func handleConnect(config *Config, pctx *goproxy.ProxyCtx) error {
	sctx := pctx.UserData.(*smokescreenContext)

	// Check if requesting role is allowed to talk to remote
	sctx.decision, sctx.lookupTime, pctx.Error = checkIfRequestShouldBeProxied(config, pctx.Req, pctx.Req.Host)
	if pctx.Error != nil {
		return pctx.Error
	}
	if !sctx.decision.allow {
		return denyError{errors.New(sctx.decision.reason)}
	}

	return nil
}

Fixed code:
file_path: pkg/smokescreen/smokescreen.go
snippet: func BuildProxy(config *Config) *goproxy.ProxyHttpServer {
	proxy := goproxy.NewProxyHttpServer()
	proxy.Verbose = false
	configureTransport(proxy.Tr, config)

	// dialContext will be invoked for both CONNECT and traditional proxy requests
	proxy.Tr.DialContext = dialContext

	// Use a custom goproxy.RoundTripperFunc to ensure that the correct context is attached to the request.
	// This is only used for non-CONNECT HTTP proxy requests. For connect requests, goproxy automatically
	// attaches goproxy.ProxyCtx prior to calling dialContext.
	rtFn := goproxy.RoundTripperFunc(func(req *http.Request, pctx *goproxy.ProxyCtx) (*http.Response, error) {
		ctx := context.WithValue(req.Context(), goproxy.ProxyContextKey, pctx)
		return proxy.Tr.RoundTrip(req.WithContext(ctx))
	})

	// Associate a timeout with the CONNECT proxy client connection
	if config.IdleTimeout != 0 {
		proxy.ConnectClientConnHandler = func(conn net.Conn) net.Conn {
			return NewTimeoutConn(conn, config.IdleTimeout)
		}
	}

	// Handle traditional HTTP proxy
	proxy.OnRequest().DoFunc(func(req *http.Request, pctx *goproxy.ProxyCtx) (*http.Request, *http.Response) {

		// 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().
		sctx := newContext(config, httpProxy, req)

		// Attach smokescreenContext to goproxy.ProxyCtx
		pctx.UserData = sctx

		// Delete Smokescreen specific headers before goproxy forwards the request
		defer func() {
			req.Header.Del(roleHeader)
			req.Header.Del(traceHeader)
		}()

		// Set this on every request as every request mints a new goproxy.ProxyCtx
		pctx.RoundTripper = rtFn

		// Build an address parsable by net.ResolveTCPAddr
		remoteHost := req.URL.Hostname()
		if strings.LastIndex(remoteHost, ":") <= strings.LastIndex(remoteHost, "]") {
			switch req.URL.Scheme {
			case "http":
				remoteHost = net.JoinHostPort(remoteHost, "80")
			case "https":
				remoteHost = net.JoinHostPort(remoteHost, "443")
			default:
				remoteHost = net.JoinHostPort(remoteHost, "0")
			}
		}

		sctx.logger.WithField("url", req.RequestURI).Debug("received HTTP proxy request")

		sctx.decision, sctx.lookupTime, pctx.Error = checkIfRequestShouldBeProxied(config, req, remoteHost)

		// Returning any kind of response in this handler is goproxy's way of short circuiting
		// the request. The original request will never be sent, and goproxy will invoke our
		// response filter attached via the OnResponse() handler.
		if pctx.Error != nil {
			return req, rejectResponse(pctx, pctx.Error)
		}
		if !sctx.decision.allow {
			return req, rejectResponse(pctx, denyError{errors.New(sctx.decision.reason)})
		}

		// Proceed with proxying the request
		return req, nil
	})

	// Handle CONNECT proxy to TLS & other TCP protocols destination
	proxy.OnRequest().HandleConnectFunc(func(host string, pctx *goproxy.ProxyCtx) (*goproxy.ConnectAction, string) {
		pctx.UserData = newContext(config, connectProxy, pctx.Req)
		pctx.HTTPErrorHandler = HTTPErrorHandler

		// Defer logging the proxy event here because logProxy relies
		// on state set in handleConnect
		defer logProxy(config, pctx)
		defer pctx.Req.Header.Del(traceHeader)

		err := handleConnect(config, pctx)
		if err != nil {
			pctx.Resp = rejectResponse(pctx, err)
			return goproxy.RejectConnect, ""
		}
		return goproxy.OkConnect, host
	})

	// Strangely, goproxy can invoke this same function twice for a single HTTP request.
	//
	// If a proxy request is rejected due to an ACL denial, the response passed to this
	// function was created by Smokescreen's call to rejectResponse() in the OnRequest()
	// handler. This only happens once. This is also the behavior for an allowed request
	// which is completed successfully.
	//
	// If a proxy request is allowed, but the RoundTripper returns an error fulfulling
	// the HTTP request, goproxy will invoke this OnResponse() filter twice. First this
	// function will be called with a nil response, and as a result this function will
	// return a response to send back to the proxy client using rejectResponse(). This
	// function will be called again with the previously returned response, which will
	// simply trigger the logHTTP function and return.
	proxy.OnResponse().DoFunc(func(resp *http.Response, pctx *goproxy.ProxyCtx) *http.Response {
		sctx := pctx.UserData.(*smokescreenContext)

		if resp != nil && resp.Header.Get(errorHeader) != "" {
			if pctx.Error == nil && sctx.decision.allow {
				resp.Header.Del(errorHeader)
			}
		}

		if resp == nil && pctx.Error != nil {
			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)
		return resp
	})
	return proxy
}

file_path: pkg/smokescreen/smokescreen.go
snippet: func handleConnect(config *Config, pctx *goproxy.ProxyCtx) error {
	sctx := pctx.UserData.(*smokescreenContext)

	// Check if requesting role is allowed to talk to remote
	sctx.decision, sctx.lookupTime, pctx.Error = checkIfRequestShouldBeProxied(config, pctx.Req, pctx.Req.URL.Hostname())
	if pctx.Error != nil {
		return pctx.Error
	}
	if !sctx.decision.allow {
		return denyError{errors.New(sctx.decision.reason)}
	}

	return nil
}