Read this before exposing the backend to the public internet.
The backend's job is to spawn, attach to, and kill Docker containers
on demand. To do that, docker-compose.yml bind-mounts the host's
/var/run/docker.sock into the backend container:
volumes:
- /var/run/docker.sock:/var/run/docker.sockThis is the standard tradeoff for a session-orchestrator design, but the consequences need to be explicit:
- The backend has full Docker daemon access. The Docker API the
socket exposes is unauthenticated to anything that can
connect(2)to it. There is no per-endpoint ACL on a vanilla socket. - An RCE in the backend is host root. An attacker who reaches arbitrary code execution inside the backend container can ask the daemon to start a privileged container that bind-mounts the host rootfs and chroots into it. There is no container-level mitigation for this once the socket is reachable.
- The session containers themselves run unprivileged (no
--privileged, no extra capabilities, no host PID/net namespaces). The socket exposure is a backend-trust property, not a session- trust property.
An is_admin=1 account is a fully trusted operator, not a
scoped-up user. Beyond the cross-user dashboard (list, force-stop,
force-delete, edit resource caps), an admin can operate any user's
session — attach to the live terminal with full write, edit its env
vars and exposed ports, create/delete/search its tabs, run exec, and
start a stopped session (#429) — via the same is_admin claim
(assertCanOperate, owner-OR-admin). Stop and delete stay owner-driven
(the owner and the idle sweeper own teardown). This is
distinct from the read-only tech-lead role (assertCanObserve,
group-scoped, no write). Every cross-user attach is recorded in
session_observe_log with mode='observe'|'operate', giving the
owner and other admins an audit trail of who watched vs. who drove
their session — a detective control, not a preventive one. Treat
admin credential compromise as equivalent to compromise of every
user's session data; scope admin accounts accordingly.
- Do not expose port 3001 directly to the internet. Treat the backend as a private service.
- Run behind Cloudflare Tunnel (see DEPLOYMENT.md → With Cloudflare Tunnel) so the origin has no inbound ports open at all. The tunnel terminates at Cloudflare; the home server only makes outbound connections.
- Gate the tunnel hostname with Cloudflare Access. A short Access policy ("emails matching me@example.com") in front of the tunnel hostname turns "anyone on the internet" into "people who can prove they're me" before any HTTP request reaches the backend. The in-app JWT auth is still the primary control; Access is a hard belt around it.
- Keep the host patched and the backend image rebuilt. Both the
Node runtime and the dependencies in
backend/package.jsonare CVE-bearing surfaces, and an unpatched RCE there inherits the socket-access blast radius above.
secret-typed env entries are AES-256-GCM encrypted before they are
written to D1 (key: SECRETS_ENCRYPTION_KEY). The threat model is "a
compromised D1 export hands an attacker only ciphertext."
Enabling per-session .env materialisation breaks that guarantee for
the materialised values. When a session sets config.writeEnvFile
(#277), the bootstrap stage writes ALL env var entries — plain values
verbatim and secret values decrypted — as plaintext KEY=VALUE lines
into /home/developer/workspace/.env, which is the bind-mounted
workspace, host path <WORKSPACE_ROOT>/<sessionId>/.env. chmod 600
limits reads inside the container, but the cleartext now lives on the
host filesystem, not just in encrypted D1.
Three consequences operators should weigh before enabling it:
- At-rest encryption no longer covers those values. Anyone with
read access to
WORKSPACE_ROOTon the host sees the secrets in the clear, independent ofSECRETS_ENCRYPTION_KEY. - The cleartext
.envsurvives a soft delete.DELETE /api/sessions/:id(without?hard=true) preserves the workspace dir so the session can be restored — so the.env, and the secrets in it, persist after the session is "deleted." Use?hard=true(which runspurgeWorkspace) to actually remove a session whose.envheld secrets; a plain soft delete leaves them on disk. - No API rewrites the on-disk
.envafter create-time bootstrap. The file is written once, during the create-time bootstrap pipeline. Editing env vars viaPATCH /sessions/:id/envupdates only the legacysessions.env_varscolumn — it touches neither the typedsession_configsstore thatwriteEnvFilereads nor the file on disk. So there is no rotate-in-place path: a leaked secret's old cleartext stays in the host.envuntil the session is hard-deleted (and recreated).
This is an explicit, opt-in feature (off by default). Leave
writeEnvFile unset for sessions whose secrets must stay
encrypted-at-rest, and prefer hard delete for any session that used it.
The session image persists Claude CLI state in the bind-mounted
workspace so it survives container recreation (POST /sessions/:id/start, reconcile after a host reboot): the entrypoint
symlinks ~/.claude and ~/.claude.json to
<WORKSPACE_ROOT>/<sessionId>/.st/claude-state and .st/claude.json
on the host. Without this, every recreate forced a re-login and
orphaned the session transcripts claude --resume / --continue
replay. The .st/ root carries a * self-.gitignore so that when the
workspace is a repo checkout (the normal cloned-session case), a
git add -A cannot commit the state into the user's repo — that
protects the git surface only; everything below about host-side
readers still applies. (#371 briefly targeted
<workspace>/.claude{,.json} directly, which collided with a repo's
project-level .claude/ config dir — #377; the entrypoint migrates
that layout to .st/ on the next recreate.)
That state includes material operators should be aware of:
- Conversation transcripts (
.st/claude-state/projects/) — the full content of every Claude conversation run in the session, in cleartext JSONL. Anything the user pasted into a conversation (including secrets) is in there. - OAuth credentials (
.st/claude-state/.credentials.json) — a live Anthropic token,chmod 600but cleartext on the host filesystem. - SSH private keys (
.st/ssh/) — the session's~/.sshis persisted here too, so any private keys the user generates or copies in live cleartext on the host under<WORKSPACE_ROOT>/<sessionId>(chmod 600, but not covered bySECRETS_ENCRYPTION_KEY). Backing up or sharing a workspace dir now also copies whatever keys the session can push with.
Same consequences as the writeEnvFile section above, for the same
reason (the workspace bind mount is the persistence boundary):
- Anyone with read access to
WORKSPACE_ROOTon the host sees transcripts and the token in the clear. Neither is covered bySECRETS_ENCRYPTION_KEY— that only protectssecret-typed env entries in D1. - The state survives a soft delete.
DELETE /api/sessions/:idpreserves the workspace dir so the session can be restored — the transcripts and token persist after the session is "deleted." Use?hard=true(which runspurgeWorkspace) for sessions whose conversations or credentials must actually be destroyed. - There is no rotate-in-place path. Revoking the Anthropic token
requires
claude /logoutinside a running session (or revocation on the Anthropic side); deleting transcripts requires removing.st/claude-state/projects/in the workspace or a hard delete.
Unlike writeEnvFile this is not opt-in — persistence is the point of
the feature, and the alternative (state evaporating on every recreate)
was a standing bug, not a privacy control anyone relied on.
For deployments that want to shrink the backend's daemon surface below "all of the Docker API", you can interpose a proxy that only exposes the endpoints this app actually needs. The backend uses the daemon for: container create, start, stop, remove, inspect, exec create + start + resize. The split between HTTP verbs matters for the proxy's allowlist:
POSTpaths:containers/create,containers/{id}/start,containers/{id}/stop,exec/create,exec/{id}/start,exec/{id}/resize.GETpaths:containers/{id}/json(called byisAlive(),startContainer(),reconcile()) andexec/{id}/json(called byexecOneShot()to read tmux exit codes — everycapture-pane,list-sessions,kill-session,new-session -d,set-optionruns through this path). WithoutGET=1the backend boots fine and authenticates fine, butreconcile()silently 403s at startup, everyPOST /api/sessions/:id/startfails when it inspects the container, and every tab attach fails when the tmux exec exit code can't be read.DELETEpaths:containers/{id}(full removal at session kill).
(DockerManager.kill() is a method name, not a daemon endpoint —
it routes through stop + remove, so POST /containers/{id}/kill
is never issued.)
tecnativa/docker-socket-proxy
is a small HAProxy-based filter that does exactly this. With the
ruleset below, an RCE in the backend can't pull or build images,
reach the Swarm/network/volume APIs, or touch any daemon surface
outside /containers/* and /exec/*. It can still create a
container — including one that bind-mounts the host rootfs
(HostConfig.Binds) — and inspect existing containers (which
exposes their environment variables). The proxy is blast-radius
reduction, not a privilege boundary: a determined attacker with
RCE can still escape via a new privileged container, just not via
the image/network/volume side of the API. Treat it as one layer of
defence in depth, paired with the tunnel + Access posture above.
The snippet below is a drop-in replacement for the project's
docker-compose.yml, not a docker-compose.override.yml overlay.
This matters because Compose merges volumes: lists from override
files onto the base service rather than replacing them — the base
file's /var/run/docker.sock:/var/run/docker.sock mount would
survive the override and an RCE would still find the live socket
file descriptor inside the container even with DOCKER_HOST
pointed at the proxy. Either replace docker-compose.yml
wholesale (the simpler path), or remove the socket mount from
the base file before layering an override.
networks:
# Internal-only network for the backend ↔ proxy hop. `internal:
# true` blocks egress to the default bridge AND prevents the
# session containers (which the backend creates on the default
# network) from reaching the proxy: a compromised session
# container can't issue Docker API calls through this hop to
# inspect other sessions' env vars or spawn its own containers.
# `expose:` alone would NOT achieve this — it gates host-level
# publishing, not inter-container reachability on a shared
# bridge, so the proxy port would otherwise be a flat lateral-
# movement target.
docker-proxy:
internal: true
services:
docker-socket-proxy:
# Pin to an immutable digest in production (`image:
# tecnativa/docker-socket-proxy@sha256:…`) so a future
# `docker compose pull` can't silently swap the proxy out from
# under the operator. A floating `:latest` defeats the
# blast-radius story this whole section is selling. The
# explicit version tag below is the minimum bar; the digest is
# the right answer.
image: tecnativa/docker-socket-proxy:v0.4.2
restart: unless-stopped
networks: [docker-proxy]
environment:
# Endpoints required by dockerManager.ts. The proxy enforces
# HTTP-verb ACLs (POST/GET/DELETE) SEPARATELY from the
# resource flags (CONTAINERS/EXEC/…), and every verb defaults
# to 0. All four of POST/GET/DELETE + the two resource flags
# are required; dropping any one silently breaks a different
# part of the app:
CONTAINERS: 1 # /containers/* — create, inspect, start, stop, remove
EXEC: 1 # /exec/* — create + start + resize + inspect (WS attach path)
GET: 1 # GET /containers/{id}/json (isAlive/start/reconcile)
# and GET /exec/{id}/json (execOneShot exit-code read).
# Without this the backend boots and authenticates fine but
# is non-functional: reconcile silently 403s, /start fails,
# every tab attach fails when execOneShot can't read its
# exit code.
POST: 1 # POST verbs (create/start/exec/resize/stop)
DELETE: 1 # DELETE /containers/{id} for container.remove() on
# session kill — without this the call is silently swallowed
# by dockerManager's try/catch and stopped containers pile up
# on the host indefinitely.
# Default-deny everything else: IMAGES, NETWORKS, VOLUMES,
# SERVICES, SWARM, NODES, INFO, AUTH, BUILD, COMMIT, etc.
volumes:
- /var/run/docker.sock:/var/run/docker.sock:ro
# No `expose:` and no `ports:`: the internal network already
# makes the proxy reachable to peers on `docker-proxy` and
# unreachable to anything else, including session containers
# the backend creates on the default Docker network.
app:
# Replace the direct socket mount with a network connection to
# the proxy. The backend joins BOTH networks: `docker-proxy`
# for the daemon hop, `default` for everything else (Cloudflare
# Tunnel egress, the session containers it creates, etc.).
environment:
- DOCKER_HOST=tcp://docker-socket-proxy:2375
depends_on:
- docker-socket-proxy
networks: [docker-proxy, default]
# No /var/run/docker.sock bind mount at all: this snippet is a
# full replacement for docker-compose.yml's `volumes:`, not
# additive on top of it.
volumes:
- ${WORKSPACE_ROOT:-/var/shared-terminal/workspaces}:/var/shared-terminal/workspacesCaveats:
- The backend honours
DOCKER_HOSTwhen it's set: with that env var present, theDockerManagerconstructor passes no explicit connection options to dockerode and lets docker-modem read the URL from the environment. WithDOCKER_HOSTunset it falls back to/var/run/docker.sock, so the defaultdocker-compose.ymlstack continues to work untouched. If you also bind-mount/var/run/docker.sockwhileDOCKER_HOSTis set, the bind-mount is silently ignored —DOCKER_HOSTwins because nosocketPathis forwarded to dockerode at all in that branch. The replacement snippet above already drops the bind-mount; if you instead layer adocker-compose.override.yml, Compose mergesvolumes:lists and the base mount survives, so the live socket file descriptor is still inside the container even though the backend is using the proxy. Replacedocker-compose.ymlwholesale rather than overriding it so there's nothing left for an RCE to fall back to. - Bind-mount paths the backend asks for (
<WORKSPACE_ROOT>/<id>,.uploads/<id>) must still exist on the host — the proxy forwards container-create requests verbatim, so the daemon resolves bind sources from its own filesystem. - Pin the proxy image to an immutable digest
(
@sha256:…) in production. A floating:latestwould let a futuredocker compose pullsilently change the proxy under you, defeating the point of installing it. The example above uses an explicit version tag as the minimum bar — promote to a digest when you adopt this in your own deployment. - A scoped proxy reduces blast radius. It does not make the backend safe to expose without authentication; the tunnel + Access posture above is still load-bearing.