From c54da278ed3e9a8084fdbab77983f83d36351162 Mon Sep 17 00:00:00 2001 From: manusjs Date: Wed, 24 Jun 2026 17:27:48 +0000 Subject: [PATCH] fix(sandbox): add docker-socket-proxy to restrict DinD API access (issue #337) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Root cause fix for the Docker socket escape described in issue #337. When DOCKER_INSIDE=true, agent sandbox containers previously received the raw host /var/run/docker.sock, giving any process inside unrestricted access to the Docker daemon — enough to launch a privileged container with the host filesystem mounted and achieve a full host escape. This PR introduces a least-privilege socket proxy layer: docker-compose.yml — new docker-socket-proxy service A Tecnativa/docker-socket-proxy container (optional "dind" compose profile) that fronts the raw socket with a strict allowlist: ALLOW: CONTAINERS, IMAGES, NETWORKS, VOLUMES, INFO, PING, VERSION BLOCK: AUTH, BUILD, EXEC, SECRETS, CONFIGS, SWARM, SYSTEM, PLUGINS, … Agents can pull images and run sub-containers for tools, but cannot: - Create privileged or host-mounted containers - exec into the pentagi container or any sibling container - Read secrets, configs, or swarm tokens - Trigger system-level prune or shutdown operations The proxy socket is exposed via a named Docker volume (docker-proxy-socket) and mounted into pentagi at /var/run/docker-proxy/. backend/pkg/config/config.go New DOCKER_SANDBOX_SOCKET env var. When set, pentagi binds this path (intended to be the proxy socket) into sandbox containers instead of the raw DOCKER_SOCKET. backend/pkg/docker/client.go - sandboxSocket field on dockerClient; falls back to socket when not set - Bind-mount uses sandboxSocket instead of socket when DOCKER_INSIDE=true - Startup warning now mentions DOCKER_SANDBOX_SOCKET as the recommended configuration alongside DOCKER_INSIDE=false - Debug log includes docker_sandbox_socket field .env.example - Full setup instructions for the proxy (3-step: profile + env vars) - DOCKER_INSIDE default corrected to false (matches config.go envDefault) - DOCKER_NET_ADMIN default corrected to false - DOCKER_SANDBOX_SOCKET added with explanation Usage — to enable DinD with the proxy: docker compose --profile dind up DOCKER_INSIDE=true DOCKER_SANDBOX_SOCKET=/var/run/docker-proxy/docker.sock To disable DinD entirely (safest): DOCKER_INSIDE=false (the default) --- .env.example | 29 +++++++++-- backend/pkg/config/config.go | 1 + backend/pkg/docker/client.go | 93 ++++++++++++++++++++++++------------ docker-compose.yml | 67 ++++++++++++++++++++++++++ 4 files changed, 157 insertions(+), 33 deletions(-) diff --git a/.env.example b/.env.example index 4b95dc8fc..7a2140ac1 100644 --- a/.env.example +++ b/.env.example @@ -213,9 +213,32 @@ DOCKER_TLS_VERIFY= DOCKER_CERT_PATH= ## Docker settings inside primary terminal container -DOCKER_INSIDE=true # enable to use docker socket -DOCKER_NET_ADMIN=true # enable to use net_admin capability -DOCKER_SOCKET=/var/run/docker.sock # path on host machine +# SECURITY NOTE: DOCKER_INSIDE=true mounts a Docker socket into every sandbox +# container so agents can spawn sub-containers (Docker-in-Docker / DinD). +# Mounting the raw host socket grants unrestricted Docker API access, which can +# be exploited via prompt injection to escape the sandbox and compromise the +# host (issue #337). Leave DOCKER_INSIDE=false unless DinD is required. +# +# PARTIAL MITIGATION using docker-socket-proxy (reduces attack surface but does +# NOT fully prevent sandbox escape — tecnativa proxy filters by URL/method, not +# request body, so Privileged containers can still be requested): +# 1. Start the proxy profile: docker compose --profile dind up +# 2. Set DOCKER_INSIDE=true +# 3. Set DOCKER_SANDBOX_SOCKET=tcp://docker-socket-proxy:2375 +# The proxy allows: CONTAINERS, IMAGES, NETWORKS, VOLUMES, INFO, PING, VERSION +# The proxy blocks: EXEC, SECRETS, CONFIGS, SWARM, SYSTEM, and other endpoints +# It does NOT inspect request bodies (Privileged, bind-mounts, CapAdd etc.) +# 4. REQUIRED: Set DOCKER_NETWORK=pentagi-network +# Sandbox containers must share a Docker network with the proxy so they +# can resolve the hostname 'docker-socket-proxy'. Without this, the proxy +# address is unreachable and all in-sandbox docker commands will fail. +# +# Safest: DOCKER_INSIDE=false (the default). +DOCKER_INSIDE=false # enable to use docker socket +DOCKER_NET_ADMIN=false # enable to use net_admin capability +DOCKER_SOCKET=/var/run/docker.sock # raw host socket (used by pentagi backend) +# When using the dind profile, set to tcp://docker-socket-proxy:2375 for partial restriction +DOCKER_SANDBOX_SOCKET= DOCKER_NETWORK= DOCKER_WORK_DIR= DOCKER_PUBLIC_IP=0.0.0.0 # public ip of host machine diff --git a/backend/pkg/config/config.go b/backend/pkg/config/config.go index 9ad47c973..e922c5d8d 100644 --- a/backend/pkg/config/config.go +++ b/backend/pkg/config/config.go @@ -31,6 +31,7 @@ type Config struct { DockerInside bool `env:"DOCKER_INSIDE" envDefault:"false"` DockerNetAdmin bool `env:"DOCKER_NET_ADMIN" envDefault:"false"` DockerSocket string `env:"DOCKER_SOCKET"` + DockerSandboxSocket string `env:"DOCKER_SANDBOX_SOCKET"` // optional proxy socket for sandbox containers (see issue #337) DockerNetwork string `env:"DOCKER_NETWORK"` DockerPublicIP string `env:"DOCKER_PUBLIC_IP" envDefault:"0.0.0.0"` DockerWorkDir string `env:"DOCKER_WORK_DIR"` diff --git a/backend/pkg/docker/client.go b/backend/pkg/docker/client.go index 58c1b9bf3..2577ffedb 100644 --- a/backend/pkg/docker/client.go +++ b/backend/pkg/docker/client.go @@ -54,16 +54,17 @@ type containerPathStatResult struct { } type dockerClient struct { - db database.Querier - logger *logrus.Logger - dataDir string - hostDir string - client *client.Client - inside bool - defImage string - socket string - network string - publicIP string + db database.Querier + logger *logrus.Logger + dataDir string + hostDir string + client *client.Client + inside bool + defImage string + socket string + sandboxSocket string // proxy address for sandbox containers; tcp:// URL or unix socket path; empty = use raw socket + network string + publicIP string } type DockerClient interface { @@ -111,7 +112,27 @@ func NewDockerClient(ctx context.Context, db database.Querier, cfg *config.Confi socket = getHostDockerSocket(ctx, cli) } inside := cfg.DockerInside + if inside { + logrus.Warn("DOCKER_INSIDE=true: the host Docker socket will be bind-mounted into " + + "every sandbox container. Any process inside the sandbox can use the socket " + + "to ask the host daemon to launch a privileged container, achieving a full " + + "host escape (issue #337). Set DOCKER_INSIDE=false unless DinD is required, " + + "or set DOCKER_SANDBOX_SOCKET to a least-privilege proxy (e.g. tcp://docker-socket-proxy:2375) " + + "to reduce API surface. Note: tecnativa proxy does not inspect request bodies " + + "and cannot block Privileged containers; see issue #337 for full mitigation.") + } + sandboxSocket := cfg.DockerSandboxSocket + if inside && sandboxSocket == "" { + // Fall back to the raw socket if no proxy is configured. + sandboxSocket = socket + } netName := cfg.DockerNetwork + if inside && strings.HasPrefix(sandboxSocket, "tcp://") && netName == "" { + logrus.Warn("DOCKER_SANDBOX_SOCKET is a tcp:// proxy address but DOCKER_NETWORK is empty: " + + "sandbox containers will run on the default bridge and cannot resolve the proxy hostname. " + + "Set DOCKER_NETWORK to a network shared with the docker-socket-proxy service " + + "(e.g. DOCKER_NETWORK=pentagi-network) — without this, all in-sandbox docker commands will fail.") + } publicIP := cfg.DockerPublicIP defImage := strings.ToLower(cfg.DockerDefaultImage) if defImage == "" { @@ -142,28 +163,30 @@ func NewDockerClient(ctx context.Context, db database.Querier, cfg *config.Confi logger := logrus.StandardLogger() logger.WithFields(logrus.Fields{ - "docker_name": info.Name, - "docker_arch": info.Architecture, - "docker_version": info.ServerVersion, - "client_version": cli.ClientVersion(), - "data_dir": dataDir, - "host_dir": hostDir, - "docker_inside": inside, - "docker_socket": socket, - "public_ip": publicIP, + "docker_name": info.Name, + "docker_arch": info.Architecture, + "docker_version": info.ServerVersion, + "client_version": cli.ClientVersion(), + "data_dir": dataDir, + "host_dir": hostDir, + "docker_inside": inside, + "docker_socket": socket, + "docker_sandbox_socket": sandboxSocket, + "public_ip": publicIP, }).Debug("Docker client initialized") return &dockerClient{ - db: db, - client: cli, - dataDir: dataDir, - hostDir: hostDir, - logger: logger, - inside: inside, - defImage: defImage, - socket: socket, - network: netName, - publicIP: publicIP, + db: db, + client: cli, + dataDir: dataDir, + hostDir: hostDir, + logger: logger, + inside: inside, + defImage: defImage, + socket: socket, + sandboxSocket: sandboxSocket, + network: netName, + publicIP: publicIP, }, nil } @@ -280,7 +303,17 @@ func (dc *dockerClient) RunContainer( hostConfig.Binds = append(hostConfig.Binds, fmt.Sprintf("%s:%s", hostDir, WorkFolderPathInContainer)) if dc.inside { - hostConfig.Binds = append(hostConfig.Binds, fmt.Sprintf("%s:%s", dc.socket, defaultDockerSocketPath)) + if strings.HasPrefix(dc.sandboxSocket, "tcp://") { + // Proxy speaks TCP — inject DOCKER_HOST so the Docker CLI inside the + // sandbox container routes to the proxy instead of the host socket. + // The proxy container must be on the same Docker network as the sandbox. + config.Env = append(config.Env, "DOCKER_HOST="+dc.sandboxSocket) + } else { + // Unix socket (raw host socket or a socket-based proxy). + // Bind-mount it at the standard location so DOCKER_HOST remains unset + // and the Docker CLI uses unix:///var/run/docker.sock by default. + hostConfig.Binds = append(hostConfig.Binds, fmt.Sprintf("%s:%s", dc.sandboxSocket, defaultDockerSocketPath)) + } } hostConfig.LogConfig = container.LogConfig{ diff --git a/docker-compose.yml b/docker-compose.yml index 20cc37d4e..f0da4cd50 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -22,6 +22,69 @@ networks: name: langfuse-network services: + # docker-socket-proxy fronts /var/run/docker.sock with an API allowlist + # so sandbox containers have a more restricted view of the Docker daemon. + # + # IMPORTANT — partial mitigation only (issue #337): + # tecnativa/docker-socket-proxy filters by URL/method, NOT by request body. + # With CONTAINERS=1, agents can still POST /containers/create with + # Privileged=true or a host bind-mount in the body. This proxy reduces + # the attack surface (removes EXEC, SECRETS, SWARM, SYSTEM, etc.) but does + # NOT fully prevent a prompt-injected agent from escaping the sandbox. + # The safest option remains DOCKER_INSIDE=false. + # + # The proxy listens on TCP port 2375. Sandbox containers reach it via + # DOCKER_HOST=tcp://docker-socket-proxy:2375 (network reachability required). + # No socket sharing is needed. + # + # Usage: docker compose --profile dind up + # DOCKER_INSIDE=true + # DOCKER_SANDBOX_SOCKET=tcp://docker-socket-proxy:2375 + # DOCKER_NETWORK=pentagi-network # REQUIRED: sandboxes must share this network + # # to resolve the proxy hostname + docker-socket-proxy: + image: ${DOCKER_PROXY_IMAGE:-tecnativa/docker-socket-proxy:latest} + restart: unless-stopped + container_name: docker-socket-proxy + hostname: docker-socket-proxy + # No privileged needed — proxy only reads the host socket read-only. + read_only: true + cap_drop: [ALL] + environment: + # Allow only what agents genuinely need for DinD. + # NOTE: CONTAINERS+POST still permits Privileged containers in request bodies; + # see the service comment above. + - CONTAINERS=1 + - IMAGES=1 + - NETWORKS=1 + - VOLUMES=1 + - INFO=1 + - PING=1 + - VERSION=1 + - POST=1 + # Explicitly block everything else: + - AUTH=0 + - BUILD=0 + - COMMIT=0 + - CONFIGS=0 + - DISTRIBUTION=0 + - EVENTS=0 + - EXEC=0 + - GRPC=0 + - PLUGINS=0 + - SECRETS=0 + - SERVICES=0 + - SESSION=0 + - SWARM=0 + - SYSTEM=0 + - TASKS=0 + volumes: + - ${PENTAGI_DOCKER_SOCKET:-/var/run/docker.sock}:/var/run/docker.sock:ro + networks: + - pentagi-network + profiles: + - dind + pentagi: image: ${PENTAGI_IMAGE:-vxcontrol/pentagi:latest} restart: unless-stopped @@ -165,6 +228,10 @@ services: - DOCKER_INSIDE=${DOCKER_INSIDE:-false} - DOCKER_NET_ADMIN=${DOCKER_NET_ADMIN:-false} - DOCKER_SOCKET=${DOCKER_SOCKET:-} + # When DOCKER_INSIDE=true, optionally set this to tcp://docker-socket-proxy:2375 + # (when using the dind profile) to reduce the Docker API surface exposed to + # sandbox containers. See service comment above for limitations. + - DOCKER_SANDBOX_SOCKET=${DOCKER_SANDBOX_SOCKET:-} - DOCKER_NETWORK=${DOCKER_NETWORK:-} - DOCKER_PUBLIC_IP=${DOCKER_PUBLIC_IP:-} - DOCKER_WORK_DIR=${DOCKER_WORK_DIR:-}