From df18e6f6d869f9a4b50bab87b0fbc440f0f6e64d Mon Sep 17 00:00:00 2001 From: GeiserX <9169332+GeiserX@users.noreply.github.com> Date: Sat, 7 Nov 2026 14:29:31 +0100 Subject: [PATCH 1/2] deps(proxy): move the socket proxy to sockguard 2.4.6, which stops a malformed zstd frame crashing it sockguard 2.4.6 updates klauspost/compress to 1.20.2 for GO-2026-6865 / CVE-2026-116591, a panic on malformed zstd frames. It changes no code or setting beyond that, so the policy and the limits the docs name are the same. Pinned by tag and the multi-arch index digest in every compose file and both store templates. SECURITY.md still listed joining another container's process namespace as an open way to root; owner isolation closed it, as the security defaults page already says. --- SECURITY.md | 2 +- app/socket_policy.py | 2 +- contrib/app-stores/casaos/CashPilot/docker-compose.yml | 2 +- contrib/app-stores/runtipi/cashpilot/docker-compose.yml | 2 +- docker-compose.build.yml | 2 +- docker-compose.fleet.yml | 2 +- docker-compose.worker.yml | 2 +- docker-compose.yml | 2 +- docs/comparison.md | 2 +- docs/security-defaults.md | 8 ++++---- 10 files changed, 13 insertions(+), 13 deletions(-) diff --git a/SECURITY.md b/SECURITY.md index 46285397..1cd34737 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -81,7 +81,7 @@ An embargo period applies between fix and disclosure. The reporter will be notif The CashPilot worker (`drumsergio/cashpilot-worker`) creates and manages containers, so it needs the Docker API. Raw access to the Docker socket (`/var/run/docker.sock`) is root on the host. The UI never touches Docker; it commands workers over their authenticated API. -**The shipped compose files do not give the worker the socket.** A Docker socket proxy, [sockguard](https://github.com/CodesWhat/sockguard) `codeswhat/sockguard:2.4.5` pinned by digest, holds it read-only and serves the worker a filtered socket. It refuses every Docker API call the worker does not make, and it reads request bodies, so it refuses privileged containers, binds of `/` or of the socket, capabilities and devices outside the catalog's, host namespaces, a container without `no-new-privileges`, and exec of anything but the catalog's reader commands. [Security defaults: Docker socket proxy](docs/security-defaults.md#docker-socket-proxy) has the full policy, how it is tested, and what a compromised worker can still do through it. It is not yet a full root boundary: on sockguard 2.4.5 a compromised worker can still reach root in two steps, by joining another root container's process namespace or through a symlink planted in a host folder the proxy allows, so it narrows the risk rather than removing it. +**The shipped compose files do not give the worker the socket.** A Docker socket proxy, [sockguard](https://github.com/CodesWhat/sockguard) `codeswhat/sockguard:2.4.6` pinned by digest, holds it read-only and serves the worker a filtered socket. It refuses every Docker API call the worker does not make, and it reads request bodies, so it refuses privileged containers, binds of `/` or of the socket, capabilities and devices outside the catalog's, host namespaces, a container without `no-new-privileges`, and exec of anything but the catalog's reader commands. [Security defaults: Docker socket proxy](docs/security-defaults.md#docker-socket-proxy) has the full policy, how it is tested, and what a compromised worker can still do through it. It is not yet a full root boundary: on sockguard 2.4.6 a compromised worker can still reach root in two steps, through a symlink planted in a host folder the proxy allows (owner isolation closes the other one, joining another container's process namespace), so it narrows the risk rather than removing it. Other mitigations: diff --git a/app/socket_policy.py b/app/socket_policy.py index dd593adc..1924c0ff 100644 --- a/app/socket_policy.py +++ b/app/socket_policy.py @@ -74,7 +74,7 @@ ("GET", "/networks"), ("GET", "/networks/*"), # The LAN fence (app/lan_fence.py) creates cashpilot-isolated, moves - # earners onto it and recreates it after a prune. sockguard 2.4.5 cannot + # earners onto it and recreates it after a prune. sockguard 2.4.6 cannot # limit these by network name, so they are allowed for any network. ("POST", "/networks/create"), ("POST", "/networks/*/connect"), diff --git a/contrib/app-stores/casaos/CashPilot/docker-compose.yml b/contrib/app-stores/casaos/CashPilot/docker-compose.yml index 74d02772..180877fc 100644 --- a/contrib/app-stores/casaos/CashPilot/docker-compose.yml +++ b/contrib/app-stores/casaos/CashPilot/docker-compose.yml @@ -85,7 +85,7 @@ services: # Same service, same policy as the repository's docker-compose.yml; # scripts/socket_proxy_policy.py writes the policy into both. cashpilot-socket-proxy: - image: codeswhat/sockguard:2.4.5@sha256:d15f93e856626ff21f48400034d887874a4dafbbf6bb1e31799511beea07d84c + image: codeswhat/sockguard:2.4.6@sha256:9a7142d9f8254e7fde7543e046eff920463f5df27b36d959915b44fb4878d06f container_name: cashpilot-socket-proxy # Root with no capabilities: uid 0 opens the root-owned host socket, and # group 1000 (the worker's) lets it give the worker its socket. diff --git a/contrib/app-stores/runtipi/cashpilot/docker-compose.yml b/contrib/app-stores/runtipi/cashpilot/docker-compose.yml index 79b812ce..f217b5fc 100644 --- a/contrib/app-stores/runtipi/cashpilot/docker-compose.yml +++ b/contrib/app-stores/runtipi/cashpilot/docker-compose.yml @@ -51,7 +51,7 @@ services: # Same service, same policy as the repository's docker-compose.yml; # scripts/socket_proxy_policy.py writes the policy into both. cashpilot-socket-proxy: - image: codeswhat/sockguard:2.4.5@sha256:d15f93e856626ff21f48400034d887874a4dafbbf6bb1e31799511beea07d84c + image: codeswhat/sockguard:2.4.6@sha256:9a7142d9f8254e7fde7543e046eff920463f5df27b36d959915b44fb4878d06f container_name: cashpilot-socket-proxy # Root with no capabilities: uid 0 opens the root-owned host socket, and # group 1000 (the worker's) lets it give the worker its socket. diff --git a/docker-compose.build.yml b/docker-compose.build.yml index 62e9dfe7..0f2ee66c 100644 --- a/docker-compose.build.yml +++ b/docker-compose.build.yml @@ -95,7 +95,7 @@ services: # Pinned by digest and bumped on purpose; the in-app update never recreates # it (it runs `up --no-deps` on the UI and worker only). cashpilot-socket-proxy: - image: codeswhat/sockguard:2.4.5@sha256:d15f93e856626ff21f48400034d887874a4dafbbf6bb1e31799511beea07d84c + image: codeswhat/sockguard:2.4.6@sha256:9a7142d9f8254e7fde7543e046eff920463f5df27b36d959915b44fb4878d06f container_name: cashpilot-socket-proxy # Root with no capabilities: uid 0 opens the root-owned host socket, and # group 1000 (the worker's) lets it give the worker its socket. diff --git a/docker-compose.fleet.yml b/docker-compose.fleet.yml index fda0f4e3..49beb609 100644 --- a/docker-compose.fleet.yml +++ b/docker-compose.fleet.yml @@ -102,7 +102,7 @@ services: # Pinned by digest and bumped on purpose; the in-app update never recreates # it (it runs `up --no-deps` on the UI and worker only). cashpilot-socket-proxy: - image: codeswhat/sockguard:2.4.5@sha256:d15f93e856626ff21f48400034d887874a4dafbbf6bb1e31799511beea07d84c + image: codeswhat/sockguard:2.4.6@sha256:9a7142d9f8254e7fde7543e046eff920463f5df27b36d959915b44fb4878d06f container_name: cashpilot-socket-proxy # Root with no capabilities: uid 0 opens the root-owned host socket, and # group 1000 (the worker's) lets it give the worker its socket. diff --git a/docker-compose.worker.yml b/docker-compose.worker.yml index 4081a520..61499bd1 100644 --- a/docker-compose.worker.yml +++ b/docker-compose.worker.yml @@ -54,7 +54,7 @@ services: # Pinned by digest and bumped on purpose; the in-app update never recreates # it (it runs `up --no-deps` on the UI and worker only). cashpilot-socket-proxy: - image: codeswhat/sockguard:2.4.5@sha256:d15f93e856626ff21f48400034d887874a4dafbbf6bb1e31799511beea07d84c + image: codeswhat/sockguard:2.4.6@sha256:9a7142d9f8254e7fde7543e046eff920463f5df27b36d959915b44fb4878d06f container_name: cashpilot-socket-proxy # Root with no capabilities: uid 0 opens the root-owned host socket, and # group 1000 (the worker's) lets it give the worker its socket. diff --git a/docker-compose.yml b/docker-compose.yml index 2ce2af05..5b322d12 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -117,7 +117,7 @@ services: # Pinned by digest and bumped on purpose; the in-app update never recreates # it (it runs `up --no-deps` on the UI and worker only). cashpilot-socket-proxy: - image: codeswhat/sockguard:2.4.5@sha256:d15f93e856626ff21f48400034d887874a4dafbbf6bb1e31799511beea07d84c + image: codeswhat/sockguard:2.4.6@sha256:9a7142d9f8254e7fde7543e046eff920463f5df27b36d959915b44fb4878d06f container_name: cashpilot-socket-proxy # Root with no capabilities: uid 0 opens the root-owned host socket, and # group 1000 (the worker's) lets it give the worker its socket. diff --git a/docs/comparison.md b/docs/comparison.md index d66f993e..d89b7286 100644 --- a/docs/comparison.md +++ b/docs/comparison.md @@ -43,7 +43,7 @@ CashPilot's column describes v1.64.0, except the proxy, Docker socket and LAN is | Daily reward claiming | No | Yes, sidecars ([README][m4b-features]) | Yes ([README][igm-features], [compose][igm-sidecars]) | No | No | No | | Credentials at rest | Encrypted (Fernet) in the UI's database; the key file sits in the same data volume ([code][cp-fernet]). Earner credentials reach each worker as container environment variables, so they are plain text in that worker's Docker config (`docker inspect`) | Encrypted (Fernet); the key file sits in the data directory ([code][m4b-crypto]) | Encrypted (AES-256 with openssl); the key file sits beside the vault ([code][igm-encryptor]) | Plain text in `properties.conf`; some passwords are passed as `docker run` arguments ([script][ii-password-arg]) | Plain text in `.env` ([file][cf-env]) | Add-on options, stored by the Supervisor in `/data/options.json` ([HA docs][ha-options]) | | Earner container settings | Every capability dropped, then the catalog adds back what a service needs; `no-new-privileges`; a pids limit; privileged refused ([code][cp-hardening], [code][cp-privileged]). Mysterium runs with host networking and `NET_ADMIN`, `SETUID` and `SETGID` ([catalog][cp-myst]) | `no-new-privileges` on every non-privileged service ([code][m4b-nnp]); the proxy container is privileged with `NET_ADMIN` ([template][m4b-tun]) | Nothing set on app services; Mysterium gets `NET_ADMIN` ([compose][igm-hosting-myst]); the proxy container gets `NET_ADMIN`, with `no-new-privileges` and a pids limit ([compose][igm-proxy]) | Ebesucher runs with `seccomp=unconfined` ([script][ii-seccomp]); proxy containers and Mysterium get `NET_ADMIN` | Nothing set | No add-on asks for privileged, host network or Docker API access ([example config][ha-config]) | -| Docker socket mounted into | Since v1.71.0, a socket proxy (sockguard 2.4.5, pinned by digest), read-only. The worker gets a filtered copy that refuses privileged containers, binds of `/` or of the socket, and capabilities or devices outside the catalog's ([compose][cp-sock], [details](security-defaults.md)); never an earner ([code][cp-mounts]) | Watchtower ([template][m4b-watchtower]) | Watchtower, which is read-only with `no-new-privileges` ([compose][igm-watchtower]) | Five restart helper containers ([script][ii-sock-helper]) and Watchtower ([script][ii-watchtower]) | Portainer, published on ports 8000, 9000 and 9443 on all interfaces ([compose][cf-portainer]), and two Watchtowers ([compose][cf-watchtower-ps], [compose][cf-watchtower-efm]) | None | +| Docker socket mounted into | Since v1.71.0, a socket proxy (sockguard 2.4.6, pinned by digest), read-only. The worker gets a filtered copy that refuses privileged containers, binds of `/` or of the socket, and capabilities or devices outside the catalog's ([compose][cp-sock], [details](security-defaults.md)); never an earner ([code][cp-mounts]) | Watchtower ([template][m4b-watchtower]) | Watchtower, which is read-only with `no-new-privileges` ([compose][igm-watchtower]) | Five restart helper containers ([script][ii-sock-helper]) and Watchtower ([script][ii-watchtower]) | Portainer, published on ports 8000, 9000 and 9443 on all interfaces ([compose][cf-portainer]), and two Watchtowers ([compose][cf-watchtower-ps], [compose][cf-watchtower-efm]) | None | | Image tags | 4 of the 17 earner images are pinned to a version or digest; the rest float | All 15 app images use `:latest` ([example][m4b-image]); proxy container pinned to v2.6.0, Watchtower to `:1` | No tag, so Docker uses `latest` ([compose][igm-hosting]); a tag is set only for some ARM builds | Mostly `:latest` ([example][ii-seccomp]); EarnApp `:lite`, Honeygain pinned to 0.6.6 when proxied on amd64 ([script][ii-honeygain]), Traffmonetizer per-arch tags on ARM; Watchtower `:nightly` | `:latest` or no tag, except EarnApp `:lite` ([compose][cf-compose]) | One version per add-on, bumped by a daily CI job ([workflow][ha-bump], [example][ha-dockerfile]) | | App image updates | Pulled again on each deploy or redeploy; no automatic updater | Watchtower ([template][m4b-watchtower]) | Watchtower every 4 hours ([compose][igm-watchtower]) | Watchtower once a day, if you turn it on ([script][ii-watchtower]) | Two Watchtowers, each limited to one app, PacketStream ([compose][cf-watchtower-ps]) and EarnFM ([compose][cf-watchtower-efm]); the other 9 apps are not updated | Home Assistant's add-on updates | | LAN isolation | On by default since v1.70.0 for new installs and for upgraded installs with no earner on Docker's default bridge: the worker puts earners on its own bridge and writes a firewall table that drops private ranges, the tailnet range, cloud metadata, the host's connected subnets, the host itself and, with public IP detection on, the home's public IP ([isolation](isolation.md#lan-isolation-by-default), [code][cp-fence]). An upgraded install whose earners already sit on the default bridge stays there until you press **Turn on isolation**; `CASHPILOT_ISOLATION=off` turns it off; with `CASHPILOT_CONTAINER_NETWORK` set, CashPilot writes no fence for that network and the firewall rules are yours ([isolation](isolation.md#modes), [code][cp-fence-mode]). Host-networked Mysterium is not covered. A check shows what earners can reach ([check](isolation-check.md)) | Own bridge subnet ([config][m4b-subnet]), which does not block the LAN | None merged | None found | None found | None found | diff --git a/docs/security-defaults.md b/docs/security-defaults.md index 589bdddd..b966eb8d 100644 --- a/docs/security-defaults.md +++ b/docs/security-defaults.md @@ -101,9 +101,9 @@ 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](https://github.com/CodesWhat/sockguard) 2.4.5 as +[sockguard](https://github.com/CodesWhat/sockguard) 2.4.6 as `cashpilot-socket-proxy`, pinned by digest -(`codeswhat/sockguard:2.4.5@sha256:d15f93e856626ff21f48400034d887874a4dafbbf6bb1e31799511beea07d84c`). +(`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 @@ -164,7 +164,7 @@ those. Images are not scoped: a custom service can still pull any image. 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.5 has no setting for it: +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 @@ -198,7 +198,7 @@ stands between the worker and root. 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-isolated` and move earners onto - it, and sockguard 2.4.5 cannot limit it to that one network. The proxy lets bridge driver options and + 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 From 8cee1ab2f3ace23fa6d73226e04bb1545b2203cc Mon Sep 17 00:00:00 2001 From: GeiserX <9169332+GeiserX@users.noreply.github.com> Date: Sat, 7 Nov 2026 14:51:30 +0100 Subject: [PATCH 2/2] docs(comparison): keep the socket row on 2.4.5, the version its v1.71.1 snapshot pins --- docs/comparison.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/comparison.md b/docs/comparison.md index d89b7286..d66f993e 100644 --- a/docs/comparison.md +++ b/docs/comparison.md @@ -43,7 +43,7 @@ CashPilot's column describes v1.64.0, except the proxy, Docker socket and LAN is | Daily reward claiming | No | Yes, sidecars ([README][m4b-features]) | Yes ([README][igm-features], [compose][igm-sidecars]) | No | No | No | | Credentials at rest | Encrypted (Fernet) in the UI's database; the key file sits in the same data volume ([code][cp-fernet]). Earner credentials reach each worker as container environment variables, so they are plain text in that worker's Docker config (`docker inspect`) | Encrypted (Fernet); the key file sits in the data directory ([code][m4b-crypto]) | Encrypted (AES-256 with openssl); the key file sits beside the vault ([code][igm-encryptor]) | Plain text in `properties.conf`; some passwords are passed as `docker run` arguments ([script][ii-password-arg]) | Plain text in `.env` ([file][cf-env]) | Add-on options, stored by the Supervisor in `/data/options.json` ([HA docs][ha-options]) | | Earner container settings | Every capability dropped, then the catalog adds back what a service needs; `no-new-privileges`; a pids limit; privileged refused ([code][cp-hardening], [code][cp-privileged]). Mysterium runs with host networking and `NET_ADMIN`, `SETUID` and `SETGID` ([catalog][cp-myst]) | `no-new-privileges` on every non-privileged service ([code][m4b-nnp]); the proxy container is privileged with `NET_ADMIN` ([template][m4b-tun]) | Nothing set on app services; Mysterium gets `NET_ADMIN` ([compose][igm-hosting-myst]); the proxy container gets `NET_ADMIN`, with `no-new-privileges` and a pids limit ([compose][igm-proxy]) | Ebesucher runs with `seccomp=unconfined` ([script][ii-seccomp]); proxy containers and Mysterium get `NET_ADMIN` | Nothing set | No add-on asks for privileged, host network or Docker API access ([example config][ha-config]) | -| Docker socket mounted into | Since v1.71.0, a socket proxy (sockguard 2.4.6, pinned by digest), read-only. The worker gets a filtered copy that refuses privileged containers, binds of `/` or of the socket, and capabilities or devices outside the catalog's ([compose][cp-sock], [details](security-defaults.md)); never an earner ([code][cp-mounts]) | Watchtower ([template][m4b-watchtower]) | Watchtower, which is read-only with `no-new-privileges` ([compose][igm-watchtower]) | Five restart helper containers ([script][ii-sock-helper]) and Watchtower ([script][ii-watchtower]) | Portainer, published on ports 8000, 9000 and 9443 on all interfaces ([compose][cf-portainer]), and two Watchtowers ([compose][cf-watchtower-ps], [compose][cf-watchtower-efm]) | None | +| Docker socket mounted into | Since v1.71.0, a socket proxy (sockguard 2.4.5, pinned by digest), read-only. The worker gets a filtered copy that refuses privileged containers, binds of `/` or of the socket, and capabilities or devices outside the catalog's ([compose][cp-sock], [details](security-defaults.md)); never an earner ([code][cp-mounts]) | Watchtower ([template][m4b-watchtower]) | Watchtower, which is read-only with `no-new-privileges` ([compose][igm-watchtower]) | Five restart helper containers ([script][ii-sock-helper]) and Watchtower ([script][ii-watchtower]) | Portainer, published on ports 8000, 9000 and 9443 on all interfaces ([compose][cf-portainer]), and two Watchtowers ([compose][cf-watchtower-ps], [compose][cf-watchtower-efm]) | None | | Image tags | 4 of the 17 earner images are pinned to a version or digest; the rest float | All 15 app images use `:latest` ([example][m4b-image]); proxy container pinned to v2.6.0, Watchtower to `:1` | No tag, so Docker uses `latest` ([compose][igm-hosting]); a tag is set only for some ARM builds | Mostly `:latest` ([example][ii-seccomp]); EarnApp `:lite`, Honeygain pinned to 0.6.6 when proxied on amd64 ([script][ii-honeygain]), Traffmonetizer per-arch tags on ARM; Watchtower `:nightly` | `:latest` or no tag, except EarnApp `:lite` ([compose][cf-compose]) | One version per add-on, bumped by a daily CI job ([workflow][ha-bump], [example][ha-dockerfile]) | | App image updates | Pulled again on each deploy or redeploy; no automatic updater | Watchtower ([template][m4b-watchtower]) | Watchtower every 4 hours ([compose][igm-watchtower]) | Watchtower once a day, if you turn it on ([script][ii-watchtower]) | Two Watchtowers, each limited to one app, PacketStream ([compose][cf-watchtower-ps]) and EarnFM ([compose][cf-watchtower-efm]); the other 9 apps are not updated | Home Assistant's add-on updates | | LAN isolation | On by default since v1.70.0 for new installs and for upgraded installs with no earner on Docker's default bridge: the worker puts earners on its own bridge and writes a firewall table that drops private ranges, the tailnet range, cloud metadata, the host's connected subnets, the host itself and, with public IP detection on, the home's public IP ([isolation](isolation.md#lan-isolation-by-default), [code][cp-fence]). An upgraded install whose earners already sit on the default bridge stays there until you press **Turn on isolation**; `CASHPILOT_ISOLATION=off` turns it off; with `CASHPILOT_CONTAINER_NETWORK` set, CashPilot writes no fence for that network and the firewall rules are yours ([isolation](isolation.md#modes), [code][cp-fence-mode]). Host-networked Mysterium is not covered. A check shows what earners can reach ([check](isolation-check.md)) | Own bridge subnet ([config][m4b-subnet]), which does not block the LAN | None merged | None found | None found | None found |