Skip to content
Merged
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
30 changes: 26 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -125,11 +125,16 @@ make validate
```bash
./docker-socket-policy \
--listen-socket=/var/run/docker-socket-policy.sock \
--listen-socket-group=builders \
--docker-host=/var/run/docker.sock \
--config-dir=./config \
--log-file=/tmp/docker-socket-policy.log
```

The socket is created `0660` owned by `--listen-socket-group`, so members of
that group can connect and nobody else can. Omit the flag and only the proxy's
own user can reach it.

### Configure a Service

Create a YAML policy in the config directory:
Expand Down Expand Up @@ -217,6 +222,8 @@ docker pull attacker/malware:latest # denied: image not in allowlist
| `--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 |
| `--listen-socket-mode` | `0660` | Octal mode for the listening socket (ignored for `fd://3`) |
| `--listen-socket-group` | *(none)* | Group name or gid owning the listening socket (ignored for `fd://3`) |

> **Unix socket security boundary**: the proxy listens on a Unix socket only,
> in all three implementations. Access control is the file permissions and Unix
Expand All @@ -228,10 +235,25 @@ docker pull attacker/malware:latest # denied: image not in allowlist
> 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`.
> To grant access, set `--listen-socket-group` to a group, place the caller's
> container user in that group, and bind-mount the 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`.
>
> The socket is created at `--listen-socket-mode` (default `0660`) regardless of
> the ambient umask. This matters: `bind(2)` applies `0777 & ~umask`, so left to
> a default umask the socket would be `0755`, and `connect(2)` on a Unix socket
> requires **write** permission — the group grant above would silently not work.
> Under `umask 0` it would be `0777`, reachable by every local uid. A
> world-writable mode is rejected at startup and there is no opt-out.
>
> Without `--listen-socket-group` the socket is `0660` owned by the proxy's own
> user and group, so only that user can connect. The group is what makes the
> mode useful.
>
> Under `fd://3` the socket belongs to systemd: use `SocketMode=` and
> `SocketGroup=` in the `.socket` unit instead, as in the example below. Both
> flags are ignored in that mode.
>
> **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
Expand Down
6 changes: 6 additions & 0 deletions deploy/docker-compose.sock.yml
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,10 @@ services:
command:
- --docker-host=/sock/docker.sock
- --listen-socket=/sock/granted.sock
# Exercises the #40 flags: the socket must come out 0660 owned by this
# group regardless of the image's umask.
- --listen-socket-mode=0660
- --listen-socket-group=2001
- --config-dir=/etc/docker-socket-policy/services
- --log-file=/tmp/docker-socket-policy.log

Expand All @@ -77,6 +81,8 @@ services:
command:
- --docker-host=/sock/docker.sock
- --listen-socket=/sock/denied.sock
- --listen-socket-mode=0660
- --listen-socket-group=3001
- --config-dir=/etc/docker-socket-policy/services
- --log-file=/tmp/docker-socket-policy.log

Expand Down
31 changes: 31 additions & 0 deletions deploy/test-sock.sh
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,37 @@ if [ $i -eq 15 ]; then
fi
echo ""

# ─── Listening socket permissions ─────────────────────

# Regression guard for #40. The listening socket's mode used to be whatever the
# ambient umask left behind — 0755 by default, 0777 under umask 0. connect(2)
# on a Unix socket needs write permission, so at 0755 the documented group
# grant did not work, and at 0777 every local uid could drive the Docker API.
echo "--- listening socket permissions ---"

MODE=$(stat -c '%a' "$GRANTED_SOCK" 2>/dev/null || echo "?")
check "granted.sock mode is 660, not the umask default" "660" "$MODE"

GROUP=$(stat -c '%g' "$GRANTED_SOCK" 2>/dev/null || echo "?")
check "granted.sock is owned by --listen-socket-group 2001" "2001" "$GROUP"

# The specific failure mode that removes the boundary entirely.
case "$MODE" in
*[2367])
echo " FAIL: granted.sock is world-writable (mode $MODE) — any local uid could connect"
FAIL=$((FAIL+1))
;;
*)
echo " PASS: granted.sock is not world-writable"
PASS=$((PASS+1))
;;
esac

MODE=$(stat -c '%a' "$DENIED_SOCK" 2>/dev/null || echo "?")
check "denied.sock mode is 660" "660" "$MODE"

echo ""

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

