CVE-2026-48742: Cross-Team IDOR in Coolify Livewire Service Components — Container RCE via Image Substitution
| CVE | CVE-2026-48742 |
|---|---|
| GHSA | GHSA-4vv5-7vg4-3v6x |
| Severity | High CVSS v3.1 base score 8.8 |
| CVSS vector | CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:C/C:H/I:H/A:H |
| Weaknesses | CWE-639 — Authorization Bypass Through User-Controlled Key (IDOR) • CWE-862 — Missing Authorization |
| Product | Coolify (open-source self-hostable PaaS, Laravel 12 / Livewire 3 / PHP 8.4) |
| Affected | All v4.x through v4.1.0 (commit 49656aa) |
| Fixed in | v4.1.1 (PR #10358, merged 2026-05-22); fix confirmed in v4.1.2 (released 2026-06-04) |
| Reported by | Louis Sanchez — Voke Cyber |
| Vendor credit | andrasbacsai accepted promptly, fixed within 4 days, requested CVE assignment, credited researcher in advisory |
| Disclosure | CVE-2026-48742 assigned 2026-05-22. Advisory in draft; researcher write-up published 2026-06-24. |
Summary
Coolify's Livewire service management component (Project/Service/Index.php) loaded service objects by UUID without applying a team-ownership scope. Coolify's entire Laravel policy layer had been temporarily disabled ("Authorization temporarily disabled" comments throughout app/Policies/), leaving per-query scopes as the sole access control. Any authenticated user on the instance who knew a victim's service UUID could navigate to that service's management page, read its configuration, modify it, delete it, or — via the child Heading component — trigger a pull-and-restart with a swapped container image, causing Coolify to SSH into the victim's server and run arbitrary containers. Fixed in v4.1.1 via PR #10358.
Background: Coolify's authorization architecture
Coolify is a multi-tenant self-hosted PaaS. A single instance hosts multiple teams; each team owns projects, environments, and resources (applications, databases, services). Resources run on team-owned servers via SSH. Teams are supposed to be isolated — Team A cannot read or act on Team B's resources.
Authorization is designed with two layers:
- Laravel policy classes — app/Policies/*.php (e.g., ServicePolicy::view(), ServiceApplicationPolicy::update()). These define who is allowed to perform which actions on which resource types.
- Per-query ownership scopes — model methods like ownedByCurrentTeam() that filter database queries to the current team's records.
At the time of discovery, the policy layer was entirely disabled. Every policy in app/Policies/ contained commented-out logic with the annotation "Authorization temporarily disabled." The CanUpdateResource middleware was similarly a pass-through (return $next($request) with the body commented out). Policies returned true unconditionally for every authorization check.
This meant authorization enforcement resided entirely in per-query ownership scopes. Any component that loaded a resource without the team-ownership scope had no effective access control.
Vulnerability 1 — Unscoped service lookup in Project/Service/Index.php
The mount() method of app/Livewire/Project/Service/Index.php (line 111 in the affected version) loaded the service model using a bare UUID lookup:
// VULNERABLE — no team-ownership scope applied $this->service = Service::whereUuid($this->parameters['service_uuid'])->first(); // Policy check that returned true unconditionally: $this->authorize('view', $this->service); // ServicePolicy::view() = true
The URL for this component includes project_uuid and environment_uuid parameters, but Index.php captured them from the route and never validated them against the loaded service. An attacker could supply their own project_uuid and environment_uuid (or random strings) in the URL — the loaded service was determined solely by the service_uuid parameter, which was not team-scoped.
The correct pattern existed in the sibling component, Configuration.php, which is rendered one level up in the same URL hierarchy:
// SAFE — team-scoped chain; each step anchored to the current team $project = currentTeam()->projects()->select('id','uuid','team_id') ->where('uuid', $parameters['project_uuid'])->firstOrFail(); $environment = $project->environments()->select('id','uuid','name','project_id') ->where('uuid', $parameters['environment_uuid'])->firstOrFail(); $this->service = $environment->services() ->whereUuid($this->parameters['service_uuid'])->firstOrFail(); $this->authorize('view', $this->service);
Index.php was written alongside a component that had the correct pattern. The logic was not missing from the codebase — it was missing from this one component.
The same unscoped lookup was present in two additional components:
- app/Livewire/Project/Service/DatabaseBackups.php (line 31) — unscoped service load in mount()
- app/Livewire/Project/Database/Import.php (line 302, stack_service_uuid branch) — unscoped lookup when handling the service-database import path
Vulnerability 2 — No authorization on Heading action methods
app/Livewire/Project/Service/Heading.php is a child component rendered inside Index.php. It receives the service model via Livewire binding (:service="$service" in the blade template). Once the parent loaded a victim's service into the Livewire snapshot, the child component operated on that model for all subsequent actions.
The action methods in Heading.php had no authorize() calls:
// No authorization check on any of these methods: public function start() { StartService::run($this->service); } public function stop() { StopService::dispatch($this->service); } public function restart() { RestartService::dispatch($this->service); } public function forceDeploy() { /* triggers deploy pipeline */ } public function pullAndRestartEvent() { /* triggers SSH + docker compose up */ }
The Livewire snapshot is server-signed, so the attacker does not forge the model — they drive their browser to the URL that loads the victim model, and the signed snapshot contains the unscoped victim service.
Attack scenario
Prerequisite: Any authenticated account on the Coolify instance. Any team, any role — including the lowest-privilege team member. The attacker must know (or have seen) a victim service UUID and stack service UUID. These are 24-character Cuid2 values that appear in browser URLs and are routinely shared in support tickets, screenshots, and collaborative documentation.
Read: service configuration disclosure
Navigate to /project/[own_uuid]/environment/[own_uuid]/service/[victim_service_uuid]/[victim_stack_uuid]. The page loads the victim's service. The attacker can read the FQDN, container image, log-drain settings, public port exposure, and environment variables exposed in Alpine.js component state.
Modify: rewrite docker-compose on victim's server
Call Index::submitApplication() or Index::submitDatabase() to update the victim service's configuration. This writes directly to the victim's server — rewriting the image, FQDN, port exposure, or other service parameters in the docker-compose definition.
Escalate: run arbitrary container on victim's server
- Call submitApplication() to replace the container image with an attacker-controlled image (e.g., attacker/evil:latest).
- Call Heading::pullAndRestartEvent().
- Coolify SSHes into the victim's server and executes docker compose up with the attacker-supplied image.
- The attacker's container runs on the victim's server with access to the victim's docker network, shared volumes, and any data mounted to that service stack.
Destroy: delete victim service with attacker's own password
Call Index::deleteApplication($attackerPassword). The delete confirmation step validates the attacker's password — not the victim's — against the attacker's own account. The victim's resource is permanently deleted.
Disrupt: stop or restart victim's production service
Call Heading::stop() to take down the victim's running production service. No confirmation required.
CVSS vector rationale
CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:C/C:H/I:H/A:H
- AV:N — The Coolify web dashboard is network-accessible.
- AC:L — No special conditions. UUID is the only requirement and is obtainable from shared URLs.
- PR:L — Requires any authenticated Coolify account. No elevated role needed.
- UI:N — No victim interaction required. The attacker drives the exploit entirely from their own browser session.
- S:C (Scope Changed) — The impact crosses team boundaries and reaches the victim's physical server infrastructure. The attacker's Coolify account is the exploited component; the victim's server, deployed services, and data are the impacted scope.
- C:H — Full read access to service configuration including environment variables; container execution on victim's server.
- I:H — Can rewrite service configuration, swap container images, modify database services, and add attacker-controlled containers to victim's infrastructure.
- A:H — Can stop, delete, and permanently destroy victim's production services with no recovery path.
The test gap
Coolify's test suite included dedicated cross-team IDOR coverage at the time of discovery:
- tests/Feature/CrossTeamIdorServerProjectTest.php
- tests/Feature/CrossTeamIdorLogsTest.php
- tests/Feature/ActivityMonitorCrossTeamTest.php
- tests/Feature/ResourceOperationsCrossTenantTest.php
None of these covered Project/Service/Index, Project/Service/Heading, or Project/Service/DatabaseBackups. The test infrastructure existed for exactly this bug class; the surface was not in scope. A regression test covering the cross-team service path was added as part of PR #10358.
The fix (PR #10358)
The fix was merged 2026-05-22 and shipped in v4.1.1. It replaces the unscoped lookup in Index.php with the team-scoped chain from Configuration.php:
// AFTER — scoped chain matching Configuration.php pattern $project = currentTeam()->projects()->select('id','uuid','team_id') ->where('uuid', $parameters['project_uuid'])->firstOrFail(); $environment = $project->environments()->select('id','uuid','name','project_id') ->where('uuid', $parameters['environment_uuid'])->firstOrFail(); $this->service = $environment->services() ->whereUuid($this->parameters['service_uuid'])->firstOrFail(); $this->authorize('view', $this->service);
Additional changes in the same PR: AuthorizesRequests added to Heading.php action methods; start(), stop(), restart(), forceDeploy(), and pullAndRestartEvent() now include $this->authorize() calls before dispatching. Same fix applied to DatabaseBackups.php and Import.php.
Remediation
- Update to v4.1.1 or later immediately. v4.1.2 (2026-06-04) is the current release and contains the fix. All v4.x releases prior to v4.1.1 are vulnerable.
- If running a pinned version, verify PR #10358 is present. Check your git log for the merge commit or inspect app/Livewire/Project/Service/Index.php to confirm the team-scoped chain is in the mount() method.
- Audit team membership. Any user account on the instance was capable of reaching any service on the instance if they had a UUID. Review who has Coolify accounts and reduce to the minimum necessary.
- Review audit logs for unexpected cross-team service access. Look for service configuration changes from users who do not own the affected teams, or unexpected container restarts.
Disclosure timeline
- 2026-05-18 — Vulnerability discovered during source-code audit of coollabsio/coolify v4.1.0 (commit 49656aa). GHSA-4vv5-7vg4-3v6x filed via private vulnerability reporting.
- 2026-05-22 — andrasbacsai accepted report, fixed in PR #10358, requested CVE assignment. GitHub Security assigned CVE-2026-48742. Louis Sanchez credited in advisory.
- 2026-05-22 — Advisory in draft state.
- 2026-06-04 — v4.1.2 released; fix confirmed in current codebase.
- 2026-06-24 — Researcher write-up published independently.
References
- GitHub Security Advisory: GHSA-4vv5-7vg4-3v6x
- Fix PR: PR #10358 — "Fix cross-team service IDOR"
- Coolify repository: github.com/coollabsio/coolify
- Blog post (story and technical overview): Authorization Temporarily Disabled: The Comment That Opened Coolify to Cross-Team IDOR
Found and reported by Louis Sanchez, Founder & Principal Security Consultant at Voke Cyber (OSCP, OSWA, CISSP, CCSK). Prior advisories: CVE-2026-35198 (HeyForm), CVE-2026-48507 (Snipe-IT), CVE-2026-42318 (GLPI), and GHSA-jxgw-q79v-g84j (Leantime).
We find the bugs tools miss
Cross-tenant authorization flaws in multi-tenant applications do not surface in dependency scans. They surface when someone traces every resource lookup back to its team-ownership anchor. That is how we approach web application and API testing for clients across the Charlotte, NC area and nationwide.
Get a Quote