Arcane, an open-source web UI for managing Docker, has published 14 security advisories in 2026. Half of them are the same kind of bug: a request reached something it shouldn't, because an authentication or authorization check was missing, skipped or too coarse. GitHub's advisory database added two more of them on 16 October: a delegated permission that could reset the administrator's password (CVE-2026-96627) and an environment token that skipped the admin-only gate (CVE-2026-96628).
That pattern isn't special to Arcane. A Docker management UI is a web app with the Docker socket behind it, so an access-control slip in the UI usually ends up as control of the host. This post is for teams that run Arcane, Portainer or a similar tool, and for anyone who writes role-based access control. It covers what the 14 advisories show, the two bug shapes behind the newest ones, a test pattern that catches both, and a script that reviews how you deploy the UI.
The short answer
Upgrade Arcane to 2.16.0 or later. 2.16.1 was current on 17 October. Seven of its 14 advisories this year were authentication or authorization failures, and four of the 14 aren't in GitHub's advisory database, so Dependabot won't report them. But treat the upgrade as the minimum. Because the UI holds the Docker socket, a bypass in the UI usually means root on the host. Keep the UI off the public internet, bind it to localhost or a private interface behind a VPN or authenticating proxy, use a Docker socket proxy where you can, and be stingy with delegated roles. A role that can edit users or credentials is close to an admin role whether or not it's called one.
What the 14 advisories show
Arcane's repository advisories for 2026, grouped by what went wrong:
| Bug class | Advisories | Examples |
|---|---|---|
| Missing or bypassed authentication or authorization | 7 | Unauthenticated proxying to remote environments (CVE-2026-23944, critical), unauthenticated reads of Compose templates and their .env secrets (CVE-2026-42461), git repository and global-variable endpoints with no admin check (CVE-2026-45625, CVE-2026-47125), delegated users:update to admin takeover (CVE-2026-96627), environment token past the admin gate (CVE-2026-96628), OIDC account merge accepting a missing email_verified claim (GHSA-577w-5mcf-3wc9) |
| Stored credentials sent to a new destination | 2 | A delegated user repoints a registry's URL and "tests" it, delivering the stored credential to their own host (CVE-2026-96626), and the same flaw in five other subsystems (GHSA-xhmp-fqc9-qc3g) |
| Command injection | 2 | Update lifecycle labels passed to /bin/sh -c (CVE-2026-23520, critical), a volume browser path passed into a shell command (CVE-2026-45626) |
| Server-side request forgery | 1 | Unauthenticated template fetch from any URL (CVE-2026-40242) |
| Cross-site scripting | 1 | Unauthenticated reflected XSS through an SVG logo parameter (CVE-2026-45627) |
| Host file read | 1 | Compose include directives read before path validation (CVE-2026-47179) |
Four of the 14 could be triggered with no account at all. The two credential-repoint bugs are access control too, in a quieter form: the user was allowed to edit a setting, and the app assumed the stored secret should follow the new destination.
Four advisories have no reviewed entry in GitHub's database as of 17 October: the critical unauthenticated proxy bug from January, the registry credential exfiltration, the multi-subsystem credential repoint, and the OIDC account merge published on 14 October. OSV has the January one through its CVE record. A Dependabot-only setup wouldn't flag any of the four.
Two shapes of the same mistake
The two advisories GitHub added on 16 October are worth reading closely, because they're the two ways delegated access control usually breaks.
An early return that skips the check. In Arcane 2.10.2, the manager's auth middleware accepted an environment access token in the X-Arcane-Agent-Token header, installed an environment-scoped permission set, and called return next(c). The AdminRequired check came later in the function, so token-authenticated requests never reached it. The route it exposed was the Go pprof debug interface, which is meant for global admins only. The advisory rates it medium (CVSS 4.3), and it was fixed in 2.11.0 on 12 September. Credit to gehan-psbc.
A permission without a hierarchy. In versions before 2.6.0, a user with the delegated users:update permission could set any user's password, including the global administrator's. The handler loaded the target user and saved the new password hash without comparing the caller's privileges to the target's. Role assignment was correctly restricted to admins, which shows users:update was meant to sit below admin. The advisory rates it high (CVSS 8.8), and it was fixed in 2.6.0 on 29 July. Credit to manus-use.
Both are easy to miss in review because every individual line looks right. The middleware does check tokens. The handler does check users:update. What's missing is a rule that every path ends at the same authorization decision, and that a delegated permission can't be used on someone more privileged than the caller.
A test that catches both
The defense is an authorization matrix: every kind of principal against every protected action, with the expected answer written down, run against the real handlers. This model reproduces the two shapes in Go, buggy and fixed, and tests them with one table. It's not Arcane's code:
// Package authz models two authorization bugs from Arcane's 2026 advisories.
// It is not Arcane's code: just the shape of each mistake and its fix.
package authz
import "errors"
var ErrForbidden = errors.New("forbidden")
type Principal struct {
Name string
Admin bool
Perms map[string]bool
EnvToken bool // authenticated with an environment-scoped token
}
type Route struct {
Perm string
AdminRequired bool
}
// Middleware, buggy: the environment-token path returns before the
// AdminRequired check runs (the CVE-2026-96628 shape).
func AuthorizeBuggy(p Principal, r Route) error {
if p.EnvToken {
return nil // "environment-scoped, continue"
}
if r.AdminRequired && !p.Admin {
return ErrForbidden
}
if r.Perm != "" && !p.Admin && !p.Perms[r.Perm] {
return ErrForbidden
}
return nil
}
// Middleware, fixed: every authentication path ends at the same checks.
func AuthorizeFixed(p Principal, r Route) error {
if r.AdminRequired && !p.Admin {
return ErrForbidden
}
if r.Perm != "" && !p.Admin && !p.Perms[r.Perm] {
return ErrForbidden
}
return nil
}
// Handler, buggy: users:update lets the caller set any user's password,
// including an admin's (the CVE-2026-96627 shape).
func CanSetPasswordBuggy(actor, target Principal) error {
if !actor.Admin && !actor.Perms["users:update"] {
return ErrForbidden
}
return nil
}
// Handler, fixed: a delegated permission can't be used on someone more
// privileged than the caller.
func CanSetPasswordFixed(actor, target Principal) error {
if err := CanSetPasswordBuggy(actor, target); err != nil {
return err
}
if target.Admin && !actor.Admin {
return ErrForbidden
}
return nil
}
package authz
import "testing"
var (
admin = Principal{Name: "admin", Admin: true}
manager = Principal{Name: "user-manager", Perms: map[string]bool{"users:update": true}}
envTok = Principal{Name: "env-token", EnvToken: true}
pprof = Route{AdminRequired: true}
)
// One table: every principal, every protected action, the expected answer.
// Run it against both versions; the buggy rows are the advisories.
func TestAuthorizationMatrix(t *testing.T) {
impls := map[string]struct {
route func(Principal, Route) error
pass func(Principal, Principal) error
}{
"buggy": {AuthorizeBuggy, CanSetPasswordBuggy},
"fixed": {AuthorizeFixed, CanSetPasswordFixed},
}
cases := []struct {
name string
allow bool
check func(route func(Principal, Route) error, pass func(Principal, Principal) error) error
}{
{"admin reads pprof", true, func(r func(Principal, Route) error, _ func(Principal, Principal) error) error { return r(admin, pprof) }},
{"env token reads pprof", false, func(r func(Principal, Route) error, _ func(Principal, Principal) error) error { return r(envTok, pprof) }},
{"manager resets own password", true, func(_ func(Principal, Route) error, p func(Principal, Principal) error) error { return p(manager, manager) }},
{"manager resets admin password", false, func(_ func(Principal, Route) error, p func(Principal, Principal) error) error { return p(manager, admin) }},
}
for implName, impl := range impls {
for _, c := range cases {
allowed := c.check(impl.route, impl.pass) == nil
if allowed != c.allow {
t.Errorf("%s: %s: allowed=%v, want %v", implName, c.name, allowed, c.allow)
}
}
}
}
$ go test ./...
--- FAIL: TestAuthorizationMatrix (0.00s)
authz_test.go:36: buggy: env token reads pprof: allowed=true, want false
authz_test.go:36: buggy: manager resets admin password: allowed=true, want false
FAIL
FAIL example.com/authz 0.684s
FAIL
The two failing rows are the two advisories. The fixed versions pass every row. In a real codebase, generate the table from your route list, so a new route without a row fails the build.
Review how you deploy the UI
This script reads Compose files. For Arcane it flags images older than 2.16.0 and moving tags. For Arcane and similar Docker UIs it flags a mounted Docker socket, ports published on every interface, and example secrets left in place. It reads files only and uses the Python standard library:
#!/usr/bin/env python3
"""Review Compose files that run a Docker management UI.
Flags Arcane images older than the latest advisory fix, moving tags, a raw
Docker socket mount, UI ports published on every interface, and placeholder
secrets. Reads files only; standard library only. Usage: docker_ui_check.py DIR
"""
import re
import sys
from pathlib import Path
ARCANE_FIXED = (2, 16, 0) # GHSA-577w-5mcf-3wc9, OIDC account merge, the latest fix on 17 Oct 2026
UI_IMAGES = re.compile(r"(ghcr\.io/getarcaneapp/(?:manager|arcane|agent)|portainer/portainer-(?:ce|ee)|"
r"lscr\.io/linuxserver/dockge|louislam/dockge|ghcr\.io/dockur/[\w-]+|"
r"ghcr\.io/umputun/[\w-]+)(?::([\w.\-]+))?")
SOCK = re.compile(r"^\s*-\s*['\"]?/var/run/docker\.sock:/var/run/docker\.sock(:ro)?['\"]?", re.M)
PORT = re.compile(r"^\s*-\s*['\"]?(\d{2,5}):(\d{2,5})['\"]?\s*$", re.M)
PLACEHOLDER = re.compile(r"(ENCRYPTION_KEY|AGENT_TOKEN|JWT_SECRET)=(replace_me\w*|arc_X+|changeme)", re.I)
def version(tag):
m = re.match(r"v?(\d+)\.(\d+)\.(\d+)", tag or "")
return tuple(int(x) for x in m.groups()) if m else None
def check(path):
text = path.read_text(errors="ignore")
images = UI_IMAGES.findall(text)
if not images:
return []
out = []
for repo, tag in images:
tag = tag or "latest"
ver = version(tag)
if "getarcaneapp" in repo:
if ver is None:
out.append(("WARN", f"{repo}:{tag} is a moving tag; pin a version so you know which advisories apply"))
elif ver < ARCANE_FIXED:
out.append(("UPGRADE", f"{repo}:{tag} predates 2.16.0; Arcane published 14 advisories in 2026, the latest fixed in 2.16.0"))
elif ver is None:
out.append(("WARN", f"{repo}:{tag} is a moving tag"))
for m in SOCK.finditer(text):
note = "read-only mount still allows every Docker API call" if m.group(1) else "full Docker API, which is root on the host"
out.append(("RISK", f"Docker socket mounted into the UI container: {note}; consider a socket proxy"))
for host, _ in PORT.findall(text):
out.append(("RISK", f"port {host} published on all interfaces; bind 127.0.0.1:{host} and put it behind a VPN or authenticating proxy"))
for m in PLACEHOLDER.finditer(text):
out.append(("FIX", f"{m.group(1)} still has the example value"))
return out
def main(root):
found = 0
for p in sorted(Path(root).rglob("*")):
if p.suffix in (".yml", ".yaml") and p.is_file():
for level, msg in check(p):
found += 1
print(f"{level:7} {p.relative_to(root)}: {msg}")
print(f"{found} finding(s)")
return 1 if found else 0
if __name__ == "__main__":
sys.exit(main(sys.argv[1] if len(sys.argv) > 1 else "."))
On Arcane's own basic and agent examples from its repository, with the agent pinned to 2.10.2, plus a hardened office deployment:
$ python3 docker_ui_check.py fx
UPGRADE homelab/agent.compose.yaml: ghcr.io/getarcaneapp/agent:v2.10.2 predates 2.16.0; Arcane published 14 advisories in 2026, the latest fixed in 2.16.0
RISK homelab/agent.compose.yaml: Docker socket mounted into the UI container: full Docker API, which is root on the host; consider a socket proxy
RISK homelab/agent.compose.yaml: port 3553 published on all interfaces; bind 127.0.0.1:3553 and put it behind a VPN or authenticating proxy
FIX homelab/agent.compose.yaml: AGENT_TOKEN still has the example value
WARN homelab/compose.yaml: ghcr.io/getarcaneapp/manager:latest is a moving tag; pin a version so you know which advisories apply
RISK homelab/compose.yaml: Docker socket mounted into the UI container: full Docker API, which is root on the host; consider a socket proxy
RISK homelab/compose.yaml: port 3552 published on all interfaces; bind 127.0.0.1:3552 and put it behind a VPN or authenticating proxy
FIX homelab/compose.yaml: ENCRYPTION_KEY still has the example value
8 finding(s)
The hardened deployment produces no findings: a pinned current image, a localhost bind and the Docker API reached through a proxy. Arcane's repository includes a Compose example that uses a Docker socket proxy. Note that mounting the socket :ro doesn't help on its own, since read-only applies to the socket file, not to what's sent over it.
What to do
- Upgrade to Arcane 2.16.0 or later, on the manager and on every agent. Pin a version tag so you know which advisories apply.
- Take it off the internet. Bind the UI to localhost or a private interface, and reach it through a VPN or an authenticating reverse proxy. Four of this year's advisories needed no login.
- Put a socket proxy between the UI and Docker, and allow only the API sections the UI needs. It's defense in depth, not a boundary: a UI that can create containers can create a privileged one. Our post on Docker privileged mode risks covers why that matters.
- Treat delegated roles as admin-adjacent.
users:update, registry edit and credential test permissions all led to admin-level outcomes this year. Grant them to people you'd trust with admin. - Rotate stored credentials if you ran an affected version with untrusted users: registry tokens, git credentials, notification webhooks and OIDC settings.
- If you build software like this, write the authorization matrix. Our broken access control prevention guide covers the wider set of checks.
The fair view
Arcane's record looks bad in a table, but it's also a sign of a project that takes reports seriously. It publishes detailed advisories with root-cause analysis, credits reporters, ships a fixed release with each one, and has been through a major version and dozens of releases this year. A project that publishes fewer advisories isn't necessarily one with fewer bugs.
The category is the problem. A Docker management UI gives a browser session the power of the Docker daemon, and then adds the features that make access control hard: delegated roles, API keys, agent tokens, OIDC, stored credentials. Each one adds a path to the same root-equivalent capability, and each path has to end at the same check. Until that's proven by tests, the safest assumption is that anyone who can reach the UI may eventually reach the host.