{"repro_id":"REPRO-2026-00275","version":6,"title":"Coolify terminal WebSocket endpoints lack proper authorization checks, allowing low-privileged members to access terminal functionality and achieve remote command execution on managed hosts.","repro_type":"security","status":"published","severity":"critical","cvss_score":9.9,"description":"Coolify terminal WebSocket authorization bypass (CVE-2026-34047 / GHSA-652w-qv22-2r7c). The backend terminal bootstrap routes POST /terminal/auth and POST /terminal/auth/ips only check authentication, not authorization. A low-privileged authenticated Member can call these endpoints directly, obtain terminal-enabled host IPs, generate an API token, retrieve the team SSH private key via the API, and execute commands as root on managed hosts via the WebSocket terminal backend.","root_cause":"## Summary\n\nCVE-2026-34047 is an authorization bypass in Coolify's terminal flow. The user-facing terminal pages are protected by the `can.access.terminal` / `canAccessTerminal` authorization gate, but the backend bootstrap endpoints used by the terminal WebSocket server (`POST /terminal/auth` and `POST /terminal/auth/ips`) only require authentication in the vulnerable release. A low-privileged authenticated team `member` can call those endpoints directly, obtain an authorized host list, connect to the real `/terminal/ws` WebSocket service, and cause Coolify's terminal server to spawn an SSH command toward a managed host.\n\n## Impact\n\n- **Package/component affected:** `coollabsio/coolify`, specifically the Laravel terminal authorization routes in `routes/web.php` and the `docker/coolify-realtime/terminal-server.js` WebSocket terminal backend.\n- **Affected versions:** Coolify versions up to and including `4.0.0-beta.470` are affected according to the ticket.\n- **Patched version:** `4.0.0-beta.471` / the middleware fix adding `can.access.terminal` to the terminal bootstrap routes.\n- **Risk level and consequences:** Critical. An authenticated low-privileged team member can bypass terminal authorization and reach the command-execution path intended only for team administrators/owners. In a real Coolify deployment, this can provide command execution on configured/managed hosts through Coolify's terminal SSH flow.\n\n## Impact Parity\n\n- **Disclosed/claimed maximum impact:** Code execution / remote command execution on managed hosts by an authenticated low-privileged `Member` user.\n- **Reproduced impact from this run:** Full WebSocket terminal command execution path was reproduced. The script authenticates as a Coolify team `member`, receives HTTP 200 from `/terminal/auth` and `/terminal/auth/ips`, opens `/terminal/ws`, sends a terminal SSH command payload, and receives output from a managed-host SSH peer after the host executes the attacker-supplied shell commands (`printf 'REPRO_WS_COMMAND_EXECUTED\\n'; whoami; hostname; pwd`).\n- **Parity:** `full` for the requested WebSocket terminal flow and code/command-execution impact. The managed host is a local unprivileged SSH peer created by the script, but the boundary crossed is the real Coolify terminal WebSocket server and OpenSSH command path.\n- **Not demonstrated:** The script does not retrieve a production SSH private key or spawn a root shell on a real external host. It proves the terminal authorization bypass reaches real command execution through the product WebSocket/SSH path using a controlled local managed-host peer.\n\n## Root Cause\n\nIn the vulnerable route definitions, Coolify protects the visible terminal pages with `can.access.terminal` but leaves the terminal backend authorization endpoints without that middleware:\n\n```php\nRoute::get('/terminal', TerminalIndex::class)->name('terminal')->middleware('can.access.terminal');\nRoute::post('/terminal/auth', function () {\n    if (auth()->check()) {\n        return response()->json(['authenticated' => true], 200);\n    }\n    return response()->json(['authenticated' => false], 401);\n})->name('terminal.auth');\n\nRoute::post('/terminal/auth/ips', function () {\n    if (auth()->check()) {\n        $team = auth()->user()->currentTeam();\n        $ipAddresses = $team->servers\n            ->where('settings.is_terminal_enabled', true)\n            ->pluck('ip')\n            ->filter()\n            ->values();\n        return response()->json(['ipAddresses' => $ipAddresses->all()], 200);\n    }\n    return response()->json(['ipAddresses' => []], 401);\n})->name('terminal.auth.ips');\n```\n\nThe real terminal WebSocket server (`docker/coolify-realtime/terminal-server.js`) trusts those endpoints. During WebSocket verification it calls `http://coolify:8080/terminal/auth`; on connection it calls `http://coolify:8080/terminal/auth/ips`; when a client sends a `command` message it parses the SSH target, checks only whether the target is present in the returned authorized IP list, and then calls `pty.spawn('ssh', ...)`. Because the backend route authorization is too weak, a low-privileged `member` receives the same authorization bootstrap data as an owner/admin.\n\nThe fix is to require the same terminal authorization gate on the backend bootstrap routes, e.g. adding `->middleware('can.access.terminal')` to both `terminal.auth` and `terminal.auth.ips`. The reproduction script applies this middleware as the fixed negative-control behavior and verifies that the `member` is blocked with HTTP 403 before WebSocket command execution.\n\n## Reproduction Steps\n\n1. Run `bundle/repro/reproduction_steps.sh` from the bundle root (or with `PRUVA_ROOT` pointing at the bundle).\n2. The script:\n   - Reuses the prepared Coolify repository at `<project_cache_dir>/repo` when available.\n   - Checks out `v4.0.0-beta.470` and starts the real Laravel application with a SQLite test database.\n   - Starts the real `docker/coolify-realtime/terminal-server.js` WebSocket service.\n   - Creates an owner, a low-privileged `member`, a terminal-enabled server record for `127.0.0.1`, and a local managed-host SSH peer on `127.0.0.1:2222`.\n   - Logs in as the member through Coolify's magic-link route and makes real HTTP requests to `POST /terminal/auth/ips` and `POST /terminal/auth`.\n   - Connects to `ws://127.0.0.1:6002/terminal/ws` with the member session cookies and sends a terminal SSH command payload.\n   - Verifies the vulnerable path returns command output from the managed host.\n   - Applies the fixed middleware to the two terminal bootstrap routes, repeats the same member flow, and verifies the endpoints return 403 and no managed-host command executes.\n3. Expected evidence of reproduction:\n   - `bundle/logs/vuln_result.txt` shows `IPS_STATUS=200` and `AUTH_STATUS=200` for the member user.\n   - `bundle/logs/terminal_vuln.log` shows WebSocket authentication, authorized IP retrieval, received command, parsed SSH metadata, and `Spawning PTY process for terminal session`.\n   - `bundle/logs/managed_host_vuln.log` shows `MANAGED_HOST_PROCESS` with the attacker-supplied shell command and `MANAGED_HOST_PROCESS_EXIT ... output='REPRO_WS_COMMAND_EXECUTED...`.\n   - `bundle/logs/ws_client_vuln.log` shows the WebSocket client received `REPRO_WS_COMMAND_EXECUTED`, `whoami`, `hostname`, and `pwd` output followed by `pty-exited`.\n   - `bundle/logs/fixed_result.txt` shows `IPS_STATUS=403` and `AUTH_STATUS=403` for the same member role.\n\n## Evidence\n\nKey runtime artifacts from the final successful runs:\n\n- `bundle/repro/reproduction_steps.sh` — self-contained reproducer.\n- `bundle/repro/runtime_manifest.json` — runtime evidence manifest with `service_started=true`, `healthcheck_passed=true`, and `target_path_reached=true`.\n- `bundle/logs/reproduction_steps.log` — orchestration log with final summary:\n  - `Vulnerable: Member endpoints 200/200, WebSocket managed-host command execution=true`\n  - `Fixed:      Member endpoints 403/403, WebSocket blocked=true`\n  - `SUCCESS: CVE-2026-34047 reproduced with WebSocket command execution and fixed negative control`\n- `bundle/logs/vuln_result.txt`:\n  - `IPS_STATUS=200`\n  - `IPS_BODY={\"ipAddresses\":[\"127.0.0.1\",\"coolify-testing-host\",\"host.docker.internal\",\"localhost\"]}`\n  - `AUTH_STATUS=200`\n  - `AUTH_BODY={\"authenticated\":true}`\n- `bundle/logs/terminal_vuln.log` proves the real terminal backend path:\n  - `Websocket client authentication succeeded.`\n  - `Fetched authorized terminal hosts for websocket session.`\n  - `Received websocket message. { ... keys: [ 'command' ] ... }`\n  - `Parsed terminal command metadata. { targetHost: '127.0.0.1', ... }`\n  - `Spawning PTY process for terminal session.`\n  - `PTY process exited. { ... exitCode: 0 ... }`\n- `bundle/logs/managed_host_vuln.log` proves command execution on the managed-host peer:\n  - `MANAGED_HOST_PROCESS label=vuln command=\" printf 'REPRO_WS_COMMAND_EXECUTED\\\\n'; whoami; hostname; pwd \"`\n  - `MANAGED_HOST_PROCESS_EXIT label=vuln rc=0 output='REPRO_WS_COMMAND_EXECUTED\\nvscode\\n...\\n/tmp\\n'`\n- `bundle/logs/ws_client_vuln.log` proves the WebSocket client received command output:\n  - `CLIENT_MSG=\"pty-ready\"`\n  - `CLIENT_MSG=\"REPRO_WS_COMMAND_EXECUTED\\r\\nvscode\\r\\n...\\r\\n/tmp\\r\\n\"`\n  - `CLIENT_MSG=\"pty-exited\"`\n- `bundle/logs/fixed_result.txt` proves the negative control:\n  - `IPS_STATUS=403`\n  - `AUTH_STATUS=403`\n  - Response message: `Access to terminal functionality is restricted to team administrators`.\n- `bundle/logs/ws_client_fixed.log` shows the WebSocket handshake fails in the fixed path (`Unexpected server response: 500`) before any command reaches the managed host.\n- `bundle/logs/managed_host_fixed.log` contains only server readiness and no `MANAGED_HOST_PROCESS`, confirming no command execution in the fixed negative control.\n\nEnvironment details captured by the script include the vulnerable git commit (`575b0766d12bad2a78febff72ab59c017772bcf7` for `v4.0.0-beta.470`), local PHP version, Coolify Laravel service health, terminal WebSocket readiness, and managed-host SSH readiness.\n\n## Recommendations / Next Steps\n\n- Add `can.access.terminal` middleware to all terminal backend bootstrap routes, not only the visible terminal UI pages.\n- Keep authorization checks server-side in the WebSocket/bootstrap flow; do not rely on frontend route visibility or UI navigation controls.\n- Ensure `/terminal/auth` and `/terminal/auth/ips` enforce the same admin/owner authorization semantics as `/terminal` and resource terminal pages.\n- Add regression tests for low-privileged team members covering:\n  - `POST /terminal/auth` returns 403.\n  - `POST /terminal/auth/ips` returns 403.\n  - `/terminal/ws` fails closed and never reaches command execution when backend auth routes reject the session.\n- Upgrade affected installations to `4.0.0-beta.471` or later.\n\n## Additional Notes\n\n- Idempotency: The final reproducer was run successfully multiple times. The last two full script executions completed with exit code 0 and reproduced the vulnerable-vs-fixed behavioral difference.\n- The managed host is a controlled local SSH peer, which avoids requiring privileged system SSH setup while still proving that attacker input crosses Coolify's real HTTP/WebSocket boundary and reaches the real terminal-server `node-pty`/OpenSSH command execution path.\n- The fixed negative control uses the middleware behavior from the patch to prove the same member session is blocked with 403 before WebSocket command execution.","cve_id":"CVE-2026-34047","cwe_id":"CWE-863 (Incorrect Authorization)","source_url":"https://nvd.nist.gov/vuln/detail/CVE-2026-34047","package":{"name":"coollabsio/coolify","ecosystem":"Composer","affected_versions":"<= 4.0.0-beta.470","fixed_version":"4.0.0-beta.471"},"reproduced_at":"2026-07-08T04:53:25.898602+00:00","duration_secs":2642.0,"tool_calls":421,"handoffs":3,"total_cost_usd":16.142925669999997,"agent_costs":{"hypothesis_generator":0.0101206,"judge":0.0436928,"repro":12.267092769999998,"support":0.0504255,"vuln_variant":3.771593999999999},"cost_breakdown":{"hypothesis_generator":{"accounts/fireworks/models/glm-5p2":0.0101206},"judge":{"gpt-5.4-mini":0.0436928},"repro":{"accounts/fireworks/routers/glm-5p2-fast":4.086067769999999,"gpt-5.5":8.181025},"support":{"accounts/fireworks/routers/glm-5p2-fast":0.0504255},"vuln_variant":{"gpt-5.5":3.771593999999999}},"quality":{"confidence":"high","idempotent_verified":false,"community_verifications":0},"environment":{"sandbox_image":"ghcr.io/n3mes1s/pruva-sandbox@sha256:8096b2518d6022e13d68f885c3b8ded6b4fe607098b1a1ccbfb99abc004d1dc1"},"published_at":"2026-07-08T04:53:52.995711+00:00","retracted":false,"artifacts":[{"path":"bundle/repro/reproduction_steps.sh","filename":"reproduction_steps.sh","size":29864,"category":"reproduction_script"},{"path":"bundle/repro/rca_report.md","filename":"rca_report.md","size":10476,"category":"analysis"},{"path":"bundle/vuln_variant/reproduction_steps.sh","filename":"reproduction_steps.sh","size":13801,"category":"reproduction_script"},{"path":"bundle/vuln_variant/rca_report.md","filename":"rca_report.md","size":10721,"category":"analysis"},{"path":"bundle/artifact_promotion_manifest.json","filename":"artifact_promotion_manifest.json","size":10203,"category":"other"},{"path":"bundle/artifact_promotion_report.json","filename":"artifact_promotion_report.json","size":14307,"category":"other"},{"path":"bundle/vuln_variant/source_identity.json","filename":"source_identity.json","size":1091,"category":"other"},{"path":"bundle/vuln_variant/root_cause_equivalence.json","filename":"root_cause_equivalence.json","size":1449,"category":"other"},{"path":"bundle/logs/vuln_result.txt","filename":"vuln_result.txt","size":161,"category":"other"},{"path":"bundle/logs/fixed_result.txt","filename":"fixed_result.txt","size":43694,"category":"other"},{"path":"bundle/repro/validation_verdict.json","filename":"validation_verdict.json","size":799,"category":"other"},{"path":"bundle/repro/runtime_manifest.json","filename":"runtime_manifest.json","size":1213,"category":"other"},{"path":"bundle/logs/reproduction_steps.log","filename":"reproduction_steps.log","size":48322,"category":"log"},{"path":"bundle/logs/ws_client_vuln.log","filename":"ws_client_vuln.log","size":245,"category":"log"},{"path":"bundle/logs/managed_host_vuln.log","filename":"managed_host_vuln.log","size":1191,"category":"log"},{"path":"bundle/logs/ws_client_fixed.log","filename":"ws_client_fixed.log","size":45,"category":"log"},{"path":"bundle/logs/terminal_vuln.log","filename":"terminal_vuln.log","size":2196,"category":"log"},{"path":"bundle/logs/terminal_fixed.log","filename":"terminal_fixed.log","size":1620,"category":"log"},{"path":"bundle/logs/managed_host_fixed.log","filename":"managed_host_fixed.log","size":106,"category":"log"},{"path":"bundle/logs/server.log","filename":"server.log","size":603,"category":"log"},{"path":"bundle/vuln_variant/patch_analysis.md","filename":"patch_analysis.md","size":6200,"category":"documentation"},{"path":"bundle/vuln_variant/variant_manifest.json","filename":"variant_manifest.json","size":4427,"category":"other"},{"path":"bundle/vuln_variant/validation_verdict.json","filename":"validation_verdict.json","size":2544,"category":"other"},{"path":"bundle/vuln_variant/runtime_manifest.json","filename":"runtime_manifest.json","size":1113,"category":"other"},{"path":"bundle/logs/vuln_variant/reproduction_steps.log","filename":"reproduction_steps.log","size":3461,"category":"log"},{"path":"bundle/logs/vuln_variant/fixed_api_token_private_key_probe.log","filename":"fixed_api_token_private_key_probe.log","size":560,"category":"log"},{"path":"bundle/logs/vuln_variant/vulnerable_api_token_private_key_probe.log","filename":"vulnerable_api_token_private_key_probe.log","size":560,"category":"log"},{"path":"bundle/logs/vuln_variant/vulnerable_version.txt","filename":"vulnerable_version.txt","size":41,"category":"other"},{"path":"bundle/logs/vuln_variant/fixed_version.txt","filename":"fixed_version.txt","size":41,"category":"other"}]}