The Guard Lived in One File: Arcane’s Three CVEs and a Credential Repoint That Outlived Its Own Fix
Three CVEs against Arcane — a Go container-management interface with 7,931 stars, BSD-3-Clause, created 19 April 2025, describing itself as “Modern Docker Management, Designed for Everyone” — reached NVD within forty seconds of each other late on 16 October 2026, all from security-advisories@github.com, all with vulnStatus Deferred:
- CVE-2026-96626 (22:18:58.070Z, CVSS:3.1 8.5,
AV:N/AC:L/PR:L/UI:N/S:C/C:H/I:L/A:N, CWE-200 and CWE-522) — a holder of delegatedregistries:updateandregistries:testrepoints a container registry at a host they control and makes Arcane deliver the stored registry credential there in cleartext. Fixed in v2.5.0. - CVE-2026-96627 (22:18:58.227Z, CVSS:3.1 8.8,
AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:H, CWE-266 and CWE-269) — a holder of delegatedusers:updatesets the global administrator’s password and logs in as them. Fixed in v2.6.0. - CVE-2026-96628 (22:18:58.367Z, CVSS:3.1 4.3,
AV:N/AC:L/PR:L/UI:N/S:U/C:L/I:N/A:N, CWE-863) — an environment-scoped access token reaches routes the application explicitly marked administrator-only. Fixed in v2.11.0.
Read as a batch, the three look unrelated: a credential leak, a privilege escalation, an authorization inconsistency. Read as diffs — and we read all three first-hand from the GitHub commit API — they are the same bug three times. In each case the correct check already existed somewhere in the codebase. In each case it had been written at a call site rather than at a boundary, so it covered the call sites someone remembered and not the ones they did not. And in each case the shipped fix is the same move: extract the check into one function and route every path through it.
That is the story worth having. The CVE ids are the least interesting thing in this batch.
The credential repoint, fixed twice and never finished
CVE-2026-96626 is a three-request chain, and the advisory lays it out without flourish. UpdateRegistry updated registry.URL unconditionally. updateGenericRegistryFieldsInternal re-encrypted the token only when the request actually carried one — req.Token != nil && *req.Token != "" — so a PUT that supplied a new URL and omitted the token field moved the destination while preserving the stored secret against it. Then POST /container-registries/{id}/test decrypted that secret and performed a Docker RegistryLogin against the new address. The advisory notes that normalizeRegistryServerAddressInternal “only normalizes casing/docker.io — it applies NO host allowlist,” and that the response DTO deliberately omits the token, so the credential was meant to be write-only. The chain defeats that.
None of this was a new discovery about Arcane. The same mechanism had already been assigned CVE-2026-45625, a 9.9 critical published to NVD on 29 May 2026 and fixed in 1.19.0, whose own NVD description spells the technique out: “By repointing an existing repository’s URL to an attacker-controlled host while omitting the token/sshKey fields (which UpdateRepository only rewrites when explicitly supplied), the attacker causes Arcane to decrypt the legitimate PAT/SSH key on its next /test, /branches, or /files call and present it as HTTP Basic auth.”
The registry advisory states the reason it came back, and it is a single sentence of forensics that explains the whole class: there is “NO analogue of the git-repository path’s validateGitRepositoryCredentialChangeInternal (grep confirms that guard exists only in git_repository_service.go).” The fix for CVE-2026-45625 was correct, shipped, and local. It protected the file it was written in.
Commit fe31693634f4 (Kyle Mendell, 20 July 2026, 04:40:33Z, +280/−42 across six files, PR #3317) is the one that changes the shape of the problem rather than the instance. Its subject line is fix: prevent credential reuse after target URL changes, and the substantive addition is a new shared helper, backend/pkg/utils/validation/credential_target.go, 53 lines, whose doc comment states the invariant in the abstract:
// ValidateCredentialTargetChange prevents stored credentials from being reused
// against a changed target unless the update explicitly handles them.
It takes the current target, the proposed target, an optional normalizer, and two maps — which credentials are stored, and which the request supplied — and refuses the update when a stored secret would survive a destination change unaccompanied. The registry service calls it; the git repository service was rewritten to call it too, which is why git_repository_service.go shows +15/−34 in the same commit. A guard that had been thirty-four lines of one subsystem’s private logic became a fifty-three-line primitive any subsystem can apply. Note also the ordering fix hiding inside the diff: the registry type is now resolved before validation runs, and ECR is explicitly exempted because its test path sends a temporary token rather than the AWS secret key — a scope limit the reporter had already flagged, honestly, in the advisory itself.
Here is the part that should change how you read this batch. Seventy-seven seconds after the second CVE’s advisory went up, Arcane published GHSA-xhmp-fqc9-qc3g (29 July 2026, 17:19:46Z, 5.5 medium, affected <= 2.5.0, fixed v2.6.0), titled “Multiple credential-handling and path-handling vulnerabilities.” Its first section is headed Credential repoint, and it begins: “Five subsystems preserved a stored secret when the submitted credential field was left empty, while accepting a new destination host verbatim with no validation.” The five are named — notifications (SMTP password or provider token, reachable by the built-in Editor role, with tlsMode and authMode caller-controlled so the password can be forced out as cleartext AUTH PLAIN); the environment apiUrl, whose post-update sync “pushes decrypted registry tokens, ECR secret keys, git PATs and SSH private keys to the new destination”; and ECR registries, the OIDC client secret, and the Trivy server token.
So the tally for one bug class in one product: git repositories (CVE-2026-45625, critical, May), container registries (CVE-2026-96626, high, July), and seven further credential types across five more subsystems — which have no CVE at all. The advisory also credits them differently: the notification repoint was reported by the same external researcher, the rest were “found during internal review.” That is the correct outcome and it is a credit to the maintainers. It is also the reason the public record undercounts this by a factor of three.
Delegated permission, no hierarchy
CVE-2026-96627 is the simplest of the three to state and the most expensive to hold. UpdateUser set the target’s PasswordHash from the request body for any target, and UserService.UpdateUser performed an unconditional tx.Save. There was no comparison of caller against target anywhere in the update path. The advisory pins why this is a boundary violation rather than a design choice: role assignment and role CRUD are gated by RequireGlobalAdmin, “proving users:update is intended below admin,” and the only IsGlobalAdmin check in the service is a last-admin guard inside DeleteUser. Three requests — delegate a custom role carrying users:update, PUT /users/{adminId} with a new password, POST /auth/login — and the profile-management role owns the instance.
Commit 21ea962f86d9 (29 July 2026, 17:18:13Z, +465/−36 across eight files, PR #3426) introduces a sentinel, ErrInsufficientPrivilege, whose comment names the exact scenario: “a delegated users:update holder trying to edit a global admin.” Two things in this diff are worth more than the CVE.
The first is that the patch covers a sink the advisory never mentioned. The report is about password reset; the commit adds the same privilege-ordering pre-check to DeleteUser, with the comment “a non-admin caller may not delete a global admin target.” Nobody had to file a second report for the maintainer to ask which other handlers took a target ID and trusted it.
The second is that the service-layer enforcement is written against concurrency, not just against the reported request. checkTargetPrivilegeInternal takes a SELECT ... FOR UPDATE row lock over the target and, where relevant, the actor, ordered by id, and resolves permissions through the transaction rather than the TTL cache. Its comment explains the reasoning:
// The target and any request-time global-admin actor are locked together before
// permissions are resolved through tx (never the TTL cache). Role-assignment
// writes lock the same user rows, so a concurrent promotion or demotion is
// serialized with the mutation. The request-time actor permissions remain part
// of the decision so revalidation can only reduce, never elevate, authority.
That last clause is the bit to steal. A revalidation that can only narrow authority is safe to add anywhere; one that can widen it has quietly become a second grant path. Most authorization caches we read in disclosures this month got this wrong by never asking the question at all.
The check that was copied, and the path it was never copied into
CVE-2026-96628 is the smallest score in the batch and the clearest statement of the thesis. Arcane’s authentication middleware supports several credential types. The branch that accepts an environment access token via the X-Arcane-Agent-Token header resolved the environment, installed an environment-scoped permission set, and returned:
environmentScopedInternal(c, env)
return next(c)
The AdminRequired option was evaluated later, on the ordinary session path. The environment branch never reached it. So WithAdminRequired() — used, among other places, to protect the manager’s Go pprof group at /api/debug/pprof/* — had no effect for one of the ways you can authenticate. The advisory draws the contrast in the cleanest form available: an ordinary non-admin user gets 403 on that route; an environment token gets the handler. The permission set itself was never the problem. EnvironmentPermissionSet adds only environment-scoped permissions and the advisory quotes the function to prove it. The problem was that nothing consulted it.
We will note the report’s restraint, because it is rarer than the bug. It explicitly declines to claim credential extraction from heap profiles: “Direct extraction of session tokens, environment access tokens, passwords, or other credentials from pprof output has not been demonstrated and is therefore not claimed as part of the confirmed impact.” The confirmed issue is the authorization bypass. A 4.3 that refuses to inflate itself is worth more to a defender than an 8.8 that will not say what was tested.
Commit 4fe4c7ed9483 (9 September 2026, 00:04:48Z, +80/−59 across two files, 60 of the added lines in middleware_test.go, PR #3881) is tiny and tells you something the advisory does not. The AdminRequired block existed in the file twice — once in managerAuth, once in apiKeyHeaderAuth — and the diff deletes both, replacing them with one helper:
func (m *AuthMiddleware) authorizeAndContinueInternal(c *echo.Context, next echo.HandlerFunc, ps *authz.PermissionSet) error {
if m.options.AdminRequired && !ps.IsGlobalAdmin() {
return c.JSON(http.StatusForbidden, common.APIError{
Code: "FORBIDDEN",
Message: "You don't have permission to access this resource",
})
}
return next(c)
}
Four call sites now route through it. Two of them are environment-token branches — and the second one, inside apiKeyHeaderAuth, is a path the advisory never named. It had the identical early return next(c). A reader working only from the CVE text would have patched one of two instances of the same defect in the same file.
The supporting change is a one-line signature edit with an outsized meaning: environmentScopedInternal stopped returning nothing and started returning the *authz.PermissionSet it had been quietly stuffing into the request context. The permission set existed before the fix; it was simply not handed to anybody who would act on it. That is the per-call-site failure in its purest form: the data for the decision was present, and the decision was somewhere else.
Two of the three are in your scanner. One is not.
All three CVEs were published to NVD in the same forty-second window. Measured directly at time of writing, they are not equally visible:
GET api.github.com/advisories/GHSA-pw5q-rjx4-7f7p(CVE-2026-96627) → 200, typereviewed, ecosystemgo, packagegithub.com/getarcaneapp/arcane/backend/v2, range< 2.6.0. In OSV with a SEMVER range and apkg:golangpurl.GET api.github.com/advisories/GHSA-mp22-58ff-6fhr(CVE-2026-96628) → 200, same shape, range< 2.11.0.GET api.github.com/advisories/GHSA-9pxc-cxp5-qp54(CVE-2026-96626, the 8.5 credential leak) → 404. OSV returns 404 for that GHSA id.
OSV does hold a record under CVE-2026-96626 itself, auto-generated from the CVE List rather than from the advisory database, and its affected block is a GIT range — repository URL plus a fixed event naming commit 92bfcd87 — accompanied by a list of raw tag strings. There is no package name and no ecosystem. We checked what that costs in the only way that matters, by asking OSV the question a scanner asks. A version query for the Go module github.com/getarcaneapp/arcane/backend/v2 at 2.4.0 — a version vulnerable to all three — returns GHSA-mp22-58ff-6fhr and GHSA-pw5q-rjx4-7f7p, and nothing else. The credential-exfiltration record is simply absent from the answer.
The repository-level advisory shows the likely mechanism: it records the affected package as github.com/getarcaneapp/arcane/backend, without the /v2 suffix. The real import path at the affected version is github.com/getarcaneapp/arcane/backend/v2/... — we read it in the import block of the fix commit’s own diff. One missing module-path suffix on one advisory, and the highest-impact finding of the three never reaches the feed. Querying the un-suffixed name at 2.4.0 does return something, which makes it worse rather than better: GO-2026-5292, the Go vulnerability database’s record for a 2026 command-injection CVE, carrying a range with an introduced event and no fixed event, so it matches every version of that module path forever.
Add the advisories that never received an identifier at all. Arcane has published fourteen repository security advisories; two carry no CVE, and both are invisible to the global database (404 each). One is GHSA-xhmp-fqc9-qc3g, the five-subsystem credential-repoint record above. The other is GHSA-577w-5mcf-3wc9, published 14 October 2026 at 8.8 high: Arcane’s OIDC account-merge fails open when the provider omits the email_verified claim — it rejects an explicit false and “logs a warning and continues when the provider omits the claim,” which, as the advisory points out, a conforming provider may legitimately do. Affected < v2.15.0, fixed v2.16.0, three days old, no CVE, no feed entry. That is the most recent and one of the most serious records in the repository, and no dependency scanner on earth will mention it to you.
This is the same structural failure we recorded for OPNsense, runwasi and Wheels — with an extra turn of the screw. Those products had no usable ecosystem coordinate at all. Arcane has one, it is correct on two records out of three, and the product is nonetheless deployed as a container image rather than imported as a Go module, so the coordinate that scanners can match is not the artefact operators are running. A clean dependency pipeline over a fleet of Arcane instances can return zero findings while being configured perfectly. It is answering a question about modules you compile, not about applications you run.
Neither CVE-2026-96626, 96627, 96628 nor 45625 appears in the CISA KEV catalog (version 2026.10.16, 1,748 entries), and we have seen no evidence of exploitation for any of them.
Why a container manager belongs in this feed
Arcane is not an AI product and we are not going to pretend otherwise. It belongs here because of what it holds and who increasingly drives it. The credentials behind CVE-2026-96626 and its uncounted siblings are registry PATs, ECR secret keys, git PATs, SSH private keys, SMTP passwords, an OIDC client secret and a Trivy token — which is to say push access to the images your platform runs, write access to the repositories your pipelines build from, and the identity provider that fronts both. A registry credential is an integrity asset, not a confidentiality one; the advisory’s I:L and S:C encode exactly that, and the CVSS scope change is doing real work.
The delegation model is the second half. All three findings are reachable by accounts that an administrator deliberately created with less-than-admin authority: a “User Manager” role, a registry editor, an environment agent token. That is the permission shape operators are handing to automation right now — the scoped token you issue a deployment bot or an agent that restarts services. CVE-2026-96628 is precisely the case of a machine credential, correctly scoped by design, meeting a gate that forgot to ask it anything. The same pattern drove Komodo’s periphery agent, and the authorization half of VulnCheck’s 122-CVE batch was thirteen records of nobody checking whether the caller was allowed. Arcane’s version is more interesting than most because the check was there, which rules out the comfortable explanation that someone forgot about authorization.
What to do
Upgrade to v2.16.0 or later; current is v2.16.1 (15 October 2026). There is no single version that fixes this batch — the three CVEs are patched in v2.5.0, v2.6.0 and v2.11.0 respectively, and the un-numbered OIDC fail-open needs v2.16.0. Anything below v2.16.0 is missing at least one of them. There are no configuration workarounds for the three CVEs; the controls are in request handling.
Rotate before you congratulate yourself on patching. If you ran anything at or below 2.5.0 with delegated roles in use, treat every secret Arcane has held as potentially delivered elsewhere: registry tokens, ECR keys, git PATs and SSH keys, SMTP credentials, the OIDC client secret, the Trivy server token. Exploitation here is an outbound login to an attacker host, so the evidence lives in egress logs and in the provider’s own authentication history — not in Arcane’s. And check the configured destination hosts for notification, registry, OIDC and Trivy integrations before you rotate, because the repoint persists in configuration after the version is fixed. The advisory’s own workaround section says this; it is the step most upgrade notes skip.
Re-derive your role grants rather than trusting them. Assume, for anything that ran below v2.6.0, that users:update was equivalent to administrator and that notifications:manage granted instance-wide reach regardless of the environment it was scoped to. Note also that the v2.6.0 notification scoping fix is breaking by design — a per-environment grant no longer satisfies those endpoints and custom roles must be regranted at organization level. If a custom role silently stopped working after that upgrade, that was the security fix, not a regression.
Then audit your own code for the shape, not the CVEs. Three questions, each of which found a real bug here. Does any security check appear more than once in your codebase — and if so, which call site does not have it? Can a request change the destination a stored credential is sent to without re-supplying that credential? Does every authentication mechanism you accept — session, API key, machine token, agent token — pass through the same authorization evaluation, or does one of them return early? The grep that answers the first question is the cheapest security review you will run this quarter, and a duplicated check is a strictly better signal than a missing one, because it tells you both what the invariant is and where someone already decided it mattered.
Related reading: Portkey Gateway’s trusted-host short-circuit for the same destination-validation failure one layer down, and n8n’s fourteen-CVE batch for a longer run of checks that ran against the wrong thing.
Sourcing: all three CVE identifiers, publication timestamps, vulnStatus values, source identifiers, CVSS 3.1 scores and vectors, CWE lists, reference URLs and the absence of CPE configurations read first-hand from the NVD 2.0 API, as was the full CVE-2026-45625 record. All five Arcane repository security advisories cited — GHSA-9pxc-cxp5-qp54, GHSA-pw5q-rjx4-7f7p, GHSA-mp22-58ff-6fhr, GHSA-xhmp-fqc9-qc3g and GHSA-577w-5mcf-3wc9 — including every quoted sentence, severity, CVSS vector, affected and patched range, ecosystem and package coordinate, and publication timestamp, read first-hand from the GitHub repository security-advisory API, as was the count of fourteen advisories. Fix commits fe31693634f4, 21ea962f86d9 and 4fe4c7ed9483 — author, timestamp, subject, file list, line counts and complete diffs — read first-hand from the GitHub commit API, and every line of Go and every code comment shown above is quoted verbatim from those diffs. Release tags and publication timestamps, and repository metadata (7,931 stars, 336 forks, Go, BSD-3-Clause, created 19 April 2025) from the GitHub repository and releases APIs. GitHub global-advisory 404s, OSV record lookups and the OSV package-version queries against both module paths at 2.4.0 and 2.5.0 were measured directly at time of writing, as was the KEV catalogue check against version 2026.10.16. Brave Search was used for discovery only and returned CVE mirrors and automated scoring pages with no independent technical analysis of any of the three records. Nothing was installed, no Arcane instance was deployed or contacted, no registry, notification, environment or user configuration was authored, no rogue registry was stood up, no token was generated or consumed, no proof-of-concept was executed, and no third-party request was sent beyond reading the cited public records. The attack chains are described only to the extent already published in the vendor’s own advisories.