Skip to content

fix(container-logs): let the grant serve a real Docker client - #468

Merged
pofallon merged 1 commit into
mainfrom
fix/docker-proxy-head-and-info
Sep 20, 2026
Merged

pofallon merged 1 commit into
mainfrom
fix/docker-proxy-head-and-info

Conversation

@pofallon

Copy link
Copy Markdown
Contributor

Why now

container-logs@1 has shipped since 0.11.0-rc.1 — proxy, grant, consent, labels, tests — with no provider to exercise it. I packaged one (Dozzle, try-hola/apps#160) and installed it on a VM. It crash-looped:

{"level":"fatal","message":"Could not connect to any Docker Engine"}
[docker-proxy] denied: HEAD /_ping
[docker-proxy] denied: GET /v1.52/info

Three defects, all shape rather than policy — nothing here widens what the grant discloses.

1. HEAD was refused

The allowlist was GET-only. Docker's own client pings with HEAD /_ping before anything else, so every standard client was rejected on its first call — and reported it as "no engine", not as a refusal.

HEAD is now allowed wherever GET is. It returns headers and no body, so it reveals strictly less than the GET it mirrors, and it opens nothing new: HEAD /containers/{id}/archive is still 403.

2. GET /info was refused outright

A client on Docker's SDK calls it to decide an engine is really there, and exits without it. Refusing it meant the grant could not serve the clients it exists for.

It is now rebuilt from an allowlist, exactly as inspect is. Kept: Name, ServerVersion, OSType, OperatingSystem, Architecture, NCPU, MemTotal, container counts — enough to identify and size the engine. Dropped: RegistryConfig, Labels, Plugins, DockerRootDir, SecurityOptions, Swarm, KernelVersion, and above all HttpProxy/HttpsProxy, which routinely embed credentials.

3. The redacted inspect was shape-incompatible

It omitted HostConfig, Mounts and NetworkSettings entirely. A real daemon always returns them, so clients walk them unchecked — Dozzle segfaults on HostConfig.PortBindings:

panic: runtime error: invalid memory address or nil pointer dereference
docker.newContainerFromJSON(...) /dozzle/internal/container/docker/client.go:675

Dropping the field denied the client, not the data. They are now present but empty{ PortBindings: {} }, [], { Networks: {} } — which puts the same nothing on the wire in a shape an SDK client can parse. No host port map, no bind sources, no network topology.

Verified on a VM, against the real app

before after
dozzle crash-loop, "Could not connect to any Docker Engine" Connected to Docker, Accepting connections on :8080, healthy
a neighbour app's logs read through the grant, with sh.hola.app / sh.hola.deployment labels intact
neighbour's inspect no Env

And the envelope still holds — probed from inside the deployment:

HEAD   /_ping                                     200
GET    /info                                      200   (redacted)
GET    /containers/json                           200
GET    /v1.52/containers/json                     200
GET    /containers/<id>/json                      200   (redacted)
GET    /containers/<id>/logs                      200
GET    /containers/<id>/stats                     403
GET    /containers/<id>/archive?path=/etc/passwd  403
GET    /images/json                               403
GET    /secrets                                   403
GET    /volumes                                   403
POST   /containers/<id>/restart                   403
POST   /containers/<id>/exec                      403
DELETE /containers/<id>                           403

Left deliberately closed

/containers/{id}/stats stays 403, so Dozzle shows logs without resource graphs. It is benign data (counters for containers the caller can already list) and would be easy to add, but nothing needs it to function and I would rather widen the envelope on a real request than on a nice-to-have. Worth a decision alongside #438.

Tests

docker-proxy.test.ts 26 pass: HEAD allowed on each allowed path and on none of the refused ones; every mutating verb still refused; /info allowed versioned and unversioned; redactInfo keeps the identifying fields, drops the proxy URLs (asserted by searching the serialized body for the credential), drops host configuration; inspect keeps the structural fields present-but-empty while leaking no port map, bind source or IP. Three existing assertions were updated — they pinned the absent-field shape this fixes.

Full gate green: typecheck, lint, typecheck, test (983 server + 360 web), build.

🤖 Generated with Claude Code

https://claude.ai/code/session_01Vck5KSX2CLxhohx14nb5Sh

The container-logs@1 proxy has shipped since 0.11.0-rc.1 with no provider to
exercise it. Packaging one (Dozzle, try-hola/apps#160) and installing it on a
VM found three ways the envelope refused the clients it exists for. All three
were shape, not policy — nothing here widens what the grant discloses.

1. HEAD was refused. Docker's own client pings with `HEAD /_ping` before
   anything else, so a GET-only allowlist rejected every standard client on its
   first call, and the client reported it as "Could not connect to any Docker
   Engine" — not as a refusal. HEAD is now allowed wherever GET is: it returns
   headers and no body, so it reveals strictly less than the GET it mirrors.

2. GET /info was refused outright. A client on Docker's SDK calls it to decide
   an engine is really there and exits without it. It is now rebuilt from an
   allowlist, the same way inspect is: what identifies and sizes the engine
   survives (name, version, OS, arch, cpu/memory, container counts) and
   everything describing how the host is configured does not — RegistryConfig,
   Labels, Plugins, DockerRootDir, SecurityOptions, Swarm, and above all
   HttpProxy/HttpsProxy, which routinely embed credentials.

3. The redacted inspect omitted HostConfig, Mounts and NetworkSettings
   entirely. A real daemon always returns them, so clients walk them without
   checking — Dozzle segfaults on HostConfig.PortBindings. Dropping the field
   denied the client, not the data. They are now present but empty: no host
   port map, no bind sources, no network topology, and the same nothing on the
   wire, in a shape an SDK client can parse.

Verified on a disposable VM against the real app: before, dozzle crash-looped
on "Could not connect"; after, it reports "Connected to Docker", serves, and
reads a neighbouring app's logs with sh.hola.app labels intact. The envelope
still holds — stats, archive, images, secrets, volumes, POST restart, POST
exec and DELETE all answer 403, and the neighbour's inspect carries no Env.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Vck5KSX2CLxhohx14nb5Sh
@pofallon
pofallon merged commit 2ca7927 into main Sep 20, 2026
3 checks passed
@pofallon
pofallon deleted the fix/docker-proxy-head-and-info branch September 20, 2026 01:56
pofallon added a commit that referenced this pull request Sep 20, 2026
Carries #468: the container-logs@1 grant can now serve a real Docker client.

The contract has shipped since 0.11.0-rc.1 with no provider to exercise it.
Packaging one (Dozzle, try-hola/apps#160) found three ways the proxy refused
the clients it exists for — HEAD was rejected outright, so every Docker client
failed on its first call; `/info` was refused, which an SDK client treats as
"no engine"; and the redacted inspect omitted structural fields clients walk
without nil-checking, segfaulting them. All three were shape, not policy: the
grant discloses exactly what it did before, and stats, archive, images,
secrets, volumes, restart, exec and delete were all re-verified at 403 on a VM
after the fix.

This is the release a container-logs provider needs. On rc.6 or earlier such
an app installs and its proxy sidecar appears, but the app itself crash-loops.


Claude-Session: https://claude.ai/code/session_01Vck5KSX2CLxhohx14nb5Sh

Co-authored-by: Paul O'Fallon <paulofallon@gmail.com>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
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.

1 participant