Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 4 additions & 3 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -107,9 +107,10 @@ test-integration-rs:
test-integration-ts:
$(MAKE) test-integration IMPL=ts

# Unix-socket provisioning tests. The proxy only connects to the Docker
# daemon over a Unix socket — TCP would bypass user/group socket ownership,
# which is the security model this target exercises. Not run in CI (uses
# Unix-socket provisioning tests. The proxy both listens and connects over
# Unix sockets only — TCP would bypass user/group socket ownership, which is
# the security model this target exercises: proxy-granted is in the socket's
# group and succeeds, proxy-denied is not and gets 403. Not run in CI (uses
# group-restricted socket setup); run locally per IMPL.
test-integration-sock:
IMPL=$(IMPL) docker compose -f deploy/docker-compose.sock.yml down --remove-orphans -v 2>/dev/null; \
Expand Down
34 changes: 24 additions & 10 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -212,20 +212,35 @@ docker pull attacker/malware:latest # denied: image not in allowlist

| Flag | Default | Description |
|------|---------|-------------|
| `--listen-socket` | `/var/run/docker-socket-policy.sock` | Unix socket (or `fd://3` for systemd). **Go/Rust only** |
| `--listen-tcp` | `127.0.0.1:2375` | TCP listen address |
| `--listen-socket` | `/var/run/docker-socket-policy.sock` | Unix socket to listen on (or `fd://3` for systemd) |
| `--docker-host` | `/var/run/docker.sock` | Docker daemon socket path (Unix socket only) |
| `--config-dir` | `/etc/docker-socket-policy/services` | Policy config directory |
| `--log-file` | `/var/log/docker-socket-policy.log` | Audit log path |
| `--readonly` | `false` | Enable read-only mode |

> **Unix socket security boundary**: Go and Rust support `--listen-socket` for
> binding to a Unix socket, enabling socket-level access control via file
> permissions and Unix groups. TypeScript does not implement `--listen-socket`
> and listens on TCP only (`--listen-tcp`). All three implementations connect
> to the Docker daemon over Unix sockets exclusively; they reject `tcp://` and
> `http://` schemes for `--docker-host`. TCP connections would bypass socket
> ownership-based access control, breaking the security model.
> **Unix socket security boundary**: the proxy listens on a Unix socket only,
> in all three implementations. Access control is the file permissions and Unix
> group on that socket — a caller is authorised because it can `connect(2)` to
> it. A TCP listener carries no peer identity, so anything able to reach the
> port would be implicitly trusted; there is no `--listen-tcp`.
>
> The same rule applies outbound: all three implementations connect to the
> Docker daemon over Unix sockets exclusively and reject `tcp://` and `http://`
> schemes for `--docker-host`.
>
> To grant access, place the caller's container user in the group that owns the
> listening socket and bind-mount that socket in; to revoke it, remove the group
> membership. If the proxy cannot reach the daemon socket because of its own
> group permissions, requests surface as `403`.
>
> **What the socket does not give you is per-service isolation.** The proxy
> performs no caller authentication: it selects a policy from the `Image` field
> of the request body, not from the identity of the connection. Every caller of
> one socket therefore shares one trust domain, and can act under any policy in
> that proxy's `--config-dir` by naming that policy's image. Treat the socket as
> a boundary around the whole proxy, not around a single service. To isolate
> services from one another, run a proxy instance per service, each with its own
> socket and a `--config-dir` containing only that service's policy.

### Systemd Socket Activation

