Security Notes
September 3, 2026 · View on GitHub
dsh's agents can execute arbitrary commands inside the container — remote code execution is its day job — so follow this baseline when running it:
Networking & exposure
dsh web listens on 127.0.0.1 by default, and upstream dsh rejects
--host 0.0.0.0 (an intentional upstream safety design: without an auth layer it would expose
remote code execution to the network). This image exposes the UI through a Caddy reverse proxy
(0.0.0.0:3081 → 127.0.0.1:3080), and the orchestration examples publish the proxy on host
loopback (127.0.0.1:3081) — LAN/public exposure is an explicit step (change the publish line, or
put a host-side proxy in front). The proxy gzip-compresses UI assets, passes SSE/WebSocket streams
through unbuffered, and restarts itself if it crashes (the container HEALTHCHECK marks it unhealthy
while the proxy stays down).
The proxy is the security boundary
The proxy rewrites the Host and Origin headers to loopback before forwarding. dsh's /api
browser-trust fence checks HTTP headers only (it never looks at the connection's source
address), so from dsh's point of view every proxied request comes from 127.0.0.1 and passes the
trust fence — including the settings/credentials methods that upstream deliberately pins to
loopback (settings/describe, settings/update, credentials.*, agentPreset.*,
host.pickDirectory/host.openPath, llm.discoverModels).
Upstream also requires a browser session: dsh web prints a one-time ?token=... login URL and
mints a signed session cookie from it (30 days by default, backed by a signing secret persisted in
~/.dsh). This image absorbs that flow entirely at the proxy: on every container start the
supervisor (dsh-web) exchanges the printed token against 127.0.0.1:3080 itself and configures
Caddy to inject the resulting session cookie into every proxied request. Browsers never see a
token — opening 3081 is enough and there is no 401/login step left on the proxy path.
Authentication therefore lives only in Caddy (basic auth, or the network exposure you choose):
- anyone who can reach port
3081gets a fully authenticated session — enable basic auth (DSH_PROXY_USER+DSH_PROXY_PASSWORD, set together or the supervisor refuses to start) for any non-loopback deployment; - reading the container logs no longer grants anything extra — there is no token to fish out;
- dsh's own fence is untouched behind the proxy: a direct request to
127.0.0.1:3080(i.e. a peer container on the same Docker network, which bypasses Caddy) still needs the session cookie and gets401without it — the proxy is the only path in.
Upstream dsh's browser code still gates the settings/credentials pages on window.location.hostname,
so the header rewrite and session injection alone would not make those pages usable in a remote
browser. This image therefore runs dsh-client-patch before every dsh web start: it treats
proxied remote browsers as loopback and injects a crypto.randomUUID polyfill for plain-HTTP LAN
use (upstream now ships its own insecure-context randomUuid(); the polyfill stays as insurance
for other bundle code). The patch is best-effort and skips with a warning if upstream changes the
bundle strings.
Consequences:
- Enable basic auth on the proxy by setting both
DSH_PROXY_USERandDSH_PROXY_PASSWORD(recommended for any non-loopback deployment). The supervisor refuses to start when only one is set — a half-configured auth block must never silently start open. Without auth, anyone who can reach3081gets full control — same exposure as before, plus settings/credentials, now without even needing the token from the logs. - Keep the host firewall closed for
3081unless LAN access is actually needed. - For anything beyond the LAN, put an authenticated reverse proxy (e.g. Caddy + basic auth) or an
SSH tunnel in front — don't rely on the raw port; there is still no TLS on
3081itself. - Keep the host-side exposure minimal: the examples already bind to host loopback
(
127.0.0.1:3081:3081) — don't loosen that unless you mean to expose the port.
Don't mount host credentials
Avoid bind-mounting ~/.ssh, ~/.aws, ~/.config/gh, cloud vendor credentials, etc.
(see the official Claude Code container security warning).
Agents can read everything reachable inside the container, including mounted credentials.
For the same reason, don't bind-mount the host Docker socket (/var/run/docker.sock) or run the
container with --privileged: either gives the agent a straightforward path to the host's root
account.
Container-internal privileges
The Debian slim image gives uid 1000 passwordless sudo via the sudo group
(%sudo ALL=(ALL) NOPASSWD:ALL), so USER 1000 is not a privilege boundary inside the container.
Treat the container root as reachable by the agent; for stricter sandboxes, remove that sudoers
entry or use an additional isolation layer (user namespace, VM, etc.).
In-container rootless podman
The image installs podman and configures subuid/subgid for uid 1000, so the agent can use podman
for nested containers. Whether podman run actually works depends on the host runtime: it needs
nested user namespaces and appropriate seccomp/AppArmor settings. Docker's default seccomp profile
or a locked-down Kubernetes runtime may reject the unshare/clone calls. Treat podman as an
experimental in-container dev tool, not a guaranteed isolation primitive.
~/.dsh holds API keys
The persisted /home/dsh volume contains ~/.dsh — model API credentials, profiles and
session data — plus ~/.cargo, pnpm store/cache, dotfiles, and folders created by dsh under $HOME.
Mind the volume's access permissions and backups; don't share it with untrusted containers.
Only run trusted repositories
Even with the approval mechanism in place, don't let agents process untrusted code repositories.
Build-time source trust
The image builds dsh from the official deepseek-ai/deepseek-harness repository at a pinned
dsh-v* tag. Runtime updates happen only by replacing the image; the container does not run a
boot-time package update. Treat the upstream repository and its pnpm dependency tree as the
supply-chain trust boundary.
Remote access
By default the proxy is published on host loopback only (127.0.0.1:3081) — other machines cannot
reach it, and serving them is an explicit step. When you do:
- plain LAN publishing (
"3081:3081"): enable basic auth and keep the port behind the host firewall when unused; - public HTTPS (a host-side nginx in front): keep the container port on loopback and follow deployment.md "Public deployment (nginx in front)" — rate limiting/banning and basic auth both stay on;
- or don't expose at all: an SSH tunnel (
ssh -L 3081:127.0.0.1:3081 ...) or a VPN (WireGuard/Tailscale) is the strongest option for a single user; - if you front it with an external reverse proxy, forward the WebSocket upgrade headers
(
Upgrade/Connection) and raise the idle timeouts — without them the UI's event streams silently fail (see deployment.md for a working nginx example).