echo "--- proxy-granted (GID 2001, has group access) ---"
Expand Down
106 changes: 103 additions & 3 deletions go/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ import (
"net/http"
"os"
"os/signal"
"os/user"
"strconv"
"strings"
"syscall"
"time"
Expand All @@ -34,6 +36,10 @@ func main() {
"Audit log file (JSON)")
readonly := flag.Bool("readonly", false,
"Enable read-only mode (deny all POST/PUT/DELETE)")
listenSocketMode := flag.String("listen-socket-mode", "0660",
"Octal mode for the listening socket (ignored for fd://3)")
listenSocketGroup := flag.String("listen-socket-group", "",
"Group name or gid to own the listening socket (ignored for fd://3)")
flag.Parse()

if err := validateListenSocket(*listenSocket); err != nil {
Expand All @@ -44,6 +50,16 @@ func main() {
slog.Error(err.Error())
os.Exit(2)
}
socketMode, err := parseSocketMode(*listenSocketMode)
if err != nil {
slog.Error(err.Error())
os.Exit(2)
}
socketGID, err := resolveGroup(*listenSocketGroup)
if err != nil {
slog.Error(err.Error())
os.Exit(2)
}

ctx, cancel := signal.NotifyContext(context.Background(), syscall.SIGTERM, syscall.SIGINT)
defer cancel()
Expand All @@ -67,7 +83,7 @@ func main() {
transport := proxy.NewTransport(*dockerHost)
handler := proxy.NewHandler(router, chain, auditLog, transport)

listener, err := unixListener(*listenSocket)
listener, err := unixListener(*listenSocket, socketMode, socketGID)
if err != nil {
slog.Error("failed to start listener", "addr", *listenSocket, "error", err)
os.Exit(1)
Expand Down Expand Up @@ -154,11 +170,73 @@ func listenerFromFile(f *os.File) (net.Listener, error) {
return l, nil
}

// defaultListenSocketMode is the mode applied to the listening socket when
// --listen-socket-mode is not given. connect(2) on an AF_UNIX socket requires
// write permission, so 0660 is what actually grants the owning group access.
const defaultListenSocketMode = 0o660

// bindUmask is set around bind(2) so the socket is created at 0600 and is never
// briefly reachable by group or world. bind() applies 0777 &^ umask, and
// 0777 &^ 0177 == 0600. Correcting with chmod after the fact would leave a
// window in which the socket is already listening at the ambient mode.
const bindUmask = 0o177

// resolveGroup maps --listen-socket-group to a gid. A numeric value is used
// as-is so deployments without the group in /etc/group (or NSS) still work.
func resolveGroup(group string) (int, error) {
if group == "" {
return -1, nil
}
if gid, err := strconv.Atoi(group); err == nil {
if gid < 0 {
return -1, fmt.Errorf("--listen-socket-group %q: negative gid", group)
}
return gid, nil
}
g, err := user.LookupGroup(group)
if err != nil {
return -1, fmt.Errorf("--listen-socket-group %q: %w", group, err)
}
gid, err := strconv.Atoi(g.Gid)
if err != nil {
return -1, fmt.Errorf("--listen-socket-group %q: gid %q is not numeric", group, g.Gid)
}
return gid, nil
}

// parseSocketMode accepts an octal mode and rejects anything world-writable.
// A world-writable socket is connectable by every local uid, which removes the
// boundary entirely, so there is deliberately no opt-out.
func parseSocketMode(s string) (os.FileMode, error) {
if s == "" {
return 0, fmt.Errorf("--listen-socket-mode must not be empty")
}
m, err := strconv.ParseUint(s, 8, 32)
if err != nil {
return 0, fmt.Errorf("--listen-socket-mode %q: not an octal mode", s)
}
if m > 0o777 {
return 0, fmt.Errorf("--listen-socket-mode %q: must be within 0777", s)
}
if m&0o002 != 0 {
return 0, fmt.Errorf("--listen-socket-mode %q is world-writable: every local user "+
"could connect to the proxy, which disables the access-control boundary", s)
}
return os.FileMode(m), nil
}

// unixListener binds the proxy's only listening socket. Listening is Unix-socket
// only by design: filesystem ownership on the socket is the access-control
// boundary, and a TCP listener would have none.
func unixListener(addr string) (net.Listener, error) {
//
// The mode is set explicitly rather than inherited from the ambient umask.
// Left to the umask the socket is 0755 by default — connect(2) needs write, so
// the documented "add the caller to the socket's group" grant does not work —
// and 0777 under umask 0, which lets any local uid drive the Docker API.
func unixListener(addr string, mode os.FileMode, gid int) (net.Listener, error) {
if addr == fmt.Sprintf("fd://%d", systemdSocketFD) {
// Under socket activation systemd owns the socket and applies its own
// SocketMode/SocketGroup. Re-chmod'ing it here would fight the unit.
return listenerFromFile(os.NewFile(systemdSocketFD, "socket"))
}

Expand All @@ -176,7 +254,29 @@ func unixListener(addr string) (net.Listener, error) {
return nil, fmt.Errorf("checking %s: %w", addr, err)
}

return net.Listen("unix", addr)
// umask is process-global and not thread-safe. This runs during startup,
// before any request handling, so nothing else is creating files.
old := syscall.Umask(bindUmask)
l, err := net.Listen("unix", addr)
syscall.Umask(old)
if err != nil {
return nil, err
}

// Widen from 0600 to the configured mode only after ownership is right,
// so the socket is never group-reachable by the wrong group.
if gid >= 0 {
if err := os.Chown(addr, -1, gid); err != nil {
l.Close()
return nil, fmt.Errorf("setting group on %s: %w", addr, err)
}
}
if err := os.Chmod(addr, mode); err != nil {
l.Close()
return nil, fmt.Errorf("setting mode on %s: %w", addr, err)
}

return l, nil
}

// shutdownTimeout bounds how long in-flight requests are given to finish once
Expand Down
Loading
Loading