A few months ago I found myself installing the LinuxServerIO container to secure connections to the Docker socket and I wondered how it actually worked.
Certain containers, like Watchtower or the Caddy Docker Module require access to the Docker Socket to manage other containers (bring them up and down, check for image updates, etc.). These powers, however, make it critical that the Docker Socket is kept secure, e.g. not exposed to any old container that requests it. Even with open source projects, supply chain attacks do happen. Docker Socket Proxy and similar images limit a container’s access to only the Docker Socket functions it needs to function.
Connections to the Docker Socket can be made over Unix socket or TCP, for this we’ll be using TCP
Docker images are built by layering changes onto a base image, usually a Linux distribution. Taking a look at the Dockerfile on the Github page we can see it’s an Alpine Linux base base that installs HAProxy. We can also see declared the environment variables used to configure the level of access to the socket.
...
# The base Docker image
FROM docker.io/alpine:3.22
...
# Installation of HAProxy
RUN \
echo "**** install build packages ****" && \
apk add --no-cache \
alpine-release \
curl \
tzdata && \
if [ -z ${HAPROXY_VERSION+x} ]; then \
HAPROXY_VERSION=$(curl -sL "http://dl-cdn.alpinelinux.org/alpine/v3.22/main/x86_64/APKINDEX.tar.gz" | tar -xz -C /tmp \
&& awk '/^P:haproxy$/,/V:/' /tmp/APKINDEX | sed -n 2p | sed 's/^V://'); \
fi && \
apk add --no-cache \
haproxy==${HAPROXY_VERSION} && \
printf "Linuxserver.io version: ${VERSION}\nBuild-date: ${BUILD_DATE}" > /build_version && \
apk del --no-cache \
curl && \
rm -rf \ # Deletion of the default HAProxy configuration file
/etc/haproxy \
/tmp/*
# Declaring the environment variables for configuration
ENV ALLOW_RESTARTS=0 \
ALLOW_STOP=0 \
ALLOW_START=0 \
AUTH=0 \
BUILD=0 \
COMMIT=0 \
CONFIGS=0 \
CONTAINERS=0 \
DISTRIBUTION=0 \
EVENTS=1 \
EXEC=0 \
IMAGES=0 \
INFO=0 \
LOG_LEVEL=info \
NETWORKS=0 \
NODES=0 \
PING=1 \
PLUGINS=0 \
POST=0 \
SECRETS=0 \
SERVICES=0 \
SESSION=0 \
SOCKET_PATH=/var/run/docker.sock \
SWARM=0 \
SYSTEM=0 \
TASKS=0 \
VERSION=1 \
VOLUMES=0
...
When setting up the socket-proxy container with Docker Compose configuration is minimal. The main points of interest are ensuring the Docker socket is mounted inside the container, and setting the environment variables to configure the level of access permitted.
services:
docker-socket-proxy:
...
environment:
- ALLOW_RESTARTS=true # Configure the required permissions
...
volumes:
- /var/run/docker.sock:/var/run/docker.sock:ro # Mount the Docker socket inside the container
The Docker socket can be mounted as read-only inside the container (
:ro), however the actual efficacy of this is debatable.
Looking at the docker-entrypoint.sh, when the container is started the HAProxy configuration file is copied from /templates/haproxy.cfg to /run/haproxy/haproxy.cfg, where it is used by HAProxy. Looking at haproxy.cfg reveals how the configuration environment variables are put into effect:
...
# Configure the backend server requests will be proxied to, in this case the Docker socket
backend docker
server socket $SOCKET_PATH
# Configure the ACL rules on which connections to allow
frontend proxy
bind @@BIND_PROTO@@
http-request allow if { path,url_dec -m reg -i ^(/v[\d\.]+)?/containers/[a-zA-Z0-9_.-]+/((stop)|(restart)|(kill)) } { env(ALLOW_RESTARTS) -m bool }
http-request allow if { path,url_dec -m reg -i ^(/v[\d\.]+)?/containers/[a-zA-Z0-9_.-]+/start } { env(ALLOW_START) -m bool }
http-request allow if { path,url_dec -m reg -i ^(/v[\d\.]+)?/containers/[a-zA-Z0-9_.-]+/stop } { env(ALLOW_STOP) -m bool }
http-request deny unless METH_GET || { env(POST) -m bool }
http-request allow if { path,url_dec -m reg -i ^(/v[\d\.]+)?/auth } { env(AUTH) -m bool }
http-request allow if { path,url_dec -m reg -i ^(/v[\d\.]+)?/build } { env(BUILD) -m bool }
http-request allow if { path,url_dec -m reg -i ^(/v[\d\.]+)?/commit } { env(COMMIT) -m bool }
http-request allow if { path,url_dec -m reg -i ^(/v[\d\.]+)?/configs } { env(CONFIGS) -m bool }
http-request allow if { path,url_dec -m reg -i ^(/v[\d\.]+)?/containers } { env(CONTAINERS) -m bool }
http-request allow if { path,url_dec -m reg -i ^(/v[\d\.]+)?/distribution } { env(DISTRIBUTION) -m bool }
http-request allow if { path,url_dec -m reg -i ^(/v[\d\.]+)?/events } { env(EVENTS) -m bool }
http-request allow if { path,url_dec -m reg -i ^(/v[\d\.]+)?/exec } { env(EXEC) -m bool }
http-request allow if { path,url_dec -m reg -i ^(/v[\d\.]+)?/grpc } { env(GRPC) -m bool }
http-request allow if { path,url_dec -m reg -i ^(/v[\d\.]+)?/images } { env(IMAGES) -m bool }
http-request allow if { path,url_dec -m reg -i ^(/v[\d\.]+)?/info } { env(INFO) -m bool }
http-request allow if { path,url_dec -m reg -i ^(/v[\d\.]+)?/networks } { env(NETWORKS) -m bool }
http-request allow if { path,url_dec -m reg -i ^(/v[\d\.]+)?/nodes } { env(NODES) -m bool }
http-request allow if { path,url_dec -m reg -i ^(/v[\d\.]+)?/_ping } { env(PING) -m bool }
http-request allow if { path,url_dec -m reg -i ^(/v[\d\.]+)?/plugins } { env(PLUGINS) -m bool }
http-request allow if { path,url_dec -m reg -i ^(/v[\d\.]+)?/secrets } { env(SECRETS) -m bool }
http-request allow if { path,url_dec -m reg -i ^(/v[\d\.]+)?/services } { env(SERVICES) -m bool }
http-request allow if { path,url_dec -m reg -i ^(/v[\d\.]+)?/session } { env(SESSION) -m bool }
http-request allow if { path,url_dec -m reg -i ^(/v[\d\.]+)?/swarm } { env(SWARM) -m bool }
http-request allow if { path,url_dec -m reg -i ^(/v[\d\.]+)?/system } { env(SYSTEM) -m bool }
http-request allow if { path,url_dec -m reg -i ^(/v[\d\.]+)?/tasks } { env(TASKS) -m bool }
http-request allow if { path,url_dec -m reg -i ^(/v[\d\.]+)?/version } { env(VERSION) -m bool }
http-request allow if { path,url_dec -m reg -i ^(/v[\d\.]+)?/volumes } { env(VOLUMES) -m bool }
http-request deny
default_backend docker
Taking a look at one of the ACL rules:
http-request allow if { path,url_dec -m reg -i ^(/v[\d\.]+)?/images } { env(IMAGES) -m bool }
A typical request to the Docker socket might look something like this:
GET /v1.25/images/sha256:bf700010ce28b2744ed00ca6105951604c68db0a28105cf2da2956b89c2560e8/json HTTP/1.1
In this case, Watchtower is checking the image ID to see if the present image version is the latest, in this case Postgres 15.2. The portion of the ACL rule identifying the socket function requested is this regex:
path,url_dec -m reg -i ^(/v[\d\.]+)?/images
This checks that path of the URL is formatted v[any number with a decimal] followed by /images. The next portion:
env(IMAGES) -m bool
checks the environment variable IMAGES we configured in the Docker compose file. If we set IMAGES=true, the ACL rule is satisfied and the request is proxied to the Docker socket.