# CVE-2026-34594 — Variant Root Cause Analysis

## Summary

A materially distinct **alternate-trigger variant** of the Coolify Destination
Network command-injection (CVE-2026-34594) was found and confirmed at runtime.
The fix shipped in PR #9228 (`4.0.0-beta.471`) added regex validation to the
`network` Livewire **property** of the `Destination\New\Docker` and
`Destination\Show` components, plus a `setNetworkAttribute` model mutator and
`escapeshellarg()` on every shell consumer. However, the same root cause
(unsanitized, attacker-controlled Docker network name interpolated into the SSH
`bash -se` here-doc via `StandaloneDocker::created` → `instant_remote_process`)
is reachable through a **different Livewire component that PR #9228 did not
touch**: `App\Livewire\Server\Destinations::add($name)`. That method takes the
network name as a **method argument** (not a `#[Validate]`-annotated property)
and calls `StandaloneDocker::create(['network' => $name, …])` with no
validation of its own.

- On the **vulnerable** build (`4.0.0-beta.470`) the variant reproduces
  end-to-end: `add("x;id > /tmp/marker #")` executes `id` **as root** on the
  managed server (marker = `uid=0(root) gid=0(root) groups=0(root)`), 2/2
  attempts.
- On the **fixed** build (`4.0.0-beta.471`) the variant is **blocked** by the
  `StandaloneDocker::setNetworkAttribute()` model mutator, which throws
  `InvalidArgumentException` ("Invalid Docker network name…") before the
  `created` event runs (confirmed in `laravel.log`: exception at
  `app/Models/StandaloneDocker.php:37` originating from
  `app/Livewire/Server/Destinations.php:60` → `add()`). No marker, no DB row.

This is therefore an **alternate trigger, not a bypass**: it reaches the same
sink from a different entry point and is stopped only by the fix's
defense-in-depth model mutator, not by the fix's primary (regex-validation)
layer — which was never applied to `Server\Destinations::add()`.

## Fix Coverage / Assumptions

PR #9228 (merge `9e96a20a49f32e46d7a31e66e1aa6338b029f284`, branch commit
`3d1b9f53a0aec74468be75675bcaaaed0fd41d46`, released in `4.0.0-beta.471`)
relies on three assumptions:

1. **Every user-controlled network value enters through a `#[Validate]`-annotated
   `$network` Livewire property.** The fix only added
   `regex:/^[a-zA-Z0-9][a-zA-Z0-9._-]*$/` to `Destination\New\Docker::$network`
   and `Destination\Show::$network`. It assumes there is no other component that
   accepts a network name from the user.
2. **The model mutator is a universal backstop.** `setNetworkAttribute()` on
   `StandaloneDocker`/`SwarmDocker` throws on any invalid name, so any
   `::create(['network' => …])` is assumed to be caught even if a component
   skips property validation.
3. **`escapeshellarg()` on every shell consumer** neutralizes any value that
   somehow reaches the sink.

Paths explicitly covered: `destination.new.docker.submit()`,
`destination.show.submit()`/`delete()`, `StandaloneDocker::created`,
`ApplicationDeploymentJob`, `DatabaseBackupJob`, `StartService`, `Init`, and
`bootstrap/helpers/proxy.php`.

## Variant / Alternate Trigger

**Entry point:** `App\Livewire\Server\Destinations` (Livewire component name
`server.destinations`, route `server.destinations` →
`/server/{uuid}/destinations`), method **`add($name)`**.

```php
// app/Livewire/Server/Destinations.php  (UNCHANGED by PR #9228)
public function add($name)
{
    if ($this->server->isSwarm()) {
        … SwarmDocker::create([ 'name' => …, 'network' => $this->name, … ]); // pre-existing bug
    } else {
        $this->authorize('create', StandaloneDocker::class);
        $found = $this->server->standaloneDockers()->where('network', $name)->first();
        if ($found) { $this->dispatch('error', 'Network already added…'); return; }
        StandaloneDocker::create([
            'name' => $this->server->name.'-'.$name,
            'network' => $name,                 // <-- attacker-controlled method ARGUMENT
            'server_id' => $this->server->id,
        ]);
    }
    $this->createNetworkAndAttachToProxy();
}
```

