Vulnerability summary
- Product: Coolify — open-source, self-hostable platform-as-a-service (PaaS), a Heroku / Vercel / Netlify alternative that hosts many teams on one instance
- Advisory: GHSA-jq58-rxhg-623r — reported privately, accepted, and credited; closed as a duplicate with no CVE assigned. No security advisory was published
- CVSS: 9.9 (Critical) ·
CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:C/C:H/I:H/A:H, as we scored it in the advisory. Full cross-tenant impact requires an instance hosting more than one team; on a single-team instance there is no neighboring tenant to pivot to - Vulnerability type: argument injection into an
sshinvocation — CWE-88 (argument injection), leading to CWE-77 (command injection) - Component: the Node realtime service,
docker/coolify-realtime/terminal-server.js(withterminal-utils.js), reachable over the terminal WebSocket - Affected versions: >= 4.0.0, <= 4.3.12 per the advisory (the version we tested). The fix was issued only in v4.3.15, so v4.3.13 and v4.3.14 are unpatched as well — in practice, every release before v4.3.15
- Patched version: v4.3.15 (released 3 Sep 2026), realtime image
1.0.18, fix commit aff581043f — shipped with a one-line changelog entry and no security advisory
Affected product
Coolify is a self-hosted PaaS built on Laravel. On Coolify a tenant is a team, and we use the two terms interchangeably. Alongside the PHP application, Coolify runs a small Node service, coolify-realtime, which powers live features: deployment logs, the in-browser server terminal, and WebSocket broadcasts. On Coolify Cloud that realtime container is shared across every tenant on the node; on a self-hosted instance it is shared across every team on that instance.
The in-browser terminal proxies an interactive ssh session from the realtime container out to one of the team's managed servers. The browser opens a WebSocket to the realtime service; the service authenticates the session against the Coolify backend, then spawns an ssh process and streams its output back to the terminal. The tenancy boundary that matters here is straightforward: a session should only be able to reach its own team's servers, and the code it runs should stay inside that ssh session to the target host, not on the realtime container itself.
This finding breaks the second half of that guarantee.
Vulnerability overview
The realtime terminal server accepts the entire ssh command string from the WebSocket client. It tokenizes that string into an argv array, validates only the user@host token against the caller's list of authorized hosts, and then runs:
const ptyProcess = pty.spawn('ssh', sshArgs.concat([hereDocContent]), options);
Every other ssh option in sshArgs passes through unchecked. There is an allowlist on the host but none on the options. This is the root cause of the flaw: OpenSSH lets the client specify options that execute local programs, most notably -o ProxyCommand=<cmd>, which OpenSSH runs on the local machine to establish the connection, before and independently of whether the target host is reachable. Because the local machine here is the shared realtime container, and the process runs as root, a crafted command frame turns "open a terminal to my server" into "run arbitrary code as root on Coolify's shared realtime container."
The precondition is weaker than the phrase "terminal access" implies. The access gate, canAccessTerminal, is isOwner() || isAdmin() evaluated against the caller's active team, and since every user owns their own personal team, that check presents no meaningful barrier. is_terminal_enabled, in turn, defaults to true on any server a user adds. Any authenticated user who has added a server can launch a successful attack: no instance admin, no API key, no victim interaction. The browser UI is not required either — a valid session cookie and a direct WebSocket connection are enough.
Root cause
The tokenizer captures everything the client placed between ssh and the trailing quoted remote command, and the handler validates only the target host:
// docker/coolify-realtime/terminal-server.js (pre-fix)
const sshArgs = extractSshArgs(commandString); // tokenizes the WHOLE client string
const targetHost = extractTargetHost(sshArgs);
if (!isAuthorizedTargetHost(targetHost, userSession.authorizedIPs)) return; // validates HOST only
const ptyProcess = pty.spawn('ssh', sshArgs.concat([hereDocContent]), options); // SINK extractSshArgs() (in terminal-utils.js) splits on whitespace and hands back every token the client supplied. Only user@host is checked. There is no allow- or deny-list for -o, -i, -F, ProxyCommand, LocalCommand, or PermitLocalCommand. ProxyCommand is executed by ssh before, and regardless of, whether the target host is reachable, so passing the host-allowlist check is irrelevant to whether the injected command runs.
Impact
The immediate result is root code execution on the coolify-realtime container. What makes this critical rather than a contained, low-severity foothold is what that container holds.
Coolify mounts the entire /data/coolify/ssh directory into the realtime container, where every managed-server SSH private key is written flat and decrypted as ssh_key@<uuid>, with no per-team separation (app/Models/PrivateKey.php, docker-compose.prod.yml). On any instance hosting more than one team, root on that shared container therefore reads every co-located tenant's decrypted SSH key. That in turn grants root on those tenants' managed servers: a full multi-tenant infrastructure takeover, not just a local escape.
The same container also holds the instance-wide PUSHER_APP_SECRET that signs realtime broadcasts. With it, an attacker can forge or eavesdrop on broadcasts to any channel on the instance; chained with a separate broadcast-channel authorization weakness we reported during the same audit, that extends to any tenant's private channels.
On Coolify Cloud the effect is amplified, because a single realtime container is shared across many tenants by default. On a single-team instance there is no other tenant to pivot to, so the impact is bounded by that one team's own infrastructure: still root on the realtime container and its secrets, but without the cross-tenant reach.
Distinction from the two published terminal CVEs
Coolify has two published terminal-WebSocket advisories, both dated 2 Jul 2026:
- CVE-2026-34047 (GHSA-652w-qv22-2r7c) — "WebSocket Endpoint Access Control Flaw Leading to Remote Code Execution."
- CVE-2026-34048 (GHSA-mw6q-2hmg-mhxv) — "Missing authorization on terminal websocket bootstrap routes allows low-privileged members to execute commands on team servers."
Both fix an authorization problem: who is allowed to reach the terminal. They restricted it to admins and owners, fixed in 4.0.0-beta.471. Neither touches argument sanitization. CVE-2026-34048's own writeup even observes that handleCommand() "accepts an attacker-controlled SSH command string," yet its remediation is an authorization gate, and the injection itself was left intact. So even after those two CVEs were fixed, anyone who could open a terminal could still reach root on the realtime container — and, per the gate above, that is any authenticated user with a server.
Reproduction
From an account with terminal access and at least one server (a default team owner qualifies):
- Confirm the authorized host:
POST /terminal/auth/ips→{"ipAddresses":["<host>"]}. - Open the terminal WebSocket —
wss://<host>/terminal/ws(orws://<host>:6002/terminal/ws) — with thecoolify_sessionandXSRF-TOKENcookies. - Send a single
commandframe carrying an injectedProxyCommand:
{"command":["timeout 300 ssh -o 'ProxyCommand=/bin/busybox id >/tmp/marker 2>&1 & /bin/busybox sleep 3' root@<AUTHZ_HOST> 'bash -se' << ENDSSH\n\nENDSSH"]} extractTargetHostreturns<AUTHZ_HOST>(authorized);sshruns the injectedProxyCommandlocally on the realtime container before it ever tries to reach the host.
Verification on our lab stack:
$ docker exec coolify-<lab>-realtime cat /tmp/marker
uid=0(root) gid=0(root) groups=0(root)
This matches the container's real id, confirming root execution on coolify-realtime, not on the target server.
The payload above writes a benign marker (the output of id to /tmp/marker) only to demonstrate code execution. We are not publishing a weaponized exploit, and we have withheld the end-to-end key-exfiltration and pivot chain.
The fix
Commit aff581043f — "fix(realtime): harden terminal command handling" — adds a strict, server-side allowlist and inserts a rejection check immediately before the pty.spawn sink.
A new validateSshArgs() walks the argv and rejects anything outside a strict allowlist of option names and values. Rather than banning ProxyCommand outright, the fix pins it to exactly the one value Coolify itself injects for its Cloudflare-tunnel path — any other ProxyCommand is rejected:
// terminal-utils.js (added in aff581043f)
const fixedOptions = {
StrictHostKeyChecking: 'no',
UserKnownHostsFile: '/dev/null',
PasswordAuthentication: 'no',
LogLevel: 'ERROR',
ControlMaster: 'auto',
ProxyCommand: 'cloudflared access ssh --hostname %h', // pinned; any other value rejected
};
// -i restricted by regex to .../storage/app/ssh/keys/ssh_key@<id>; -p numeric 1-65535 The handler now rejects anything that fails validation and passes only sanitized args to the sink:
// terminal-server.js (added in aff581043f)
if (!validateSshArgs(sshArgs, userSession.authorizedIPs)) {
ws.send('Invalid SSH command: Unsupported SSH arguments');
return;
}
const sanitizedSshArgs = sanitizeSshArgs(sshArgs);
// ...
const ptyProcess = pty.spawn('ssh', sanitizedSshArgs.concat([hereDocContent]), options);
The commit also bumps the realtime image 1.0.17 → 1.0.18 and adds a regression test (terminal-utils.test.js) that asserts a -o 'ProxyCommand=/bin/busybox id >/tmp/marker' root@10.0.0.5 frame is rejected. The fix is complete and correctly scoped.
Disclosure
We reported this privately through a GitHub Security Advisory on 28 Aug 2026. The timeline, from GitHub's advisory API and the fix commit, is:
| Time (UTC), 28 Aug 2026 | Event |
|---|---|
| 09:08 | GHSA-jq58-rxhg-623r filed (Critical, CWE-88, ssh argument injection). |
| 12:41 | Fix commit aff581043f — "fix(realtime): harden terminal command handling" — pushed. |
| 12:59 | Advisory closed with the comment: "This is duplicated of another sec advisory that is already in progress and release soon." |
The 09:08 and 12:59 values are GitHub's server-side created_at / closed_at for the advisory; 12:41 is the fix commit's authored timestamp. The advisory was accepted and credited, with no CVE assigned.
The patch modifies exactly the two files and the pty.spawn sink named in the report, implements the allowlist remediation we proposed, and adds a regression test matching the reported proof of concept.
The advisory was closed as a duplicate. We were not able to identify the advisory it refers to:
- The two published terminal advisories, CVE-2026-34047 and CVE-2026-34048, have been public since 2 Jul 2026, and both address terminal authorization rather than argument sanitization (patched in
4.0.0-beta.471). - As of this writing, Coolify's most recent published security advisory is dated 2 Jul 2026; none has been published since, on the repository or in the global GitHub Advisory Database, covering this class.
Advisories are sometimes left in draft or unpublished for reasons unrelated to the fix, and we make no claim about intent.
The fix shipped in v4.3.15 on 3 Sep 2026 (realtime image 1.0.18), described in the release notes as "Restricted web terminal SSH commands to approved hosts, keys, ports, and options." No CVE was assigned and no security advisory accompanied the release.
Important note for teams running Coolify. Coolify users who track fixes through CVE feeds, the GitHub Advisory Database, or software composition analysis (SCA) tooling receive no signal that this issue existed or that upgrading to v4.3.15 is security-relevant. They also get no prompt to rotate the managed-server SSH keys and PUSHER_APP_SECRET that an affected multi-team instance would have exposed. The public trace is the commit and the one-line changelog entry, which is why the remediation guidance below cites those directly.
Remediation
For operators
- Upgrade to v4.3.15 or later. The realtime image must roll to
1.0.18for the fix to take effect. Confirm that the runningcoolify-realtimecontainer is on1.0.18, not just that the PHP app updated. - Rotate exposed credentials. If you ran an affected version (
>= 4.0.0, <= 4.3.12) with terminal access enabled, rotate the credentials the realtime container could reach: managed-server SSH keys andPUSHER_APP_SECRETat minimum. On Cloud-style shared deployments, treat every tenant's realtime secrets as potentially exposed for the window the instance ran unpatched. - Do not rely on terminal-access restrictions alone. The CVE-2026-34047/34048 fixes limited the terminal to admins and owners, but because every user owns their personal team, that gate never stopped this injection. Only the v4.3.15 realtime fix does. Restricting access is defense-in-depth, not a substitute for the upgrade.
For developers building similar features
Never pass a client-supplied string as arguments to a local process. Build the argv yourself from validated fields (host, port, key path) and pass the user's input only as the remote command inside an already-quoted transport. If you must parse a command string, allowlist option names and values, as the fix does, rather than denylisting the dangerous ones, because ssh exposes more mechanisms for local code execution than a denylist can reliably enumerate (ProxyCommand, LocalCommand, PermitLocalCommand, -F, Match exec). Finally, run the process without root and with per-tenant isolation.
References
- aff581043f —
fix(realtime): harden terminal command handling - Coolify v4.3.15 release — changelog line: "Restricted web terminal SSH commands to approved hosts, keys, ports, and options"
- CVE-2026-34047 / GHSA-652w-qv22-2r7c — terminal WebSocket access-control flaw (distinct; authorization, not argument injection)
- CVE-2026-34048 / GHSA-mw6q-2hmg-mhxv — missing authorization on terminal bootstrap routes (distinct)
- CWE-88 (Argument Injection), CWE-77 (Command Injection); OpenSSH
ProxyCommandsemantics
Vulnerability credits
We found this vulnerability during a re-audit of Coolify v4.3.12 and confirmed it against a live multi-tenant stack, executing id as uid=0(root) on the shared realtime container. It was filed privately with the maintainer before any public detail, and the fix was prompt and complete. As noted above, we would welcome a CVE assignment and a published advisory: either would give operators a durable signal to upgrade and to rotate the exposed credentials.
Finding: GHSA-jq58-rxhg-623r (no CVE assigned). Affected: Coolify >= 4.0.0, <= 4.3.12. Fixed: v4.3.15 (realtime 1.0.18).
If you have questions about this research or would like to discuss security research opportunities, contact us at research@solidpoint.net.