Expand All @@ -235,7 +250,6 @@ docker pull attacker/malware:latest # denied: image not in allowlist
ListenStream=/var/run/docker-socket-policy.sock
SocketMode=0660
SocketGroup=builders
ListenStream=127.0.0.1:2375
```

**`docker-socket-policy.service`**:
Expand Down
11 changes: 11 additions & 0 deletions deploy/Dockerfile.test
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
# Test runner for the integration suites.
#
# The proxy listens on a Unix socket only, and busybox wget cannot address one,
# so the suites need curl. docker:28-cli does not ship it (the upstream image
# installs only ca-certificates, openssh-client and git), so installing it at
# runtime would put the Alpine CDN on the critical path of every test run and
# of `make release-verify`. Baking it into a cached layer keeps the suites
# runnable offline once built.
FROM docker:28-cli

RUN apk add --no-cache curl
49 changes: 36 additions & 13 deletions deploy/docker-compose.sock.yml
Original file line number Diff line number Diff line change
Expand Up @@ -10,13 +10,39 @@ services:
- sh
- -c
- |
# chmod first, before the slow apk fetch: the proxies bind their own
# listening sockets in this directory as non-root, and anything they
# attempt before this runs fails with EACCES. The access control under
# test is the mode on docker.sock below, not the mode on the directory.
#
# Sticky bit: without it, proxy-denied — the container this suite
# asserts has no access — could unlink docker.sock or granted.sock and
# bind its own in their place, which would be a bypass of the very
# control being demonstrated, and a bad pattern to copy.
chmod 1777 /sock
apk add --no-cache socat
addgroup -g 2001 dockertest
socat UNIX-LISTEN:/sock/docker.sock,unlink-early,fork,group=dockertest,perm=660 \
UNIX-CONNECT:/var/run/docker.sock
volumes:
- /var/run/docker.sock:/var/run/docker.sock
- sock-data:/sock
# Gates the proxies below. service_started would only mean "the container
# process exists", which was racing the chmod and the socat bind above.
#
# Checks the mode as well as existence: socat applies group and perm after
# bind(2), so there is a window where the socket exists at the default
# umask and proxy-granted would start into an EACCES it should not get.
healthcheck:
test:
- CMD
- sh
- -c
- test -S /sock/docker.sock && [ "$$(stat -c '%a %G' /sock/docker.sock)" = "660 dockertest" ]
interval: 1s
timeout: 2s
retries: 60
start_period: 30s

proxy-granted:
build:
Expand All @@ -26,16 +52,13 @@ services:
user: 65532:2001
depends_on:
sock-perms:
condition: service_started
ports:
- "12376:2375"
condition: service_healthy
volumes:
- ./config:/etc/docker-socket-policy/services:ro
- sock-data:/sock
command:
- --docker-host=/sock/docker.sock
- --listen-tcp=0.0.0.0:2375
- --listen-socket=/tmp/docker-socket-policy.sock
- --listen-socket=/sock/granted.sock
- --config-dir=/etc/docker-socket-policy/services
- --log-file=/tmp/docker-socket-policy.log

Expand All @@ -47,27 +70,27 @@ services:
user: 65532:3001
depends_on:
sock-perms:
condition: service_started
ports:
- "12377:2375"
condition: service_healthy
volumes:
- ./config:/etc/docker-socket-policy/services:ro
- sock-data:/sock
command:
- --docker-host=/sock/docker.sock
- --listen-tcp=0.0.0.0:2375
- --listen-socket=/tmp/docker-socket-policy.sock
- --listen-socket=/sock/denied.sock
- --config-dir=/etc/docker-socket-policy/services
- --log-file=/tmp/docker-socket-policy.log

test:
image: docker:28-cli
build:
context: .
dockerfile: Dockerfile.test
depends_on:
- proxy-granted
- proxy-denied
environment:
PROXY_GRANTED: http://proxy-granted:2375
PROXY_DENIED: http://proxy-denied:2375
PROXY_GRANTED_SOCK: /sock/granted.sock
PROXY_DENIED_SOCK: /sock/denied.sock
volumes:
- ./test-sock.sh:/test-sock.sh:ro
- sock-data:/sock
entrypoint: ["/bin/sh", "/test-sock.sh"]
22 changes: 16 additions & 6 deletions deploy/docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -6,24 +6,34 @@ services:
context: ../${IMPL:-go}
dockerfile: Dockerfile
user: "0:0"
ports:
- "12375:2375"
volumes:
- /var/run/docker.sock:/var/run/docker.sock
- ./config:/etc/docker-socket-policy/services:ro
- proxy-sock:/sock
command:
- --docker-host=/var/run/docker.sock
- --listen-tcp=0.0.0.0:2375
- --listen-socket=/tmp/docker-socket-policy.sock
- --listen-socket=/sock/proxy.sock
- --config-dir=/etc/docker-socket-policy/services
- --log-file=/tmp/docker-socket-policy.log

test:
image: docker:28-cli
build:
context: .
dockerfile: Dockerfile.test
depends_on:
- proxy
environment:
DOCKER_HOST: tcp://proxy:2375
PROXY_SOCK: /sock/proxy.sock
# Hostname the proxy would have been reachable on when it still had a
# TCP listener, so the suite can assert that it no longer is.
PROXY_HOST: proxy
volumes:
- ./test.sh:/test.sh:ro
- proxy-sock:/sock
entrypoint: ["/bin/sh", "/test.sh"]

# The proxy is reachable only over this shared Unix socket: there is no
# published port, because filesystem ownership on the socket is the
# access-control boundary the proxy enforces.
volumes:
proxy-sock:
79 changes: 59 additions & 20 deletions deploy/test-sock.sh
Original file line number Diff line number Diff line change
Expand Up @@ -8,14 +8,40 @@ set -e

PASS=0
FAIL=0
GRANTED="${PROXY_GRANTED:-http://proxy-granted:2375}"
DENIED="${PROXY_DENIED:-http://proxy-denied:2375}"

# Each proxy listens on its own Unix socket in the shared volume. The host part
# of the URL is ignored when curl is given --unix-socket, but must still parse.
GRANTED_SOCK="${PROXY_GRANTED_SOCK:-/sock/granted.sock}"
DENIED_SOCK="${PROXY_DENIED_SOCK:-/sock/denied.sock}"
URL="http://localhost"

# busybox wget cannot speak to a Unix socket, so the helpers below need curl.
# It is baked into Dockerfile.test rather than installed here, so a run does
# not depend on the Alpine CDN being reachable.
if ! command -v curl >/dev/null 2>&1; then
echo "ERROR: curl is missing from the test image (see deploy/Dockerfile.test)"
exit 1
fi

# Bound every request; curl has no default overall timeout.
TIMEOUT="--max-time 10 --connect-timeout 2"

# Both helpers take the target socket as their first argument, since this
# suite talks to two proxies with deliberately different socket permissions.
# curl exits non-zero when it cannot connect at all, which under `set -e` would
# abort the run instead of reporting a failed assertion, so connection failures
# are normalised to the synthetic status 000. This matters more here than in
# test.sh: the wait loops below poll sockets that do not exist yet.
# curl still prints %{http_code} when it exits non-zero, so the fallback only
# applies when it produced nothing at all.
get_status() {
wget -qO /dev/null -S "$1" 2>&1 | grep -o 'HTTP/[0-9.]* [0-9]*' | tail -1 | awk '{print $2}'
out=$(curl -s -o /dev/null -w '%{http_code}' $TIMEOUT --unix-socket "$1" "$2" 2>/dev/null || true)
echo "${out:-000}"
}
post_json() {
wget -qO /dev/null -S --post-data="$1" --header="Content-Type: application/json" "$2" 2>&1 | grep -o 'HTTP/[0-9.]* [0-9]*' | tail -1 | awk '{print $2}'
out=$(curl -s -o /dev/null -w '%{http_code}' $TIMEOUT --unix-socket "$1" \
-X POST -H "Content-Type: application/json" -d "$2" "$3" 2>/dev/null || true)
echo "${out:-000}"
}

check() {
Expand All @@ -40,10 +66,10 @@ echo ""
# ─── Wait for proxies to be ready ──────────────────────

# proxy-granted needs the socat socket to be ready; poll _ping until 200
echo "Waiting for proxy-granted at $GRANTED..."
echo "Waiting for proxy-granted at $GRANTED_SOCK..."
i=0
while [ $i -lt 30 ]; do
S=$(get_status "$GRANTED/_ping")
S=$(get_status "$GRANTED_SOCK" "$URL/_ping")
if [ "$S" = "200" ]; then
echo "proxy-granted ready."
break
Expand All @@ -54,15 +80,23 @@ while [ $i -lt 30 ]; do
done
if [ $i -eq 30 ]; then
echo ""
echo "WARNING: proxy-granted not ready after 30s"
# Fail here rather than letting every assertion come back 000, which reads
# as a policy bug when the real cause is that the proxy never started.
if [ ! -S "$GRANTED_SOCK" ]; then
echo "ERROR: proxy-granted never created $GRANTED_SOCK — it failed to bind"
else
echo "ERROR: proxy-granted is listening but not answering after 30s"
fi
exit 1
fi

# proxy-denied: just verify TCP port is open (will always return 403 on requests)
host="${DENIED#http://}"
echo "Waiting for proxy-denied at $DENIED..."
# proxy-denied: it listens fine but cannot reach the Docker socket, so it can
# never return 200. Wait for the listening socket itself to accept a request,
# whatever status that request comes back with.
echo "Waiting for proxy-denied at $DENIED_SOCK..."
i=0
while [ $i -lt 15 ]; do
if nc -z "${host%:*}" "${host#*:}" 2>/dev/null; then
if [ "$(get_status "$DENIED_SOCK" "$URL/_ping")" != "000" ]; then
echo "proxy-denied ready."
break
fi
Expand All @@ -72,25 +106,30 @@ while [ $i -lt 15 ]; do
done
if [ $i -eq 15 ]; then
echo ""
echo "WARNING: proxy-denied not responding after 15s"
if [ ! -S "$DENIED_SOCK" ]; then
echo "ERROR: proxy-denied never created $DENIED_SOCK — it failed to bind"
else
echo "ERROR: proxy-denied is listening but not answering after 15s"
fi
exit 1
fi
echo ""

# ─── proxy-granted: should work ───────────────────────

echo "--- proxy-granted (GID 2001, has group access) ---"

S=$(get_status "$GRANTED/_ping")
S=$(get_status "$GRANTED_SOCK" "$URL/_ping")
check "GET /_ping -> 200" "200" "$S"

S=$(get_status "$GRANTED/version")
S=$(get_status "$GRANTED_SOCK" "$URL/version")
check "GET /version -> 200" "200" "$S"

S=$(get_status "$GRANTED/containers/json")
S=$(get_status "$GRANTED_SOCK" "$URL/containers/json")
check "GET /containers/json -> 200" "200" "$S"

# Allowed image create passes through to Docker (daemon returns 404, not 403)
S=$(post_json '{"Image":"chainsafe/lodestar:beacon","Cmd":["--rcConfig","/data/config.yml"]}' "$GRANTED/containers/create")
S=$(post_json "$GRANTED_SOCK" '{"Image":"chainsafe/lodestar:beacon","Cmd":["--rcConfig","/data/config.yml"]}' "$URL/containers/create")
if [ "$S" = "201" ] || [ "$S" = "404" ]; then
echo " PASS: create container -> $S (not 403)"
PASS=$((PASS+1))
Expand All @@ -106,16 +145,16 @@ echo "--- proxy-denied (GID 3001, no group access) ---"

# The proxy starts and listens, but cannot connect to the Docker socket.
# Permission denied on the Unix socket returns 403 Forbidden.
S=$(get_status "$DENIED/_ping")
S=$(get_status "$DENIED_SOCK" "$URL/_ping")
check "GET /_ping -> 403 (permission denied on socket)" "403" "$S"

S=$(get_status "$DENIED/version")
S=$(get_status "$DENIED_SOCK" "$URL/version")
check "GET /version -> 403" "403" "$S"

S=$(get_status "$DENIED/containers/json")
S=$(get_status "$DENIED_SOCK" "$URL/containers/json")
check "GET /containers/json -> 403" "403" "$S"

S=$(post_json '{"Image":"chainsafe/lodestar:beacon","Cmd":["--rcConfig","/data/config.yml"]}' "$DENIED/containers/create")
S=$(post_json "$DENIED_SOCK" '{"Image":"chainsafe/lodestar:beacon","Cmd":["--rcConfig","/data/config.yml"]}' "$URL/containers/create")
check "POST /containers/create -> 403" "403" "$S"

# ─── Summary ──────────────────────────────────────────
Expand Down
Loading
Loading