Skip to main content

Dokploy's WebSocket Terminal Bug (CVE-2026-24841): One Exec Call, Root on Every Server It Manages

9 min readDora NodaDora Noda
Share
On this page

Here is the entire vulnerable code path in Dokploy's container terminal, word for word, from the GitHub security advisory:

js
const containerId = url.searchParams.get("containerId");
const activeWay = url.searchParams.get("activeWay");
conn.exec(`docker exec -it -w / ${containerId} ${activeWay}`, { pty: true }, ...);

Two query-string parameters, no validation, dropped straight into a template literal that becomes a shell command. An attacker who can already log in — any authenticated user, including the newest and least-trusted seat on the instance — opens a WebSocket to /docker-container-terminal and sets containerId to x;MALICIOUS_COMMAND;#. The semicolon ends the intended docker exec call, the injected command runs, and the trailing # comments out whatever was supposed to follow. CVSS 3.1 score: 9.9. CWE-78, OS command injection, about as textbook as the category gets.

That's CVE-2026-24841, published January 27, 2026 in Dokploy's own advisory (GHSA-vx6x-6559-x35r), fixed in version 0.26.6. It predates — and, as the code above shows, is structurally distinct from — the appName-field command injection (CVE-2026-27130) this blog already covered. That one was a data field silently carrying a payload until someone later restarted the app. This one is a terminal: a feature whose entire purpose is running a shell inside a container, reached over a raw WebSocket connection. The interesting part isn't that it happened — interactive exec features are the hardest version of this bug class to get right — it's why, and what the code reveals about the actual blast radius once you look past the single vulnerable line.


What the Terminal Feature Actually Does

Dokploy's in-browser terminal lets a user click into a running container and get a live shell, rendered client-side with xterm.js and streamed over a WebSocket. On the backend, that shell has to actually run somewhere, and Dokploy manages two kinds of "somewhere":

  • The local machine running the Dokploy control plane itself, where the shell is spawned via node-pty.
  • Remote servers — Dokploy's multi-server fleet feature lets one control plane manage additional Hetzner/DigitalOcean/bare-metal boxes over SSH, using the ssh2 Node.js library's Client object (the conn in the snippet above).

Both paths funnel into the same docker exec invocation, because from the terminal feature's point of view, "run a shell in this container" should look identical whether the container lives on the box Dokploy runs on or on a fleet member three regions away. That symmetry is good software design and exactly what makes the bug worse than a typical shell-out: the single template-literal command gets handed to conn.exec(), and conn is polymorphic — sometimes a local process handle, sometimes a live SSH connection to a different physical machine that the control plane already authenticated to.

activeWay selects the shell to launch inside the container — the post-patch fix constrains it to an allowlist of sh, bash, zsh, ash. Pre-patch, it was just as unsanitized as containerId, so an attacker didn't even need to find a payload in the container-id field; the shell-name field would do.


Why "Just Use an Argv Array" Doesn't Fully Fix This

Dokploy's own advisory offers two remediation paths: tighten input validation (a /^[a-f0-9]{12,64}$/i regex for container IDs, the shell allowlist for activeWay), or stop building a shell string at all — swap conn.exec(templateString) for spawn('docker', ['exec', '-it', '-w', '/', containerId, activeWay]), passing arguments as a discrete array instead of text a shell has to re-parse. That second fix is the standard, correct answer to command injection, and it's the one this blog's earlier Dokploy piece recommended as the general-purpose defense.

It only works for half of this code path, though. Node's spawn() with an argv array works because the local OS execve() call accepts a list of arguments directly — no shell ever parses them, so there's no metacharacter to inject. But the other branch of conn.exec() is an SSH connection, and the SSH protocol's exec channel request (RFC 4254 §6.5) carries exactly one thing: a single command string, handed to the remote user's default shell for interpretation. There is no wire-level equivalent of "here's an argv array, don't let the remote shell re-parse it" — the protocol was designed around shelling out on the far end. You cannot array-ify your way out of a docker exec you're sending over an SSH channel; whatever string arrives at the other side gets shell-interpreted no matter how carefully you built it locally.

Which means the actual fix for the remote-exec branch has to be the first option: strict allowlist validation of every value before it ever reaches the string that gets sent — exactly what the patched regex and shell allowlist do. The lesson generalizes past this one CVE: "switch to spawn() with an array" is good advice for a fix that stays local, but the moment a command has to cross an SSH boundary to reach a remote fleet member, that advice silently stops applying, and allowlisting the input at the boundary is the only defense that survives the transport.


