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.php — cloneTo() (lines 48–58)
app/Livewire/Project/Shared/ResourceOperations.php — moveTo() (lines 351–356) — parallel issue
app/Policies/StandaloneDockerPolicy.php — update() returns true unconditionally (line 41)
bootstrap/helpers/applications.php — clone_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
- Log in as a Team A member who has update permission on an application.
- Navigate to the application's Resource Operations page.
- Open browser DevTools, select any destination from your team's dropdown, and click "Clone Resource".
- Intercept the Livewire POST request (e.g., via browser DevTools Network tab or Burp Suite).
- 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).
- Forward the request.
- Observe: the clone is created in your project but with
destination_id pointing to Team B's destination.
- 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.
Summary
The
cloneTo($destination_id)Livewire action inResourceOperations.phpauthorizes the source resource but does not authorize or scope the destination to the current team. It resolves destinations viaStandaloneDocker::find($destination_id)/SwarmDocker::find($destination_id)— plain Eloquentfind()with no team filtering. Neither model has a global scope for team isolation, andStandaloneDockerPolicy::update()returnstrueunconditionally (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 viaEnvironment::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:LAffected Component
app/Livewire/Project/Shared/ResourceOperations.php—cloneTo()(lines 48–58)app/Livewire/Project/Shared/ResourceOperations.php—moveTo()(lines 351–356) — parallel issueapp/Policies/StandaloneDockerPolicy.php—update()returnstrueunconditionally (line 41)bootstrap/helpers/applications.php—clone_application()(line 215) — no team validationCWE
Description
The Vulnerable Action
The
$destination_idparameter comes directly from the Livewire action call (a POST request). The code resolves it withStandaloneDocker::find()/SwarmDocker::find()— standard Eloquentfind()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: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_idwith any integer.No Model-Level Team Scoping
Neither
StandaloneDockernorSwarmDockerhas:team_idcolumn (ownership is indirect:destination → server → team_id)booted()/addGlobalScope()team isolationThe
BaseModelparent 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:The
view()method (line 23) does check$user->teams->contains('id', $standaloneDocker->server->team_id), butupdate(),delete(),create(),restore(), andforceDelete()all returntrueunconditionally.Clone Creates a Cross-Tenant Resource
For applications:
For databases (ResourceOperations.php:83-94):
For services (ResourceOperations.php:209-221):
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
cloneVolumeDatais enabled, the code dispatches aVolumeCloneJobthat actively writes data to the victim's server:This occurs during the clone operation itself — before any explicit deployment.
Parallel Issue:
moveTo()Cross-Tenant Environment TransferThe
Environmentmodel has noteam_idcolumn 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_dockerstable uses auto-incrementing integer primary keys:IDs are trivially enumerable (1, 2, 3, ...).
Proof of Concept
Method 1: Livewire Request Tampering
cloneTomethod call and changedestination_idfrom your team's destination ID (e.g.,3) to a Team B destination ID (e.g.,7).destination_idpointing to Team B's destination.Method 2: Browser Console
Method 3: Direct Livewire POST
Impact
VolumeCloneJobwrites data to the victim's server during the clone operation itself.moveTo: An attacker can move resources into another team's project environment, gaining visibility in the victim's project and potentially disrupting their workflow.Recommended Remediation
Option 1: Add team validation in
cloneTo()andmoveTo()(immediate fix)Option 2: Fix the policies (defense in depth)
Re-enable the commented-out team checks in
StandaloneDockerPolicyandSwarmDockerPolicy:Option 3: Scope queries to current team (systematic fix)
Replace
StandaloneDocker::find($destination_id)with a team-scoped query:All three options should ideally be applied together for defense in depth.
Credit
This vulnerability was discovered and reported by bugbunny.ai.