Authorization Temporarily Disabled: The Comment That Opened Coolify to Cross-Team IDOR
While auditing Coolify's source code, I came across a comment in every Laravel policy file in the app/Policies/ directory. The original authorization logic was still there — just commented out. Above it: "Authorization temporarily disabled."
That comment is the kind of thing that starts as a quick workaround during a refactor and then never gets cleaned up. It is also the kind of thing that, depending on what else the codebase relies on for security, can quietly expose everything downstream of it.
In Coolify's case, the entire authorization layer had been pushed down to per-query database scopes. The policy layer was a no-op. Any Livewire component that loaded a resource by UUID without applying a team-ownership scope had zero access control. We found exactly that in three components — and one of them exposed a path from a low-privilege account to running arbitrary containers on another team's production server.
The short version
CVE-2026-48742 is a cross-team IDOR (CWE-639/CWE-862) in Coolify's Livewire service components. Any authenticated user who knows a victim team's service UUID can navigate to that service's management page. From there they can read the service configuration, rewrite the docker-compose by swapping the container image, and trigger a pull-and-restart — causing Coolify to SSH into the victim's server and run the attacker's container. Fixed in v4.1.1 (PR #10358). The maintainer responded promptly and credited the report.
What Coolify is — and why the blast radius matters
Coolify is a self-hostable, open-source platform-as-a-service built on Laravel 12 and Livewire 3. It is the self-hosted alternative to Heroku, Netlify, and Vercel — 40,000+ GitHub stars, active community, widely deployed by independent developers and small engineering teams. Organizations use it to manage application deployments, databases, and background services across their own servers, all through a unified web dashboard.
The key detail for understanding this vulnerability: Coolify is multi-tenant. A single Coolify instance can host multiple teams, each managing their own servers, projects, and deployments in isolation. Teams are supposed to be separated — one team's services should not be visible or actionable from another team's account. That separation is exactly what this vulnerability breaks.
How authorization is supposed to work
Coolify's authorization model is built on two layers working together. The first is Laravel's policy classes — files in app/Policies/ that define rules like ServicePolicy::view() and ServiceApplicationPolicy::update(). The second is per-query ownership scopes — model methods like ownedByCurrentTeam() that filter database results to the current team's resources.
When both layers function, authorization is redundant: you have to pass the policy gate and retrieve a resource that belongs to your team. When one layer fails, the other should catch the gap.
The problem I found: the policy layer was not functioning. Every policy file in app/Policies/ contained commented-out logic with the annotation "Authorization temporarily disabled." The CanUpdateResource middleware was similarly a no-op — a return $next($request) with the check commented out. The policy layer was returning true unconditionally for every authorization check.
This pushed the entire weight of access control onto the per-query scopes. Any component that loaded a resource without the team-ownership scope had zero meaningful authorization.
The two bugs I found
The first is in app/Livewire/Project/Service/Index.php — the component that renders the per-service management page where operators view and edit a service's configuration, container status, and settings. Its mount() method loads the service like this:
// VULNERABLE — bare UUID lookup, no team filter $this->service = Service::whereUuid($this->parameters['service_uuid'])->first();
The URL also contains project_uuid and environment_uuid parameters — but Index.php never validates them against the loaded service. They are captured from the route and ignored. An attacker can supply their own project UUID in the URL and load any service on the instance.
The correct pattern was right there in the sibling component. Configuration.php — the component one level up in the same URL hierarchy — uses a team-scoped chain:
// SAFE — team-scoped chain, each step validated
$project = currentTeam()->projects()->where('uuid', $project_uuid)->firstOrFail();
$environment = $project->environments()->where('uuid', $env_uuid)->firstOrFail();
$this->service = $environment->services()->whereUuid($service_uuid)->firstOrFail();
Index.php just never got the same treatment. No missing feature, no complex logic — just a missed chain in a component written alongside one that had it right.
The same unscoped lookup was present in two more components: Project/Service/DatabaseBackups.php and Project/Database/Import.php. All three were fixed in the same PR.
The second bug is in the child component app/Livewire/Project/Service/Heading.php, which is rendered inside Index.php and receives the service model via Livewire binding. Its action methods — start(), stop(), restart(), forceDeploy(), pullAndRestartEvent() — had no authorize() calls at all. Once the unscoped parent loaded a victim's service into the Livewire snapshot, the child component's actions operated on that service with no further check.
What an attacker can actually do
The prerequisite is minimal: any authenticated account on the Coolify instance, on any team, at any permission level. The attacker needs to know a victim's service_uuid — a 24-character Cuid2 value that appears in browser URLs and is routinely shared in support tickets, screenshots, and documentation.
Navigate to:
/project/[your_project_uuid]/environment/[your_env_uuid]/service/[victim_service_uuid]/[victim_stack_uuid]
The project_uuid and environment_uuid can be your own — or random strings. They are never validated against the loaded service. The page loads the victim's service configuration.
From that page, an attacker can read the victim's service configuration including environment variables visible in Alpine.js state; modify the FQDN, image, log-drain settings, and public port exposure; delete the victim's service application or database (the delete confirmation validates the attacker's own password, not the victim's); and stop or restart the victim's running production services.
The worst-case path leads to container code execution on the victim's server:
- Call submitApplication() to replace the victim service's container image with an attacker-controlled one.
- Call Heading::pullAndRestartEvent().
- Coolify SSHes into the victim's server, pulls the attacker-supplied image, and runs docker compose up.
- The attacker's container is now running on the victim's physical server, with access to the victim's docker network, volumes, and any data mounted to that service.
This is why the CVSS score is 8.8 with Scope Changed even though the attacker only needs a low-privilege account. The impact crosses team boundaries and reaches the victim's physical infrastructure.
The test infrastructure that almost caught this
One detail that stood out: Coolify has a dedicated suite of cross-team IDOR test files. I found CrossTeamIdorServerProjectTest.php, CrossTeamIdorLogsTest.php, ActivityMonitorCrossTeamTest.php, and ResourceOperationsCrossTenantTest.php. The team knew this was a bug class worth testing for. They had purpose-built infrastructure for exactly this scenario.
None of those tests covered Project/Service/Index, Project/Service/Heading, or Project/Service/DatabaseBackups. The gap was not in the testing philosophy — it was in the coverage of a specific surface. This is one of those findings that is easy to identify in retrospect and genuinely hard to catch proactively: the test suite protected most surfaces and missed three.
A regression test covering the cross-team path on the service surface was added as part of the fix PR.
The disclosure
- May 18, 2026 Discovered during source-code audit of coollabsio/coolify v4.1.0 (commit 49656aa). Code-path analysis of Livewire service component mount() methods confirmed the unscoped lookup and missing authorization on action methods.
- May 18, 2026 GHSA-4vv5-7vg4-3v6x filed via GitHub's private vulnerability reporting. Full impact chain documented, including the container RCE path via image substitution and pullAndRestartEvent.
- May 22, 2026 andrasbacsai (maintainer) accepted the report, fixed in PR #10358, and requested a CVE assignment. GitHub Security assigned CVE-2026-48742 with Louis Sanchez credited in the advisory.
- June 4, 2026 v4.1.2 released. Fix confirmed present in current codebase. Follow-up comment posted in the advisory asking andrasbacsai to publish it so the CVE record can go live on cve.org.
- June 12, 2026 Second follow-up posted in the advisory thread explaining the difference between "Close" and "Publish" in GitHub's advisory UI and requesting the publish step. No response received.
- June 24, 2026 Independent researcher write-up published. CVE-2026-48742 not yet visible on cve.org. Advisory still in draft.
On the vendor response
The fix side of this disclosure was excellent. andrasbacsai accepted the report promptly, shipped a clean fix in four days, requested the CVE himself, and credited the report in the advisory. There was no pushback, no dispute about severity. Coolify already had 25 published advisories before this one — the project has a clear track record of taking security seriously.
The publish step is a different story. The advisory has been sitting in draft since May 22. Two follow-up comments asking andrasbacsai to click "Publish advisory" went unanswered. Until that step happens, CVE-2026-48742 does not appear on cve.org, no Dependabot alert fires, and no NVD entry exists. Every self-hosted Coolify operator who has not checked the git log has no automated signal that a CVSS 8.8 vulnerability was fixed in their platform.
This is not a criticism of the fix — that part was handled well. But a fix that is invisible to operators is only half the job. This write-up exists partly to close that gap and give the Coolify community something to act on while the advisory publish step completes.
The broader lesson
The "temporarily disabled" comment in the policy files is a pattern I recognize from production codebases everywhere. It starts as a legitimate workaround — maybe the policy logic was broken during a refactor, maybe it needed to be disabled to unblock a feature launch. The intent is always to come back and re-enable it. Usually, no one does.
When your security model has a temporary bypass in it, the temporary bypass becomes your actual security model. In Coolify's case, that made every per-query scope a single point of failure. One component that forgot the scope check — out of a codebase with hundreds of components — and cross-team isolation collapsed.
The fix is straightforward and the codebase is better for it. The lesson is worth keeping: temporary security bypasses are not actually temporary.
For the full technical detail — vulnerable code paths, CVSS vector rationale, and remediation steps — see the full advisory.
Authorization gaps hide in plain sight
Missing ownership checks in multi-tenant applications do not show up in dependency scans or automated tooling. They show up when someone reads the code and asks whether each resource lookup is scoped to the requesting team. That is the same question we ask when testing web applications and APIs for clients across the Charlotte, NC area and nationwide.
Get a Quote