CVE

CVE-2026-34048: Coolify's Terminal Websocket Authorization Bypass from Low-Privilege Team Member to Server Shell

Admin-only terminal bootstrap routes checked only for login state, which let a normal team member drive Coolify's realtime terminal backend and execute commands on team servers.

date: 2026-07-12
CONTENTS

Intro

I found this issue while reviewing Coolify, an open-source self-hosted PaaS, with a very direct security question in mind:

Is terminal access actually enforced at the backend trust boundary, or only in the UI?

In this case, the answer was bad.

Coolify intended terminal access to be restricted to team administrators and owners, but the realtime terminal bootstrap routes only checked whether the user was logged in. That let a low-privileged team member satisfy the websocket terminal trust checks and reach command execution on team servers.

I validated this end-to-end in a local lab built from the vulnerable revision and later reported it privately. The issue was assigned CVE-2026-34048 with:

TEXT
CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:C/C:H/I:H/A:H

Coolify: Coolify on GitHub
CVE: CVE-2026-34048

This affected Coolify, an open-source self-hosted PaaS. On its official site, Coolify states that it has 3,641+ cloud customers, and presents itself as a platform for deploying websites, databases, web applications, and 280+ one-click services. Its official v4.0 changelog also states that thousands of companies and people have been using Coolify in production for 1-2 years.

Coolify GitHub repository

Attack Chain

low-privileged team member session -> /terminal/auth and /terminal/auth/ips only check logged-in state -> realtime websocket trusts those responses -> member enumerates team server and visible SSH key UUID -> /terminal/ws accepts the session -> SSH-backed PTY is spawned -> shell access on team server


What Coolify Does

Coolify is a self-hosted PaaS and deployment platform.

It manages:

  • servers
  • applications
  • deployments
  • private keys
  • team permissions
  • terminal access to managed infrastructure

That last capability is the important one here.

Once a platform can open terminals to managed hosts, its authorization model is not just application logic anymore. It becomes an infrastructure trust boundary.

The important question was not whether the /terminal page looked admin-only.

The real question was:

Does the backend terminal path actually enforce that same authorization boundary when the websocket session is created?

In this case, it did not.


Why This Bug Was Worth Looking At

Terminal features are some of the highest-value surfaces in infrastructure software.

Why?

Because any mismatch between:

  • UI authorization
  • backend authorization
  • websocket bootstrap logic
  • host command execution

can turn a normal application user into a shell-capable operator.

That is exactly why this surface was worth testing.

I was not looking for random crashes or cosmetic permission bugs.

I was looking for a stronger class of failure:

Does an admin-only feature rely on a weaker backend trust check than the UI suggests?

That was the right question.


The Boundary I Focused On

I did not approach Coolify by fuzzing random endpoints and hoping for something interesting.

The stronger path was to identify the highest-risk boundary first.

For Coolify, that boundary was the terminal workflow:

  • the UI says terminal access is restricted
  • the terminal service is websocket-based
  • websocket services usually have separate bootstrap trust logic
  • terminal commands ultimately cross from application state into host execution

That made the bootstrap routes the right place to look.

And that is where the issue was.


Root Cause

The root cause was an authorization mismatch between the terminal UI and the terminal websocket bootstrap routes.

At the vulnerable revision:

  • GET /terminal was protected by can.access.terminal
  • POST /terminal/auth only checked auth()->check()
  • POST /terminal/auth/ips only checked auth()->check()

That means the UI was gated by terminal authorization, but the backend trust boundary was gated by simple authenticated-session presence.

The realtime service then trusted those two routes completely.

In docker/coolify-realtime/terminal-server.js:

  • verifyClient() POSTed to /terminal/auth
  • websocket session setup POSTed to /terminal/auth/ips
  • the websocket handler accepted attacker-supplied terminal command input after checking only whether the target host appeared in the returned host list

That is the whole bug chain.

Why this is exploitable

Because a normal team member could build the needed inputs from the regular application surface:

  • /servers exposed visible server UUIDs
  • /server/{uuid} exposed ip, user, and port in rendered form fields
  • /security/private-key exposed visible team private key UUIDs
  • the terminal path referenced keys through deterministic paths of the form:
TEXT
/var/www/html/storage/app/ssh/keys/ssh_key@<uuid>

So the exploit path was straightforward:

  • log in as a non-admin team member
  • call /terminal/auth
  • call /terminal/auth/ips
  • enumerate a visible server
  • enumerate a visible key UUID
  • connect to /terminal/ws
  • send the same SSH command shape the backend expects
  • receive shell output from a team host

That is not a theoretical mismatch. That is a practical backend authorization failure.


What Makes This a Security Issue, Not Just a UI Mismatch

The important distinction is backend trust and command execution.

Plenty of bugs look like:

  • "the button is hidden"
  • "the page is blocked"
  • "the UI says you should not be here"

That alone is not enough.

The real question is:

Can the lower-privileged user still satisfy the backend trust checks that matter?

Here, the answer was yes.

This was not:

  • a broken menu
  • a missing frontend check
  • a cosmetic routing problem

It was:

  • websocket bootstrap authorization too weak
  • terminal host authorization derived from that weak trust boundary
  • actual shell access on managed infrastructure

That is why this was a real security issue.


PoC

I validated this in a controlled local lab built from:

TEXT
06f60c9a98bead0c932c6adf7fd43a45d9149048

The lab used:

  • base URL: http://127.0.0.1:18000
  • low-privileged member account: test2@example.com
  • target server: localhost -> coolify-testing-host:22 as root
  • visible key UUID: ssh
  • websocket endpoint: ws://127.0.0.1:6002/terminal/ws

Step 1: confirm the UI boundary

