Summary
Coolify's API controllers consistently validate server ownership with Server::whereTeamId($teamId) before any operation. However, multiple Livewire web UI components accept server_id and destination_uuid from URL query parameters without any team ownership validation, allowing cross-team resource deployment.
Finding 1: Cross-Team Resource Deployment via Unscoped Destination/Server Lookup
CVSS: CVSS:3.1/AV:N/AC:H/PR:L/UI:N/S:C/C:H/I:H/A:H (8.5)
Safe wrapper — Used consistently in API controllers:
$server = Server::whereTeamId($teamId)->whereUuid($serverUuid)->first();
Also correctly scoped in ResourceOperations.php (clone operation):
$new_destination = StandaloneDocker::whereHas('server', fn($q) => $q->where('team_id', currentTeam()->id))->find($destination_id);
Missing team check — app/Livewire/Project/Resource/Create.php lines 82-89:
$destination = StandaloneDocker::whereUuid($destination_uuid)->first(); // NO team check
$service_payload = [
'server_id' => (int) $server_id, // from query param — NO team check
'destination_id' => $destination->id,
];
$service = Service::create($service_payload);
Same pattern in 6+ more Livewire components (DockerCompose.php, DockerImage.php, GithubPrivateRepository.php, etc.) and all create_standalone_* helper functions.
The project_uuid and environment_uuid ARE validated against currentTeam() (line 24), but server_id and destination_uuid are NOT.
PoC:
GET /project/{attacker_project}/{attacker_env}/new?type=one-click-service-wordpress&server_id=VICTIM_SERVER_ID&destination=VICTIM_DESTINATION_UUID
Finding 2: Unscoped Server Lookup in Boarding Flow
CVSS: CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:C/C:N/I:L/A:L (6.5)
Same file, line 121 correctly uses Server::ownedByCurrentTeam(). But line 124:
$this->createdServer = Server::find($this->selectedExistingServer);
// selectedExistingServer is integer from #[Url] — directly from URL
Then validateServer() calls instant_remote_process(['ls /'], $this->createdServer, true) — SSH command on unscoped server. Server IDs are auto-increment integers.
Impact
- Cross-team container deployment to another team's server
- Remote command execution on unscoped server via boarding flow
- API is correctly protected — Livewire web UI only
Remediation
// Replace all occurrences of:
StandaloneDocker::whereUuid($destination_uuid)->first();
// With:
StandaloneDocker::whereHas('server', fn($q) => $q->where('team_id', currentTeam()->id))->where('uuid', $destination_uuid)->first();
// Boarding flow:
Server::ownedByCurrentTeam()->find($this->selectedExistingServer);
Summary
Coolify's API controllers consistently validate server ownership with
Server::whereTeamId($teamId)before any operation. However, multiple Livewire web UI components acceptserver_idanddestination_uuidfrom URL query parameters without any team ownership validation, allowing cross-team resource deployment.Finding 1: Cross-Team Resource Deployment via Unscoped Destination/Server Lookup
CVSS: CVSS:3.1/AV:N/AC:H/PR:L/UI:N/S:C/C:H/I:H/A:H (8.5)
Safe wrapper — Used consistently in API controllers:
Also correctly scoped in
ResourceOperations.php(clone operation):Missing team check —
app/Livewire/Project/Resource/Create.phplines 82-89:Same pattern in 6+ more Livewire components (DockerCompose.php, DockerImage.php, GithubPrivateRepository.php, etc.) and all
create_standalone_*helper functions.The
project_uuidandenvironment_uuidARE validated againstcurrentTeam()(line 24), butserver_idanddestination_uuidare NOT.PoC:
Finding 2: Unscoped Server Lookup in Boarding Flow
CVSS: CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:C/C:N/I:L/A:L (6.5)
Same file, line 121 correctly uses
Server::ownedByCurrentTeam(). But line 124:Then
validateServer()callsinstant_remote_process(['ls /'], $this->createdServer, true)— SSH command on unscoped server. Server IDs are auto-increment integers.Impact
Remediation