Summary

Dozzle label filters do not restrict container event and statistics streams

Advisory details

Summary

Dozzle supports per-user label filters in users.yml that are documented as an access-control boundary: "Filters are used to restrict the containers that a user can see" and "the guest user can only see containers with the label com.example.app … useful for restricting access to specific containers" (docs/guide/authentication.md). This is the mechanism operators use to give different users/tenants visibility into disjoint subsets of containers on the same host.

The events stream handler streamEvents (GET /api/events/stream) honors that filter for the initial container list and for the incremental containers-changed updates, but it forwards two other channels — container-stat (live per-container CPU, memory, network and disk telemetry) and container-event (container lifecycle events: start/die/destroy/rename/pause/unpause, with the container's full attribute/label set) — to every authenticated client unconditionally, with no comparison against the caller's label filter. The upstream subscription SubscribeEventsAndStats fans out across every Docker client/host and never receives a label filter at all.

As a result, any authenticated user — including one explicitly constrained to a single label scope — receives live resource telemetry for every container on every monitored host, plus lifecycle events carrying each container's name, image and complete label map. This crosses the exact isolation boundary the filter feature is documented to enforce. The leak requires no special role (it is not gated behind the shell/actions/download roles) and works on the local Docker host, so it is distinct from the previously-patched agent-path exec/attach bypass (CVE-2026-24740 / GHSA-m855-r557-5rc5) and from the exec/attach CSWSH issue (CVE-2026-44985 / GHSA-j643-x8pv-8m67).

Affected code (v10.6.5)

internal/web/events.go — streamEvents. The handler resolves the caller's userLabels and applies them to the initial list (ListAllContainers(userLabels)) and to the containers-changed increment (ListContainersForHost(event.Host, userLabels)), but forwards container-stat and the raw container-event with no filter check:

h.hostService.SubscribeEventsAndStats(r.Context(), events, stats)   // no labels passed
...
userLabels := h.config.Labels
if h.config.Authorization.Provider != NONE {
    user := auth.UserFromContext(r.Context())
    if user.ContainerLabels.Exists() {
        userLabels = user.ContainerLabels
    }
}
allContainers, errors := h.hostService.ListAllContainers(userLabels)   // filtered (correct)
...
case stat := <-stats:
    if err := sseWriter.Event("container-stat", stat); err != nil {     // NOT filtered
        ...
    }
case event, ok := <-events:
    ...
    switch event.Name {
    case "start", "die", "destroy", "rename", "pause", "unpause":
        if event.Name == "start" || event.Name == "rename" {
            if containers, err := h.hostService.ListContainersForHost(event.Host, userLabels); err == nil {
                ... sseWriter.Event("containers-changed", containers) ... // filtered (correct)
            }
        }
        if err := sseWriter.Event("container-event", event); err != nil { // NOT filtered
            ...
        }

internal/support/docker/multi_host_service.go — SubscribeEventsAndStats subscribes to events and stats from every client with no label filter argument:

func (m *MultiHostService) SubscribeEventsAndStats(ctx context.Context, events chan<- container.ContainerEvent, stats chan<- container.ContainerStat) {
    for _, client := range m.manager.List() {
        client.SubscribeEvents(ctx, events)
        client.SubscribeStats(ctx, stats)
    }
}

The payloads carry the disclosed data. ContainerStat (internal/container/types.go) includes id, cpu, memory, memoryUsage, networkRxTotal, networkTxTotal, diskReadTotal, diskWriteTotal. ContainerEvent includes host, actorId and actorAttributes (a map[string]string), which for start events contains the container name, image, and every label.

Attacker model / precondition

The attacker is an authenticated low-privilege user of a Dozzle instance configured with simple auth (DOZZLE_AUTH_PROVIDER=simple) and at least one user whose filter: restricts them to a subset of containers — the standard multi-user / multi-tenant configuration the filter feature exists for. The attacker holds valid credentials for such a restricted account (or any account; the leak applies to whatever the account's filter excludes). No shell/actions/download role is required and no victim interaction is needed; the attacker simply opens the SSE stream that the normal UI already opens on load.

What bounds severity: the disclosure is limited to container metadata and resource telemetry — it does not by itself expose container log contents, environment-variable values, or the ability to exec/attach (those paths apply the filter correctly on the local host). It requires an authenticated account and only matters when per-user filters are actually used to separate tenants/environments; a single-user or unfiltered deployment has nothing to leak. Hence Confidentiality:Low, no Integrity/Availability impact.

Impact

A user constrained to one label scope can continuously enumerate, on every monitored host:

  • The existence and identity of every container outside their scope, via container-event actorId plus actorAttributes (container name, image name, and the full label map — which is exactly the information the filter feature is meant to hide, and may itself encode tenant/project/environment names such as secretproject=acme-payroll).
  • Live operational telemetry for those containers — CPU%, memory% and bytes, network RX/TX totals, disk read/write totals — updated every few seconds, enabling activity profiling, traffic/throughput inference, and load monitoring of other tenants' workloads.
  • Lifecycle activity (deployments, restarts, crashes, pauses) of out-of-scope containers in real time.

In a multi-tenant or environment-segregated deployment (dev user must not see prod, tenant A must not see tenant B), this defeats the intended isolation for the telemetry/metadata plane while the UI still presents the user with their correctly-filtered single-container view.

Proof of Concept (complete — runs on 127.0.0.1 only)

Lab only. Requires Docker on the local host. Uses the official amir20/dozzle:v10.6.5 image. It creates one container the guest user is allowed to see (label visible=yes) and one the guest must NOT see (dz_secret, no such label), logs in as the restricted guest, and shows that the documented filtered channel returns exactly the one authorized container while the container-stat and container-event channels leak the forbidden container's telemetry and metadata.

set -e
WORK=$(mktemp -d); cd "$WORK"; mkdir -p data

# 1. Build a users.yml with an unrestricted admin and a guest filtered to label=visible=yes.
docker run --rm amir20/dozzle:v10.6.5 generate admin --password adminpass --name Admin > data/users.yml
docker run --rm amir20/dozzle:v10.6.5 generate guest --password guestpass --name Guest \
  --user-filter "label=visible=yes" --user-roles all > guest.yml
python3 - <<'PY'
admin=open("data/users.yml").read()
guest=open("guest.yml").read().split("users:\n",1)[1]
open("data/users.yml","w").write(admin.rstrip()+"\n"+guest)
PY

# 2. Start two workload containers: one the guest IS allowed to see, one it is NOT.
docker rm -f dz_visible dz_secret dozzle_lab 2>/dev/null || true
docker run -d --name dz_visible --label visible=yes alpine \
  sh -c 'while true; do echo "visible-log $(date)"; sleep 2; done' >/dev/null
docker run -d --name dz_secret --label secretproject=acme-payroll alpine \
  sh -c 'while true; do echo "SECRET-log $(date)"; sleep 2; done' >/dev/null

# 3. Start Dozzle v10.6.5 with simple auth, bound to loopback only.
docker run -d --name dozzle_lab -p 127.0.0.1:8

References