The Blast Radius Isn't the Local Box — It's the Whole Fleet

The earlier Dokploy CVE on this blog (the appName command injection) compromises the single machine the control plane runs on. This one is worse in a specific, concrete way: because conn.exec() transparently dispatches to any server Dokploy manages, a single WebSocket connection with the right containerId/activeWay payload — and the attacker's choice of which server's terminal endpoint to hit — can execute commands on whichever fleet member the control plane already holds SSH credentials for, not just the host Dokploy itself is installed on.

That's the structural cost of a single control plane holding root-equivalent access, over SSH, to every machine it manages: a bug in one exec code path isn't scoped to one box, it's scoped to the entire set of machines the control plane can already reach. One low-privileged, authenticated tenant account — the exact privilege level PR:L in the CVSS vector describes — is one payload away from code execution on every server in the fleet, not one payload away from code execution on the server closest to the bug.


This Was the First of Four, Not an Isolated Incident

CVE-2026-24841 is worth placing on a timeline, because on its own it could read as a one-off. It isn't. Across 2026, Dokploy disclosed four separate command-injection vulnerabilities, all CWE-78, all following the identical shape of "an authenticated user's input reaches a shell-out unsanitized":

CVEDisclosedCVSSEntry point
CVE-2026-24841January 279.9containerId/activeWay in the WebSocket terminal endpoint
CVE-2026-27130later, same window9.9appName field on application create/start/stop/scale
CVE-2026-45662May8.8A crafted registry URL during registry deletion
CVE-2026-45663May9.9Docker file upload handling

Four different entry points, four different code paths, one recurring root cause: a value an authenticated tenant controls reaches a privileged shell-out without validation at the boundary. CVE-2026-24841 is chronologically the first of the four and — because it's the one where the feature itself is "give the user a shell," rather than a data field that happens to end up near one — arguably the clearest illustration of why this bug class keeps recurring in this category of software: the control plane's job requires shelling out constantly, on the tenant's behalf, across every server it manages, and each new call site is a fresh chance to get the boundary wrong.


What an Interactive Exec Feature Needs to Not Repeat This

A container terminal is a fundamentally harder feature to secure than a text field like appName, because the entire point of the feature is running an interactive shell — you can't defend it by rejecting shell syntax, since shell syntax is the deliverable. The discipline that actually holds:

  • Allowlist every parameter that reaches the command, at every code path it can reach — local and remote. The patched regex (^[a-f0-9]{12,64}$ for container IDs, a fixed shell-name allowlist for activeWay) is the correct shape precisely because it validates before either branch — local spawn() or remote SSH — ever sees the value.
  • Treat the SSH exec branch as structurally different from the local branch, not as the same problem with a different transport. An argv-array fix that only protects spawn() gives a false sense of "we fixed command injection here" while leaving the SSH-routed path exactly as exposed as before.
  • Prefer a structured, schema-validated exec primitive over a hand-rolled string-based bridge, when one already exists. Kubernetes' own pods/exec subresource takes the command as a []string array field in a structured API call, authorized through RBAC at the API server before it ever reaches a node — no shell string gets built by string concatenation anywhere in that path, on the local node or a remote one, because the whole primitive was designed around the multi-node case from the start.

That last point is the concrete architectural takeaway for a Cluster-API-based platform rather than a single-daemon-plus-SSH-fleet one: if the platform is already running Kubernetes underneath, a tenant's "get me a shell in my container" request doesn't need a custom WebSocket-to-node-pty-or-ssh2 bridge at all — it can go through the same pods/exec API the cluster already authenticates and audits every other privileged operation through, on whichever node the pod happens to be scheduled to. The multi-server problem that makes Dokploy's conn.exec() polymorphic — and its bug apply fleet-wide — is exactly the problem Kubernetes' API server already solved for every other privileged, node-crossing operation a control plane needs.


Bex.co is the open-source, AI-native Render alternative — push a git repo, get a running HTTPS service on machines you own. A tenant-facing exec/terminal feature is one of the hardest surfaces a self-hosted PaaS ships, and building it on Kubernetes' own RBAC-scoped exec API instead of a hand-rolled SSH bridge is a structural choice, not an afterthought. Star the repo on GitHub or deploy your first app today.

Related articles

Run this on infrastructure you own

bex is the open-source, AI-native Render alternative — push a git repo and get a running HTTPS service on your own machines.

Get started with bex