The member account was not intended to have terminal access through the normal admin-facing UI.

That established the expected security boundary.

Step 2: call the bootstrap routes directly

Using the member session, I sent:

  • POST /terminal/auth
  • POST /terminal/auth/ips

Both succeeded.

/terminal/auth/ips returned terminal-authorized hosts including:

TEXT
coolify-testing-host
host.docker.internal
localhost
127.0.0.1

That proved the backend bootstrap routes trusted the member session.

Step 3: enumerate server and key metadata

From normal authenticated pages, the same member could enumerate:

  • visible server UUIDs
  • server connection fields
  • visible team private key UUIDs

That was enough to drive the terminal path without needing secret key material disclosure.

Step 4: open the terminal websocket

Using the same authenticated session and XSRF token, I connected to:

TEXT
ws://127.0.0.1:6002/terminal/ws

Step 5: send the terminal command payload

The payload used the same command format expected by the terminal backend:

JSON
{"command":["timeout 30 ssh -i /var/www/html/storage/app/ssh/keys/ssh_key@ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o PasswordAuthentication=no -o ConnectTimeout=10 -o ServerAliveInterval=5 -o RequestTTY=no -o LogLevel=ERROR -p '22' 'root'@'coolify-testing-host' 'bash -se' << \\P0C\nprintf '__COOLIFY_POC_BEGIN__\\n'; id; whoami; hostname; printf '__COOLIFY_POC_END__\\n'\nP0C"]}

Step 6: observe remote shell output

The websocket returned:

TEXT
pty-ready
__COOLIFY_POC_BEGIN__
uid=0(root) gid=0(root) groups=0(root)
root
efa027413801
__COOLIFY_POC_END__

That was the important proof.

Not just:

  • route access
  • not just websocket acceptance
  • not just metadata exposure

But actual command execution on the managed host through the admin-only terminal path.


Why This PoC Was Strong

One part of this chain would already have been interesting.

For example:

  • member access to /terminal/auth
  • or member access to /terminal/auth/ips

But that would still leave room for dismissal.

The stronger validation was end-to-end:

  • member session
  • backend bootstrap success
  • websocket acceptance
  • PTY creation
  • remote shell output

That closes the gap between "authorization bug in theory" and "practical infrastructure impact in reality."

It also made severity much easier to defend.


Severity and Classification

This issue was properly classified as Critical.

The classification was:

  • CWE-862: Missing Authorization
  • CVSS:
TEXT
CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:C/C:H/I:H/A:H

That makes sense.

The claim is not that an unauthenticated attacker can get shell access from nothing.

The claim is that:

  • a low-privileged team member
  • can satisfy backend terminal trust checks
  • and reach command execution on team infrastructure

That is a major scope change from application RBAC failure into managed-host impact.

So even though privileges are low rather than none, the result is still clearly critical.


Why This Was Still Worth Reporting

Some people underrate vulnerabilities that start with PR:L.

That is a mistake when the affected feature is terminal access.

The real question is not:

"Was the attacker already logged in?"

The real question is:

"What can that lower-privileged user reach once backend authorization is wrong?"

In this case, the answer was:

  • host selection data
  • terminal bootstrap trust
  • SSH-backed PTY execution
  • shell access on team servers

That is far beyond an ordinary member-permission bug.


Fix Analysis

The minimum correct fix is straightforward:

  • apply can.access.terminal to both POST /terminal/auth and POST /terminal/auth/ips
  • ensure non-admin members are denied from both routes
  • add regression coverage for:
    • unauthenticated users denied
    • authenticated members denied
    • authorized admins and owners allowed

That addresses the immediate trust-boundary failure.

In my local validation patch, applying the terminal authorization middleware to those two routes removed the member-to-terminal path.

But the stronger lesson is that the backend should not trust attacker-controlled SSH command strings as the primary source of target metadata.

Recommended hardening is:

  • bind terminal requests to a server or container identifier authorized server-side
  • re-validate authorization when the command is executed, not only when the websocket is opened
  • reduce reliance on client-supplied terminal command structure for security decisions

That is the kind of remediation you want for a terminal feature:

  • fix the immediate missing authorization
  • then tighten the deeper trust model

Disclosure

This issue was reported privately through GitHub's security reporting flow.

The report included:

  • the authorization mismatch
  • the affected routes
  • the realtime backend trust path
  • a working local lab validation
  • end-to-end proof showing remote shell output

The issue was later assigned:

CVE-2026-34048


What This Bug Actually Teaches

The main lesson here is simple:

admin-only UI does not matter if the backend bootstrap channel trusts weaker state.

That is the real problem class.

  • A page can be protected correctly.
  • A menu can be hidden correctly.
  • A terminal screen can be blocked correctly.

None of that matters if:

  • the websocket bootstrap path checks only for login state
  • the terminal backend trusts those bootstrap responses
  • and the resulting session can reach host command execution

Once a platform manages infrastructure, authorization mismatches stop being ordinary access-control mistakes. They become infrastructure-impacting vulnerabilities.

That is the real takeaway.


Key Points

  • websocket bootstrap endpoints are real security boundaries
  • UI-only authorization is not enough for terminal features
  • low-privileged users can still produce critical impact when backend trust is wrong
  • enumeration of server metadata plus visible key UUIDs made this bug practical
  • end-to-end runtime validation matters when defending severity
  • the right fix is consistent backend authorization, not stronger frontend gating

Final Words

This vulnerability was not about a clever payload.

It was about identifying the right trust boundary.

Coolify meant terminal access to be admin-only. But the realtime terminal backend trusted routes that only checked whether the user was logged in.

From there, a low-privileged team member could drive the websocket terminal path and reach shell execution on a team server.

That is why this became CVE-2026-34048.