Securing a shared Docker socket using a Golang reverse-proxy (2/4)

Securing a shared Docker socket using a Golang reverse-proxy (2/4)

Introduction

In the previous part of this article series, we discussed :

  • How sharing the Docker socket with a container can lead to container escape and privilege escalation on the host.
  • Why current Docker client’s permissions mechanisms are somewhat unadapted (or at least to my needs).
  • How building a Golang reverse-proxy, handling clients authentication and permissions, can solve those issues.

In this article, we are going to discuss how to build a basic reverse proxy in Go and how to put it in a Docker image built FROM SCRATCH.

Building a basic Go Reverse-Proxy

So let’s start by what any developer would do, read the documentation, google if someone already did it. And lucky us, Michał Łowicki from Opera wrote a great blog post on the topic.

In his implementation :

  • A web server wait for clients’ requests.
  • An handler function receives HTTP requests, forward them to targeted web server and send the server’s responses back to the client.
  • An handler function receives HTTPS requests, establishing a tunnel between the client and the server using HTTP CONNECT.

Such design is perfect for us, since we can add our filtering logic in the handler function and block requests if needed (see part 3 of this article series). Futhermore, the use of a basic http server allows to act as a transparent reverse-proxy, which guarantee compatibility with any Docker client implementation (ie. docker official client, jenkins plugins and such).

However, we have to remove the HTTP CONNECT implementation used to support HTTPS. Indeed, we will forward clients’ requests to a unix socket, thus we need to secure the connection between the clients and our proxy, not from the proxy to a web server. Additionally, we need to be able to analyse client requests’ content, which is not possible with an HTTP CONNECT tunnel. Thus let’s start small and implement a basic HTTP reverse-proxy, HTTPS and client authentication will be added later (see part 4 of this article series).

First we setup the HTTP server :

func main() {

    server := &http.Server{
        Addr: ":8888",
        Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {handleHTTP(w, r)}),
    }

    log.Fatal(server.ListenAndServe())
}

Then we add the HTTP handler with a couple modifications. The handler receive two “objects”, a ResponseWriter to answer to the client and the client’s request :

func handleHTTP(w http.ResponseWriter, req *http.Request) {

    u := &httpunix.Transport{ //[1]
        DialTimeout:           100 * time.Millisecond,
        RequestTimeout:        1 * time.Second,
        ResponseHeaderTimeout: 1 * time.Second,
    }
    u.RegisterLocation("docker-socket", "/var/run/docker.sock") //[2]

    req.URL.Scheme = "http+unix"    //[3]
    req.URL.Host = "docker-socket"  //[4]

    resp, err := u.RoundTrip(req)   //[5]
    
    if err != nil {
        http.Error(w, err.Error(), http.StatusServiceUnavailable) //[6]
        return
    }

    //[7]
    defer resp.Body.Close()
    copyHeader(w.Header(), resp.Header)
    w.WriteHeader(resp.StatusCode)
    io.Copy(w, resp.Body)
}
  1. Create a new httpunix Transport instance which will be used to request the socket.
  2. Indicate that requests to the hostname docker-socket actually target the docker unix socket.
  3. The client request’s Sheme value is http. Since we redirect it to a unix socket, we have to change it to http+unix.
  4. Force the targeted hostname to docker-socket in the client’s request. This allow us to have any hostname value for our running container. Also, we don’t offer actual proxy capabilities and alway want to redirect to the docker socket.
  5. Forward the modified client request to the docker socket.
  6. If the request fail, we send a standard Status Service Unavailable error to the client.
  7. Else, we forward the answer of the docker API to the client.

Building a Docker image from scratch

A Docker image is built based on a Dockerfile, describing steps required to create it. In our case, a basic Docker image would look like this :

FROM golang

COPY src/reverse-proxy.go /go/src/      #[1]
RUN go get github.com/tv42/httpunix     #[2]
RUN go build src/reverse-proxy.go       #[3]

RUN mv /go/reverse-proxy /bin/reverse-proxy #[4]
USER root                                   #[5]

ENTRYPOINT ["/bin/reverse-proxy"] #[6]
  1. Move the source code to the container.
  2. Get required Go dependencies.
  3. Compile our source code.
  4. Move the output to /bin.
  5. Set the user as root (required to access the docker-socket).
  6. Define /bin/reverse-proxy as the entry point.

Nothing too fancy or difficult here. However, security concerns aside, do we really want a container with a 295MB base image ? Probably not. Thanks to the use of a multi-stage builds Dockerfile, we can build our reverse proxy statically and add the resulting binary in an empty container.

FROM golang as build #[1]
COPY reverse-proxy.go /go/src/
RUN go get github.com/tv42/httpunix
RUN CGO_ENABLED=0 GOOS=linux go build -a -ldflags '-extldflags "-static"' src/reverse-proxy.go #[2]

FROM scratch
COPY --from=build /go/reverse-proxy /bin/reverse-proxy #[3]
USER root
ENTRYPOINT ["/bin/reverse-proxy"]

Our key additions :

  1. Define the golang container as a build step named build.
  2. Compile the binary statically.
  3. Move the compiled binary from our build container to the scratch container.

The result is a container with a size of 6.5MB and an as small as possible attack surface.

Putting everything together

A working proof-of-concept is available here for testing purpose. A docker-compose file is provided, which builds and launches the container. Once running, the container makes available the host’s docker socket through the reverse proxy on localhost port 8888.

How to run it :

  • git clone https://github.com/ben-lab/blog-material.git
  • cd blog-material/golang-reverse-proxy-2
  • docker-compose up
  • docker -H=127.0.0.1:8888 container ls

Note: docker and docker-compose must be installed.

Demo

What’s next ?

Now that we have a working basic reverse-proxy, we will see in part 3 how to add permissions management capabilities, based on a configuration file generated from the Docker OpenAPI specification.

References