The intended UI flow is "Scan for Destinations" → "Add `<discovered network>`"
(`wire:click="add('{{ data_get($network, 'Name') }}')"`), where `$name` is a
real Docker network name. **But Livewire method arguments are not constrained to
rendered values**: an authenticated user can POST `/livewire/update` directly and
call `add` with an arbitrary string. `$name` is a method parameter, so the
`#[Validate]` property rules added by the fix do **not** apply to it.

**Trigger path (vulnerable):**
`POST /livewire/update` → hydrate `server.destinations` → `add("x;id > /tmp/marker #")`
→ `$this->authorize('create', StandaloneDocker::class)` (passes for any
authenticated user) → `StandaloneDocker::create(['network' => "x;id … #"])`
→ `StandaloneDocker::created` boot event →
`instant_remote_process(["docker network inspect x;id > /tmp/marker # …"], $server)`
→ `SshMultiplexingHelper::generateSshCommand` →
`ssh root@<host> 'bash -se' << delim` → remote bash executes `id > /tmp/marker`
as root. Same sink, same root cause, **different component and input vector**.

**Why it is not a bypass (fixed build):**
`StandaloneDocker::create(['network' => $name])` invokes
`setNetworkAttribute($name)` (Eloquent mutator via `fill()`/`setAttribute()`;
`network` is in `$fillable`). The mutator throws
`InvalidArgumentException` for names not matching
`/^[a-zA-Z0-9][a-zA-Z0-9._-]*$/`. The exception propagates out of `add()` (no
try/catch) to Livewire → HTTP 500; the `created` event never fires and no
command runs. Confirmed in `laravel.log`:
`InvalidArgumentException … at app/Models/StandaloneDocker.php:37` with stack
frame `app/Livewire/Server/Destinations.php(60) … ->add()`.

## Impact

- **Package/component affected:** `coollabsio/coolify` —
  `app/Livewire/Server/Destinations.php` (`server.destinations` component,
  `add()` method), reaching the shared `StandaloneDocker::created` sink,
  `instant_remote_process`, and `SshMultiplexingHelper`.
- **Affected versions (as tested):** confirmed on official image
  `ghcr.io/coollabsio/coolify:4.0.0-beta.470` (tag commit
  `575b0766d12bad2a78febff72ab59c017772bcf7`); blocked on
  `ghcr.io/coollabsio/coolify:4.0.0-beta.471` (tag commit
  `914d7e0b50505bc1fd56c34974fca09ad354e92a`, fix merge
  `9e96a20a49f32e46d7a31e66e1aa6338b029f284`).
- **Risk level:** High (parity with the parent CVE). Authenticated user with
  destination-management permissions → arbitrary command execution **as root**
  on the Coolify-managed server over SSH, at destination-creation time. The
  `StandaloneDockerPolicy::create` check returns `true` for any authenticated
  user, so any logged-in user can reach this.

## Impact Parity

- **Disclosed/claimed maximum impact (parent):** authenticated arbitrary command
  execution on the Coolify host/managed server via the Destination Network
  Management network field.
- **Reproduced impact from this variant run:** authenticated command execution
  as `root` on the destination server through the
  `server.destinations.add($name)` entry point — marker files contain
  `uid=0(root) gid=0(root) groups=0(root)` (2/2 attempts on beta.470).
- **Parity:** `full` — the same root-cause primitive (root command execution via
  the SSH here-doc) is reached from a different entry point with the same
  attacker capability (authenticated destination-management user).
- **Not demonstrated:** persistence / post-exploitation beyond the `id` proof
  marker; the fixed build's behavior (clean rejection vs. unhandled 500) is
  characterized but no further exploitation was attempted.

## Root Cause

The `network` attribute of a Docker Destination is attacker-controlled input
that, in the vulnerable code, is interpolated **unsanitized** into shell
commands executed on the destination server over SSH
(`instant_remote_process()` → `SshMultiplexingHelper::generateSshCommand()` →
`ssh root@<host> 'bash -se' << $delim`). Because the network becomes the body
of a here-doc fed to `bash -se`, shell metacharacters (`;`, `#`, `$()`,
backticks) are interpreted by the remote bash. PR #9228 closed this for the
`Destination\New\Docker` and `Destination\Show` components and added a model
mutator + `escapeshellarg()` as defense in depth.

The same underlying bug is still reachable on the vulnerable build through
`Server\Destinations::add($name)`: `$name` is a Livewire **method argument**
(not a validated property), so the fix's regex-validation layer never sees it,
and on beta.470 there is no mutator and no `escapeshellarg()` on the
`created`-event command. On beta.471 the model mutator (`setNetworkAttribute`)
catches it — which is why this is an alternate trigger rather than a bypass.

Fix commit: `3d1b9f53a0aec74468be75675bcaaaed0fd41d46` (merge
`9e96a20a49f32e46d7a31e66e1aa6338b029f284`, PR #9228).

## Reproduction Steps

1. Reference: `bundle/vuln_variant/reproduction_steps.sh` (self-contained,
   idempotent).
2. What the script does:
   - Installs the `docker compose` v2 plugin if missing; pulls the official
     Coolify images (`4.0.0-beta.470` vulnerable, `4.0.0-beta.471` fixed) plus
     `postgres:15-alpine`, `redis:7-alpine`, `coolify-testing-host`,
     `coolify-realtime`.
   - Brings up the real Coolify stack (Laravel app + Postgres + Redis + Soketi +
     SSH target) on a private bridge network, with
     `IS_WINDOWS_DOCKER_DESKTOP=true` so the seeded localhost server targets
     `coolify-testing-host`.
   - Bootstraps an authenticated root user, the testing-host SSH private key,
     and a usable `coolify-testing-host` server (idempotent `setup.php`).
   - From the `coolify-testing-host` container, runs an authenticated Python
     client against the live Coolify web app (`http://coolify:8080`): logs in,
     loads `/server/{uuid}/destinations`, extracts the **`server.destinations`**
     Livewire snapshot (deliberately NOT `destination.new.docker`), and POSTs
     `/livewire/update` calling `add("x;id > /tmp/coolify_variant_pwned_<tag> 2>&1 #")`.
   - Repeats 2× on the vulnerable build, then tears down and repeats 2× on the
     fixed build.
3. Expected evidence:
   - Vulnerable build: `/tmp/coolify_variant_pwned_vuln_*` on the SSH target
     contains `uid=0(root) gid=0(root) groups=0(root)`; Livewire returns
     `200` and re-renders the destinations page.
   - Fixed build: Livewire returns `500`; `laravel.log` shows
     `InvalidArgumentException: Invalid Docker network name… at
     app/Models/StandaloneDocker.php:37` from `Server/Destinations.php:60`
     (`add()`); no marker, no `standalone_dockers` row with the malicious
     network.

## Evidence

- `bundle/logs/vuln_variant/reproduction_steps.log` — full orchestrated run log
  (both runs).
- `bundle/logs/vuln_variant/stack_vulnerable.log`, `setup_vulnerable.log` —
  vulnerable stack bring-up and bootstrap.
- `bundle/logs/vuln_variant/vulnerable_attempt_1.log`,
  `vulnerable_attempt_2.log` — variant exploit output for beta.470
  (`livewire_update_status=200`, `snapshot_found name=server.destinations`).
- `bundle/logs/vuln_variant/vulnerable_marker_1.txt`,
  `vulnerable_marker_2.txt` — captured marker content
  (`uid=0(root) gid=0(root) groups=0(root)`).
- `bundle/logs/vuln_variant/stack_fixed.log`, `setup_fixed.log` — fixed stack.
- `bundle/logs/vuln_variant/fixed_attempt_1.log`, `fixed_attempt_2.log` —
  variant exploit output for beta.471 (`livewire_update_status=500`, no
  marker).
- `bundle/logs/vuln_variant/debug_fixed_evidence.txt` — `laravel.log` excerpt
  proving the `setNetworkAttribute` mutator (`StandaloneDocker.php:37`) threw
  from `Server/Destinations.php:60` (`add()`), and that no malicious
  `standalone_dockers` row was inserted.
- `bundle/logs/vuln_variant/debug_vulnerable_evidence.txt` — interactive debug
  capture of the vulnerable run (status 200 + `uid=0(root)` marker).
- `bundle/logs/vuln_variant/run1/` — preserved copy of run #1 logs/manifest.
- `bundle/vuln_variant/runtime_manifest.json` — runtime evidence manifest
  (`entrypoint_kind=api_remote`, `variant_confirmed_on_vulnerable=true`,
  `bypass_on_fixed=false`).

Environment: official Coolify Docker images on the shared Docker daemon; the
authenticated HTTP client runs in the `coolify-testing-host` container (reaches
`coolify:8080` over `coolifynet-variant`); command execution is performed by
Coolify over SSH (`bash -se` here-doc) from the `coolify` container to
`coolify-testing-host` as `root`.

## Recommendations / Next Steps

1. **Add `ValidationPatterns::dockerNetworkRules()` validation to
   `Server\Destinations::add($name)`** (validate `$name` before `::create`,
   with a friendly `dispatch('error', …)` mirroring `Destination\New\Docker`),
   so invalid input is rejected with a user-facing message instead of an
   unhandled `InvalidArgumentException` 500. This closes the surface gap the
   variant exposes.
2. **Fix the pre-existing swarm-branch bug** in `add()` (`'network' =>
   $this->name` → `'network' => $name`); on beta.471 the mutator's `string`
   type hint turns the current null into a `TypeError`.
3. **Keep the model mutator and `escapeshellarg()`** as defense in depth
   (already in place).
4. **Treat Livewire method arguments as untrusted input generally**: method
   parameters bypass `#[Validate]` property rules, so any component method that
   forwards a user-supplied argument into a model `::create()` or a shell
   command must validate the argument explicitly. A repo-wide audit of
   `instant_remote_process()`/`remote_process()` call sites for raw model
   attribute interpolation is recommended.

## Additional Notes

- **Idempotency:** `reproduction_steps.sh` was executed twice end-to-end. Both
  runs completed without crashing (exit 1 = alternate trigger, not bypass, per
  the stage's exit-code convention) and produced identical verdicts:
  vulnerable 2/2 markers (`uid=0(root)`), fixed 0/2 blocked. `setup.php` is
  idempotent and deletes prior `standalone_dockers`/`swarm_dockers` rows so
  `add()` does not short-circuit on "Network already added". Each attempt uses
  a unique marker path so repeated runs never collide.
- **Outcome classification:** ALTERNATE_TRIGGER_CONFIRMED (not a bypass). The
  variant reproduces on the vulnerable build and is blocked on the fixed build
  by the model mutator. The fix's *primary* mitigation (regex validation) does
  not cover `server.destinations.add()`, so a complete fix should extend
  validation to that component.
- **Scope/threat model:** Coolify's `SECURITY.md` actively supports 4.x for
  security updates and does not exclude authenticated command injection from
  scope; the maintainers explicitly fixed this class in PR #9228. The variant
  stays within the same authenticated→root trust boundary as the parent CVE.
- **Other candidates considered and ruled out:**
  - `Destination\Show::submit()` (update) — covered by the fix's regex
    validation on `$network` + mutator.
  - `Destination\Show::delete()` — covered by `escapeshellarg()`.
  - `Destination\Show` `$serverIp` field — the server IP is escaped via
    `SshMultiplexingHelper::escapedUserAtHost()` (`escapeshellarg`) for the SSH
    here-doc sink, so it is not a clean variant of the network-field bug for
    the primary sink.
  - `Server::created` / `InstallDocker` destination creation — use hardcoded
    network names ('coolify'/'coolify-overlay'), not user-controlled.
- **Repo state:** only read-only `git show`/`git log`/`git rev-parse` was used
  on the project-cache repo; HEAD remains at `575b0766d` (beta.470). No
  checkout was performed.
