Skip to content

Cross-Tenant Resource Cloning via Broken Object-Level Authorization in cloneTo()

Critical
andrasbacsai published GHSA-ggrr-wrvr-x83v Jul 2, 2026

Package

composer coollabsio/coolify (Composer)

Affected versions

< 4.0.0-beta.464

Patched versions

4.0.0-beta.464

Description

Summary

The cloneTo($destination_id) Livewire action in ResourceOperations.php authorizes the source resource but does not authorize or scope the destination to the current team. It resolves destinations via StandaloneDocker::find($destination_id) / SwarmDocker::find($destination_id) — plain Eloquent find() with no team filtering. Neither model has a global scope for team isolation, and StandaloneDockerPolicy::update() returns true unconditionally (the team check is commented out). This allows a team member to clone applications, databases, and services onto another team's server infrastructure.

A parallel issue exists in moveTo($environment_id), which resolves destinations via Environment::findOrFail($environment_id) without team scoping — allowing a user to move resources into another team's project environment.

Severity

Critical (CVSS 3.1: 9.1)

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

  • Attack Vector: Network (Livewire POST request)
  • Attack Complexity: Low (destination IDs are sequential integers)
  • Privileges Required: Low (any team member with update permission on a resource)
  • User Interaction: None
  • Scope: Changed (escapes the attacker's team boundary to affect another team's infrastructure)
  • Confidentiality Impact: Low (attacker's own resource config cloned to victim's server; further access possible post-deployment)
  • Integrity Impact: High (arbitrary containers can be deployed on the victim's server)
  • Availability Impact: Low (resource consumption on the victim's server)

Affected Component

  • app/Livewire/Project/Shared/ResourceOperations.phpcloneTo() (lines 48–58)
  • app/Livewire/Project/Shared/ResourceOperations.phpmoveTo() (lines 351–356) — parallel issue
  • app/Policies/StandaloneDockerPolicy.phpupdate() returns true unconditionally (line 41)
  • bootstrap/helpers/applications.phpclone_application() (line 215) — no team validation

CWE

  • CWE-639: Authorization Bypass Through User-Controlled Key (IDOR)
  • CWE-863: Incorrect Authorization

Description

The Vulnerable Action

// app/Livewire/Project/Shared/ResourceOperations.php:48-58
public function cloneTo($destination_id)
{
    $this->authorize('update', $this->resource);  // Only checks SOURCE

    $new_destination = StandaloneDocker::find($destination_id);  // No team scoping
    if (! $new_destination) {
        $new_destination = SwarmDocker::find($destination_id);   // No team scoping
    }
    if (! $new_destination) {
        return $this->addError('destination_id', 'Destination not found.');
    }
    // Proceeds to clone to $new_destination without verifying team ownership

The $destination_id parameter comes directly from the Livewire action call (a POST request). The code resolves it with StandaloneDocker::find() / SwarmDocker::find() — standard Eloquent find() that queries the entire table with no team filter.

UI Filtering Is the Only Guard (Client-Side Only)

The mount() method correctly restricts servers to the current team for the UI dropdown:

// ResourceOperations.php:40
$this->servers = currentTeam()->servers->filter(fn ($server) => ! $server->isBuildServer());

And the Blade template renders only these servers as dropdown options. However, an attacker can trivially bypass this by intercepting the Livewire POST request and replacing destination_id with any integer.

No Model-Level Team Scoping

Neither StandaloneDocker nor SwarmDocker has:

  • A team_id column (ownership is indirect: destination → server → team_id)
  • An Eloquent global scope for team filtering
  • Any booted() / addGlobalScope() team isolation

The BaseModel parent class only generates UUIDs on creation — no team scoping.

Disabled Policy Authorization

Even if the code DID call $this->authorize('update', $new_destination), it would pass:

// app/Policies/StandaloneDockerPolicy.php:38-42
public function update(User $user, StandaloneDocker $standaloneDocker): bool
{
    // return $user->isAdmin() && $user->teams->contains('id', $standaloneDocker->server->team_id);
    return true;  // Team check COMMENTED OUT
}

The view() method (line 23) does check $user->teams->contains('id', $standaloneDocker->server->team_id), but update(), delete(), create(), restore(), and forceDelete() all return true unconditionally.

Clone Creates a Cross-Tenant Resource

For applications:

// bootstrap/helpers/applications.php:204-217
$newApplication = $source->replicate([...])->fill([
    'uuid' => $uuid,
    'destination_id' => $destination->id,  // Victim's destination
]);
$newApplication->save();

For databases (ResourceOperations.php:83-94):

$new_resource = $this->resource->replicate([...])->fill([
    'destination_id' => $new_destination->id,  // Victim's destination
]);
$new_resource->save();

For services (ResourceOperations.php:209-221):

$new_resource = $this->resource->replicate([...])->fill([
    'destination_id' => $new_destination->id,    // Victim's destination
    'server_id' => $new_destination->server_id,  // Victim's server
]);
$new_resource->save();

The cloned resource lives in the attacker's project but points to the victim's server. Subsequent deployment deploys containers on the victim's infrastructure.

Volume Cloning Directly Accesses Victim's Server

When cloneVolumeData is enabled, the code dispatches a VolumeCloneJob that actively writes data to the victim's server:

// ResourceOperations.php:146-149
$sourceServer = $this->resource->destination->server;
$targetServer = $new_resource->destination->server;  // Victim's server
VolumeCloneJob::dispatch($sourceVolume, $targetVolume, $sourceServer, $targetServer, ...);

This occurs during the clone operation itself — before any explicit deployment.

Parallel Issue: moveTo() Cross-Tenant Environment Transfer

// ResourceOperations.php:351-358
public function moveTo($environment_id)
{
    $this->authorize('update', $this->resource);
    $new_environment = Environment::findOrFail($environment_id);  // No team scoping
    $this->resource->update(['environment_id' => $environment_id]);

The Environment model has no team_id column and no global scope. An attacker can move their resource into another team's project environment, effectively injecting a resource into the victim's project view.

Sequential Integer IDs

The standalone_dockers table uses auto-incrementing integer primary keys:

// migration: 2023_03_27_085020_create_standalone_dockers_table.php
$table->id();  // Auto-incrementing bigint

IDs are trivially enumerable (1, 2, 3, ...).

Proof of Concept

Method 1: Livewire Request Tampering

  1. Log in as a Team A member who has update permission on an application.
  2. Navigate to the application's Resource Operations page.
  3. Open browser DevTools, select any destination from your team's dropdown, and click "Clone Resource".
  4. Intercept the Livewire POST request (e.g., via browser DevTools Network tab or Burp Suite).
  5. In the request payload, find the cloneTo method call and change destination_id from your team's destination ID (e.g., 3) to a Team B destination ID (e.g., 7).
  6. Forward the request.
  7. Observe: the clone is created in your project but with destination_id pointing to Team B's destination.
  8. Deploy the cloned resource — it deploys on Team B's server.

Method 2: Browser Console

// From the Resource Operations page, call the Livewire method directly
// with a destination_id belonging to another team
Livewire.find(document.querySelector('[wire\\:id]').getAttribute('wire:id'))
    .cloneTo(TARGET_DESTINATION_ID);

Method 3: Direct Livewire POST

# Extract the Livewire component snapshot from the page source, then:
curl -X POST "https://<coolify-host>/livewire/update" \
  -H "Cookie: <session_cookie>" \
  -H "X-CSRF-TOKEN: <csrf_token>" \
  -H "Content-Type: application/json" \
  --data '{
    "components": [{
      "snapshot": "<component_snapshot>",
      "updates": {},
      "calls": [{"method": "cloneTo", "params": [TARGET_DESTINATION_ID], "path": ""}]
    }]
  }'

Impact

  • Cross-tenant infrastructure abuse: An attacker can clone resources to any server in the Coolify instance, regardless of team ownership. Deploying the clone runs arbitrary Docker containers on the victim's server.
  • Direct server data write: With volume cloning enabled, the VolumeCloneJob writes data to the victim's server during the clone operation itself.
  • Resource injection via moveTo: An attacker can move resources into another team's project environment, gaining visibility in the victim's project and potentially disrupting their workflow.
  • Multi-tenant isolation failure: In shared Coolify installations (SaaS or multi-team self-hosted), this completely breaks team boundary isolation for server resources.

Recommended Remediation

Option 1: Add team validation in cloneTo() and moveTo() (immediate fix)

public function cloneTo($destination_id)
{
    $this->authorize('update', $this->resource);

    $new_destination = StandaloneDocker::find($destination_id);
    if (! $new_destination) {
        $new_destination = SwarmDocker::find($destination_id);
    }
    if (! $new_destination) {
        return $this->addError('destination_id', 'Destination not found.');
    }

    // CRITICAL: Verify destination belongs to current team
    if ($new_destination->server->team_id !== currentTeam()->id) {
        return $this->addError('destination_id', 'Destination not found.');
    }
    // ...
}

public function moveTo($environment_id)
{
    $this->authorize('update', $this->resource);
    $new_environment = Environment::findOrFail($environment_id);

    // CRITICAL: Verify environment belongs to current team
    if ($new_environment->project->team_id !== currentTeam()->id) {
        return $this->addError('environment_id', 'Environment not found.');
    }
    // ...
}

Option 2: Fix the policies (defense in depth)

Re-enable the commented-out team checks in StandaloneDockerPolicy and SwarmDockerPolicy:

public function update(User $user, StandaloneDocker $standaloneDocker): bool
{
    return $user->teams->contains('id', $standaloneDocker->server->team_id);
}

Option 3: Scope queries to current team (systematic fix)

Replace StandaloneDocker::find($destination_id) with a team-scoped query:

$new_destination = StandaloneDocker::whereHas('server', function ($query) {
    $query->where('team_id', currentTeam()->id);
})->find($destination_id);

All three options should ideally be applied together for defense in depth.

Credit

This vulnerability was discovered and reported by bugbunny.ai.

Severity

Critical

CVSS overall score

This score calculates overall vulnerability severity from 0 to 10 and is based on the Common Vulnerability Scoring System (CVSS).
/ 10

CVSS v3 base metrics

Attack vector
Network
Attack complexity
Low
Privileges required
Low
User interaction
None
Scope
Changed
Confidentiality
High
Integrity
High
Availability
Low

CVSS v3 base metrics

Attack vector: More severe the more the remote (logically and physically) an attacker can be in order to exploit the vulnerability.
Attack complexity: More severe for the least complex attacks.
Privileges required: More severe if no privileges are required.
User interaction: More severe when no user interaction is required.
Scope: More severe when a scope change occurs, e.g. one vulnerable component impacts resources in components beyond its security scope.
Confidentiality: More severe when loss of data confidentiality is highest, measuring the level of data access available to an unauthorized user.
Integrity: More severe when loss of data integrity is the highest, measuring the consequence of data modification possible by an unauthorized user.
Availability: More severe when the loss of impacted component availability is highest.
CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:C/C:H/I:H/A:L

CVE ID

CVE-2026-34037

Weaknesses

No CWEs

Credits