Security defaults¶
CashPilot runs unattended, holds credentials for services that pay you real money, and talks to a worker that drives Docker on that host. The shipped compose files put a Docker socket proxy between the worker and the socket; without it the worker holds the socket itself, which is equivalent to root on that host. So it needs a stated position on what you may switch off, what you may not, and above all what happens when you just follow the quickstart.
The governing principle:
You may choose to weaken your own installation, but never by accident and never by default.
If you genuinely want an open bind address, that is your call. It has to be an explicit, documented decision, not the consequence of not knowing there was a decision to make.
A fresh install with no configuration at all is secure. You should not have to read this page to be safe; you should only need it to deliberately change something.
Tier 1: always on, not configurable¶
These have no off switch. If you need one of them disabled, CashPilot is the wrong tool for that job, and we would rather say so than ship a flag that quietly makes everyone less safe.
| Behaviour | Why there is no toggle |
|---|---|
| Credentials encrypted at rest | The database holds the keys to accounts with money in them. There is no plaintext mode. See the encryption key below. |
Deployed containers drop all capabilities, get no-new-privileges, and a PID limit |
These images are third-party and closed-source. They get the minimum kernel surface. Capabilities are granted per-service from the catalog, never globally. |
| Privileged containers are refused | A privileged container is not isolated in any meaningful sense. No service in the catalog needs one. |
| System volume roots are blocked | A container that can mount /etc or /var/run owns the host. Specific paths can be opted in (tier 2), but the blanket block cannot be removed. |
| Host devices are allowlisted, and per-service | Passing a host device into a container widens what that container can touch, so it is not something to enable casually. A deploy may request a device only if its own catalog entry declares it and the device is on a fixed ceiling. Today that ceiling is /dev/net/tun alone, for Mysterium. One service declaring a device does not let any other request it, and anything outside the ceiling is refused rather than quietly dropped. |
| No telemetry | Absent rather than opt-out. A toggle would imply the capability exists; it does not. There is no code that reports anything about you anywhere. The outbound calls that do exist (the daily release check, a worker's public-IP lookup, exchange rates) carry no identifier, and the first two can be switched off (tier 2). |
| Custom services get the catalog hardening and nothing a catalog entry can grant | An image you add yourself has not been reviewed. It runs with every rule above, no published ports, no host directories and a memory limit, and the worker refuses it any capability, device or host networking. See custom services. |
| Volumes holding irreplaceable state are protected from deletion | Node identities and generated wallets have no server-side copy. See what counts as irreplaceable. |
Tier 2: configurable, secure by default¶
You can change all of these. None of them require changing for a normal install.
| Setting | Default | What changing it costs you |
|---|---|---|
CASHPILOT_BIND_ADDR |
127.0.0.1 |
The dashboard can command a Docker-socket worker. Exposing it publicly exposes the host. Prefer a VPN or an authenticating reverse proxy. |
CASHPILOT_WORKER_BIND_ADDR |
127.0.0.1 |
The worker API is equivalent to root on that machine. Never publish port 8081. |
CASHPILOT_METRICS_ENABLED |
off | /metrics exposes balances and hostnames. If you enable it, set CASHPILOT_METRICS_TOKEN too. |
CASHPILOT_PROXY_BIND_MOUNTS |
empty | Host folders the Docker socket proxy lets containers bind, comma-separated. Each entry also counts as a CASHPILOT_ALLOWED_VOLUME_ROOTS entry and must pass the same checks (a service folder such as /mnt/user/storj, never a whole root). Any container the worker creates may then mount it. |
CASHPILOT_COMPOSE_DIR |
empty | Your compose folder, which the proxy then lets the in-app update bind. Opt-in: it also lets containers bind that folder, which weakens the proxy (see below). Unset, update with docker compose pull && docker compose up -d; the Update button tells you the line to add if you want it. |
CASHPILOT_ALLOWED_VOLUME_ROOTS |
empty | Opts specific host paths past the volume block. Each entry is a deliberate widening; system paths are still refused. |
CASHPILOT_ALLOW_EPHEMERAL_KEY |
off | Lets CashPilot start when the encryption key cannot be persisted. Every credential then dies on the next restart. |
allow_delete_critical (per request) |
refuses | Permits deleting a volume that holds irreplaceable state. There is no undo. |
| Alert delivery (ntfy / webhook / Telegram) | inert | Nothing is sent anywhere until you configure a target. |
CASHPILOT_UPDATE_CHECK |
on | One unauthenticated request a day to GitHub's public releases endpoint, to tell you a newer CashPilot exists. off stops it and the banner never appears. |
CASHPILOT_EGRESS_DETECT (worker) |
on | One request an hour to a public IP-echo service, so the fleet can warn when two workers share an IP. off stops it; CASHPILOT_EGRESS_IP states the address instead. See Egress IP. |
| Image tags | mostly unpinned | Only some service images in this catalog carry a version tag; the rest follow the image's default tag, so an upstream push changes what runs on your next deploy. The desktop app pins every service image by digest. In the web app you can pin one yourself under the service's Advanced box (Configuration). CashPilot's own images are pinned to a release series in both compose files. |
Tier 3: preference, no security dimension¶
Display currency, collection interval, retention window, theme. Set them to whatever you like.
Irreplaceable state¶
Some services keep state that cannot be recovered if it is destroyed: there is no server-side copy and no reset link:
- Storj: the node identity is proof-of-work bound, takes hours to regenerate, and carries the held payout balance.
- Mysterium: the keystore is the node identity.
- Anyone Protocol: the relay identity key carries the relay's earning history.
- ProxyBase Markets: the wallet is generated inside the volume. That volume is the money.
- Sia (hostd): the wallet database and contract state, plus the stored renter data whose loss forfeits the locked collateral.
These are declared as critical_volumes in the service catalog. CashPilot refuses to delete them, and tells you what would have been lost rather than just saying no.
Backing up the encryption key¶
Your credentials are only as recoverable as /data/.fernet_key. Lose it and you re-enter everything, because there is no way to decrypt the stored values without it.
Restoring onto a fresh volume: pass the saved value as CASHPILOT_ENCRYPTION_KEY. An existing key file always wins over the environment variable, so setting it on a running instance is safe and changes nothing.
Note this is a different key from CASHPILOT_SECRET_KEY, which only signs login sessions.
How this is kept honest¶
A stated posture that nobody checks is just prose. tests/test_secure_defaults.py asserts the machine-checkable parts of this page: that every collector credential is either encrypted or explicitly listed as public with a reason, that metrics are off until enabled, that alerting is inert until configured, that the volume allowlist starts empty, that the dashboard binds to loopback in every compose file, and that no telemetry-shaped code exists.
That last mechanism matters more than it looks. The at-rest encryption boundary is a naming convention: a config key is encrypted when it matches a known secret suffix. An audit of this page found that a collector argument named cookie, seed, mnemonic or private_key would have been stored in plaintext, purely because nothing enforced the convention. The suffix list was widened and the test now fails if a new collector adds a credential field that would not be encrypted.
What is deliberately not encrypted¶
Account emails, API URLs, and public relay fingerprints are stored as-is. Emails are identifiers shown back to you in the UI, and fingerprints are published by the networks themselves. Each exception is listed explicitly in the test with its reason, so adding another is a conscious decision rather than an oversight.
Reporting a problem¶
See SECURITY.md. Please do not open a public issue for a vulnerability.
Docker socket proxy¶
The worker creates and runs containers, so something on the host has to hold
the Docker socket. Whoever holds it is root on the host: one
POST /containers/create with "Privileged": true and a bind of / takes the
machine. In the shipped compose files that holder is a proxy, not the worker.
The shipped compose files (docker-compose.yml, docker-compose.fleet.yml,
docker-compose.worker.yml, docker-compose.build.yml) run
sockguard 2.4.6 as
cashpilot-socket-proxy, pinned by digest
(codeswhat/sockguard:2.4.6@sha256:9a7142d9f8254e7fde7543e046eff920463f5df27b36d959915b44fb4878d06f).
It holds the socket read-only, runs as root with every capability dropped and no
network, and serves a filtered socket in the cashpilot-docker-api-owned volume. The
worker mounts that volume and sets DOCKER_HOST to it. It has no Docker socket
of its own.
The proxy reads each request, including its JSON body, so it refuses dangerous containers, not only dangerous URLs:
- Only the calls the worker makes. Every other Docker API call is refused: swarm, services, secrets, plugins, builds, commit, export, events, attach, container update, prune, image push or load. The allow list is the set of calls the live test recorded from the worker, its update helper and Compose.
- Containers. No privileged mode, no host PID, IPC or user namespace
(host networking is allowed, see What a compromised worker can still do
below; the host cgroup namespace is not),
no-new-privilegesrequired, no unconfined seccomp, AppArmor or system paths. Added capabilities only fromNET_ADMIN,NET_RAW,SETUID,SETGID(what the catalog declares), devices only/dev/net/tun, runtimes onlyruncand the gVisor ones (the worker refuses any other runtime itself, before the running container is touched). Host folders only fromCASHPILOT_PROXY_BIND_MOUNTSandCASHPILOT_COMPOSE_DIR, so a bind of/or of the socket is refused as long as neither variable lists/, the socket or a folder above it: the proxy takes both as given. The body is parsed the way Docker parses it, so"privileged": truein lowercase is refused too. - Exec. Only the exact commands the catalog's readers run (Mysterium's node
and wallet reads, Anyone Protocol's
anonrc, the isolation check's firewall read, the tunnel sidecar's readiness file). No environment variables at all, soPATHorLD_PRELOADcannot change what such a command runs. - Files into a container only under
/run/cashpilot-secret(the tunnel sidecar's proxy settings), with no setuid files, device nodes or escaping links.
The policy is the configs block at the end of each compose file, never part
of an image: the worker image is what the policy restricts, so an image update
must not be able to widen it. An image update does not rewrite that file either,
so the worker gets the policy's fingerprint (CASHPILOT_PROXY_POLICY) and
compares it with the policy its own catalog needs. When they differ, its log,
its status page and the dashboard's update card name the release and give the
command that brings your own compose file to that release's policy, changing
nothing else:
docker run --rm -i --network none drumsergio/cashpilot-worker:<version> \
python -m app.socket_policy < docker-compose.yml > docker-compose.new.yml
The image gets no access to the file and no network; read the diff before you
use the new file. The proxy carries the same fingerprint in its environment,
because Compose does not recreate a container when only a config's content
changes: without it, docker compose up -d would leave the proxy on the old
policy. scripts/socket_proxy_policy.py
writes it from the catalog, and tests/test_socket_proxy.py fails when a
catalog change adds a reader the policy lacks, or when anyone loosens it.
scripts/socket_proxy_live_test.sh runs the real proxy against a real daemon:
every call the worker makes passes, every refusal above is refused, and the
same refused requests sent to the raw socket succeed, which proves each refusal
is the proxy's.
When the proxy refuses a deploy, the dashboard shows its reason, for example
The Docker socket proxy refused this: container create denied: bind mount
source "/mnt/user/storj" is not allowlisted. docker logs cashpilot-socket-proxy
has one line per call, refusals included.
Owner isolation. The proxy stamps the label cashpilot.deployed-by=worker
on every container, volume and network the worker creates, and refuses the
worker everything that does not carry it. The worker cannot inspect, read
files from, stop or remove a container it did not start, cannot mount or delete
a volume it did not create, and cannot join the namespace of a container it
does not own. The UI, the proxy itself, a dashboard or auto-updater that holds
the Docker socket, and every container you started by hand are out of its
reach. The worker also lists only its own containers, so it no longer sees
those. Images are not scoped: a custom service can still pull any image.
Not yet a root boundary. The proxy closes the one-call ways to root: a
privileged container, a bind of / or of the socket, an added capability.
Owner isolation closes joining another container's process namespace, which
read a root container's files through /proc/1/root. One two-step way is
left, because sockguard 2.4.6 has no setting for it:
- A symlink in an allowed folder. sockguard checks a bind's source as a
string; Docker follows symlinks when it mounts. A container that binds an
allowed folder can plant
evil -> /in it, and a second container that binds<folder>/evilgets the host's/and its socket. This applies to every folder inCASHPILOT_COMPOSE_DIRandCASHPILOT_PROXY_BIND_MOUNTS. With both unset, no host folder is bindable at all: the stand-ins the compose file uses for them are paths under the null device, where Docker cannot create a folder, so nothing can be planted there.
What the worker itself enforces on top, for deploys sent through its API:
- It refuses any bind of the compose folder, anything under it, or a folder
above it (which would reach the compose file and its
.envtoo). Only the in-app update needs that folder, and the update does not go through a deploy. The proxy still allows the folder, so this stops a deploy sent with a worker key, not a compromised worker. - It lets a deploy bind any path under a
CASHPILOT_PROXY_BIND_MOUNTSfolder, with any image and command. So with that variable set, anyone holding a worker key (the UI, or whoever reads the plain-HTTP traffic between the UI and the worker) can take the symlink route above with two deploys, not only a compromised worker. The blocked system folders (/mnt,/homeand the rest) do not apply inside a folder you listed.
It needs a change in sockguard (binds that are exact or symlink-free). Until then, an install that keeps both variables unset is the one where the proxy stands between the worker and root. That is the default: the in-app update is opt-in for this reason.
What a compromised worker can still do even once those are closed:
- Stop, remove, inspect and read the logs, files and environment variables of the containers it started: every earner on its host.
- Run the allowed reader commands in those containers.
- Delete or mount the volumes it created. The worker refuses to delete the ones the catalog marks critical (Mysterium's keystore, Storj's identity), but the proxy does not know about them, so a compromised worker can delete them directly.
- Start a container on host networking with
NET_ADMIN,NET_RAWand/dev/net/tun, which can reconfigure the host's network but not reach its files. - Write into your compose folder, through a container that binds it. A change
there takes effect the next time you run
docker compose upyourself, which uses the raw socket, and the folder's.envholds whatever keys you put there. LeaveCASHPILOT_COMPOSE_DIRunset if you update by hand, and the folder is not bindable at all. - Create Docker networks, and attach, detach and delete the ones it created.
The LAN fence needs this to build
cashpilot-isolatedand move earners onto it, and sockguard 2.4.6 cannot limit it to that one network. The proxy lets bridge driver options and a forced disconnect through, because the fence uses both; custom drivers, swarm scope, custom address plans and endpoint settings stay refused, except network aliases, which sockguard always lets through. It adds little reach, since the worker can already start a container on host networking, but a compromised worker can take an earner off the fence. - Start a container on host networking. The LAN fence's helper and the
isolation check's control probe need it. The worker itself refuses host
networking to every earner, and the catalog refuses it to any service with
NET_ADMIN, so only those two use it. The proxy no longer allows the host's cgroup namespace: only the Activity reader of a host-network earner needed it, and no earner shares the host's network since Mysterium moved onto the fenced bridge. - Pull images from any registry (custom services need it) and set namespaced sysctls on its own containers (the tunnel sidecar needs it).
What that means for the default install. docker-compose.yml runs the UI
and a worker on the same host. Owner isolation keeps a compromised worker out
of the UI: it cannot read the UI's /data (.fernet_key, the key that
decrypts every service login CashPilot stores), mount cashpilot_data into a
container, or read CASHPILOT_SECRET_KEY, CASHPILOT_API_KEY and
CASHPILOT_ADMIN_API_KEY from the UI container's environment. Two ways in are
left. With CASHPILOT_COMPOSE_DIR set, it can read the .env in your compose
folder and change the compose file, which takes effect at your next
docker compose up. And the symlink gap above, with either bind variable set,
reaches the host's root. For a boundary that holds without those conditions,
run the UI on a host with no worker and put the earners behind
docker-compose.worker.yml on other hosts, as Fleet describes.
A worker still receives the logins of the services you deploy to it, so a
compromised worker leaks those, but not the rest. That holds only while
CASHPILOT_ADMIN_API_KEY differs from CASHPILOT_API_KEY: every worker host
holds the shared key, and the same value would give it the owner role. A worker
that has not enrolled yet, or lost its own key, still accepts the shared key,
so until then a compromised worker that can reach it can stop or remove its
services. Commands and heartbeats both travel over plain HTTP, carrying the
worker's key and those logins, so keep both directions on a LAN you trust or
inside a VPN such as Tailscale, never on the open internet.
What owner isolation costs. On the UI's host, the in-app update updates
the worker only: the UI is not the worker's to recreate, so the dashboard is
updated with docker compose pull && docker compose up -d, and the update card
says so. And an earner you started by hand, or from a compose file CashPilot
exported, is invisible to the worker, so the dashboard no longer shows it as an
external service.
Moving an existing install. Docker cannot add a label to an existing
volume or network, so the compose files run a one-shot cashpilot-migrate
service before the proxy starts. It holds the raw socket only while it runs.
For each earner the worker started it copies every unlabelled volume to a
backup (<volume>-pre-isolation) and checks the copy, recreates the volume
with the label, copies the data back and checks it against the backup, then
recreates the earner as it was. It rebuilds cashpilot-isolated with the label
and puts its members back. Its record of each finished step is a root-only
folder, cashpilot-migrate-state, next to the compose file, so a run killed
at any step is finished by the next one. It never deletes a backup. A volume
another container also uses, or one with a driver or driver options, is left
as it was with a warning. The worker still controls the earner, but refuses
to redeploy it on that volume until you move the data yourself.
Two upstream gaps are not exploitable on current Docker: sockguard does not
decode HostConfig.Capabilities (Docker 29 drops that field), and a lowercase
HTTP method skips body inspection (Docker's router answers 404 to it).
Without the proxy. An install whose compose file predates it, the Unraid
templates, and a worker started with docker run still mount
/var/run/docker.sock into the worker, so the worker is root on the host.
UPGRADING.md
says how to move an existing install. The worker's status page says which one
it uses: Docker access: socket proxy or direct socket (root on this host).
Container runtimes: gVisor where it works¶
CashPilot supports an optional runtime on a deploy spec, allowed only when your
Docker daemon reports it. Nothing selects one for you.
gVisor's runsc runtime puts a user-space kernel between a container and
yours. That is a real extra layer against a container escaping through a kernel
bug. It does
not address the two risks that actually occur in this category: attribution of
other people's traffic to your IP address, and containers reaching your LAN.
Protecting your home network covers those first.
Measured on a standard Linux host, it cost about a fifth of peak throughput and
six to nine times the CPU per byte moved. That is minutes of CPU a month for a
typical bandwidth-sharing service, and more for a busy storage node. Plain
runsc cannot resolve names on a Docker network you created, so on CashPilot's
networks it needs its network passthrough mode. It turns off raw sockets unless
registered with --net-raw, gains little for services on host networking, and
cannot run at all on stock Unraid, where a VM is the better boundary. The figures, the install steps and the details are in
the gVisor section.
If you have installed a runtime, CASHPILOT_CONTAINER_RUNTIME on the worker puts
its bridge-networked services in it, and CASHPILOT_CONTAINER_RUNTIME_OVERRIDES
sets exceptions per service (configuration). A deploy spec can
also name a runtime for one service. Either way you own the outcome. The allowlist is read from your daemon, so
you cannot select something the host does not have; that would only fail later
with an error you could not act on.