Skip to content

fix(socket-proxy): CONTAINERS=1 allows Alloy's three paths, not archive and export - #1156

Merged
Gerrrt merged 3 commits into
mainfrom
gerrrt/socket-proxy-containers-allowlist
Oct 10, 2026
Merged

Gerrrt merged 3 commits into
mainfrom
gerrrt/socket-proxy-containers-allowlist

Conversation

@Gerrrt

@Gerrrt Gerrrt commented Oct 10, 2026

Copy link
Copy Markdown
Owner

What was open

Every Alloy reaches the Docker API through tecnativa/docker-socket-proxy with CONTAINERS=1, POST=0. In the image's template, CONTAINERS=1 is a prefix match on /containers. So it also allows four GETs that POST=0 does not stop:

  • /containers/{id}/archive
  • /containers/{id}/export
  • /containers/{id}/top
  • /containers/{id}/changes

The daemon serves them as root (Tecnativa/docker-socket-proxy#182; the fix, Tecnativa/docker-socket-proxy#183, is unmerged). That undoes what #188's cap_drop buys: a client trusted only to list containers can read through the proxy what it cannot read through its own mounts.

Measured on prometheus's live proxy, with no file content transferred (HEAD on archive returns only a stat header, and every other body went to /dev/null): all four answered 200.

Found while triaging #1005.

What the clients actually use

From the proxy's own HAProxy request log, since its 2026-10-07 start:

  • /containers/json, /containers/{id}/json and /containers/{id}/logs
  • /networks, /version, /info, /_ping and /events

Nothing else.

The fix

  • stacks/observability/docker-socket-proxy/haproxy.cfg. It is upstream v0.5.0's template byte for byte, except:

    • the bind line is resolved;
    • one marked block replaces the prefix rule with exactly those three /containers paths, anchored, matched on the url-decoded path.

    All the env flags keep their meaning, and the image pin is unchanged.

  • All six compose proxies (observability, lab, sensor, soc, bloodhound, analyst) mount that directory read-only and run haproxy -f on it. A directory mount, so a git checkout can't leave a stale inode behind (A deploy that changes a mounted config file but not compose.yaml leaves the container on the old one #355).

  • deploy-agent.sh stages the file, checksums it, and mounts it into alloy-socket-proxy through a volume, for oracle and trinity.

  • reload-config.sh restarts the proxy before Alloy, so a later change to haproxy.cfg alone still lands. A restart, not SIGUSR2: a rejected reload keeps the old workers and says so only in the log.

How it's checked

scripts/check_socket_proxy.sh is new, and runs in CI, in validate.sh and as make check-socket-proxy. It boots the pinned image on the committed config, with observability's env and the real socket, read-only. Then:

  • 14 allowed requests must answer 200.
  • 15 refused requests must get 403 from the proxy itself, confirmed from its log as <NOSRV>. They are:
    • archive, export, top, changes, stats and attach;
    • json/../archive, logs/../archive, %2Farchive, %252Farchive and json%2F..%2Farchive;
    • bare /containers;
    • POST /containers/create.
  • Drift: it rebuilds upstream's template from our file and requires a byte-for-byte match with the template in the pinned image. A Dependabot bump that changes upstream's rules fails here, rather than being silently overridden.

Run against upstream's own template, the same check fails: every refused path reaches the daemon, and export streams 43 MB. %2Farchive and json/../archive get through too, because the daemon decodes %2F and 301-redirects ... That's why the rule matches on the decoded path, and the id class excludes /, % and ?.

check_compose_health.py now fails a socket proxy that doesn't mount the config read-only, or isn't started on it (5 new self-tests). Either alone silently falls back to the image's template.

Deploying

  • prometheus: converge applies it at the next :29 after merge.
  • alexander, odin, fenrir, garuda and the bloodhound guest: make up STACK=<stack> by hand, from the Mac (VLAN 30).
  • oracle and trinity: scripts/deploy-agent.sh.

After each deploy, check that the Alloy on that host still lists containers and ships container logs.

🤖 Generated with Claude Code

…ve and export

tecnativa/docker-socket-proxy's template matches CONTAINERS=1 as a prefix on
/containers, so GET /containers/{id}/archive, /export, /top and /changes were
allowed. POST=0 does not stop them, and the daemon serves them as root
(Tecnativa/docker-socket-proxy#182). All four answered 200 on prometheus.

Every proxy now runs stacks/observability/docker-socket-proxy/haproxy.cfg.
It is upstream v0.5.0's template with that rule narrowed to
/containers/json, /containers/{id}/json and /containers/{id}/logs, which is
all the proxy's request log shows its clients sending. This covers the six
compose stacks, and deploy-agent.sh's alloy-socket-proxy through a volume.
reload-config.sh restarts the proxy before Alloy, so a cfg-only change lands.

The checks:
- scripts/check_socket_proxy.sh boots the pinned image on the file in CI and
  validate. It asserts that the allowed paths answer and that the refused
  ones, `..` and encoded-slash spellings included, are denied by the proxy
  itself. It also requires upstream's template to be unchanged under ours.
- check_compose_health.py fails a proxy that does not mount the config or is
  not started on it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Copilot AI balanced review requested due to automatic review settings October 10, 2026 16:22
@Gerrrt
Gerrrt enabled auto-merge (squash) October 10, 2026 16:25

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

The checker’s hard-coded Docker API 1.56 probes fail on otherwise supported pre-29 daemons.

2 open findings
What changed in this PR

Hardens Alloy’s Docker socket proxies by restricting CONTAINERS=1 to required endpoints.

Changes:

  • Adds and deploys a narrowed HAProxy configuration across all proxies.
  • Restarts proxies during configuration reloads.
  • Adds validation, CI coverage, and security documentation.
File Description
stacks/​soc/​compose.yaml Mounts and runs the hardened config.
stacks/​sensor/​compose.yaml Mounts and runs the hardened config.
stacks/​observability/​docker-socket-proxy/​haproxy.cfg Defines narrowed container ACLs.
stacks/​observability/​compose.yaml Activates the hardened config.
stacks/​lab/​compose.yaml Mounts and runs the hardened config.
stacks/​bloodhound/​compose.yaml Mounts and runs the hardened config.
stacks/​analyst/​compose.yaml Mounts and runs the hardened config.
scripts/​validate.sh Adds proxy validation.
scripts/​reload-config.sh Restarts the proxy before Alloy.
scripts/​README.md Documents the checker.
scripts/​deploy-agent.sh Deploys the config to agent proxies.
scripts/​check_socket_proxy.sh Tests allowed and refused API paths.
scripts/​check_compose_health.py Enforces proxy mount and command settings.
Makefile Adds the proxy-check target.
docs/​security.md Documents the exposure and mitigation.
docs/​changelog.md Records the security correction.
.github/​workflows/​ci.yml Runs the checker in CI.

🧠 Review effort: Balanced


💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread scripts/check_socket_proxy.sh Outdated
Comment thread stacks/observability/docker-socket-proxy/haproxy.cfg Outdated
Gerrrt and others added 2 commits October 10, 2026 17:22
… v1.56

CI's runner daemon is older than prometheus's, so the versioned requests in
check_socket_proxy.sh got a 400 from the daemon there ("client version too
new"). That is not a proxy decision. The check now reads ApiVersion from
/version through the scratch proxy and builds the /v1.NN/ spellings from it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…oad-config.sh does

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@Gerrrt
Gerrrt merged commit 65b8d0e into main Oct 10, 2026
8 checks passed
@Gerrrt
Gerrrt deleted the gerrrt/socket-proxy-containers-allowlist branch October 10, 2026 21:16
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants