Vulnerability summary
- Product: Coolify — open-source, self-hostable platform-as-a-service, or PaaS (Heroku / Vercel alternative)
- CVE IDs: CVE-2026-48738, CVE-2026-48739, CVE-2026-48740, plus a fourth finding with no CVE assigned
- CVSS Scores: 9.9, 9.9, 9.6 (Critical) and 8.5 (High)
- Vulnerability Type: Cross-tenant authorization bypass — CWE-639 (authorization bypass through user-controlled key) and CWE-862 (missing authorization)
- Affected Versions: >= 4.0.0, <= 4.1.0
- Patched Versions: v4.1.1 (released 27 May 2026)
- GitHub Advisories: GHSA-xrvp-4pp4-8rrw, GHSA-j395-3pqh-9r5g, GHSA-v2wc-fchq-pcgr, GHSA-qhpw-hc9r-wm4v — all still in draft at the time of writing, so the advisory pages and CVE records are not yet publicly reachable
Affected product
Coolify is a self-hosted PaaS built on Laravel and Livewire — the server-driven UI layer Laravel applications use to expose PHP methods to the browser. It manages servers, applications, databases, and Git integrations on behalf of teams, and a single instance commonly hosts several teams. The team is the tenancy boundary: SSH keys, GitHub App credentials, servers, and environments are scoped to it. Every finding below crosses that boundary.
Because every finding turns on which team owns which object, the object model is worth setting out first. A team owns projects; each project holds environments (production, staging, and so on); each environment holds the deployed resources — applications, databases, services. Alongside those, a team owns the infrastructure they run on: servers, the Docker networks configured on them (destinations), stored SSH private keys, and Git sources such as GitHub Apps. So an Application in the code below is one deployed resource, inside one environment, inside one project — and every object named here is supposed to belong to exactly one team.
Vulnerability overview
One low-privileged account on a multi-team instance was enough to steal other teams' SSH and GitHub App private keys, issue live GitHub App installation tokens, and delete or enumerate their environments. No elevated role is needed, and no membership in the victim team — an ordinary session on the instance is the only prerequisite.
All four share a root cause, and all four sit in Livewire components: a client-supplied integer ID reaches a lookup in Eloquent, Laravel's ORM, that was never scoped to the caller's team (CWE-639) — and in two of them, with no authorization check anywhere in the path (CWE-862). In each case the interface only ever exposes the caller's own objects — a team-scoped dropdown, or an ID baked into the page they are already on — and the handler behind it simply never re-checks that the ID it received came from there.
| # | Component | CVE | CVSS | Type |
|---|---|---|---|---|
| 1 | Project\ | None assigned | 9.9 | Cross-tenant SSH / GitHub App private-key disclosure |
| 2 | Project\ | CVE-2026-48739 | 9.9 | Cross-tenant Docker network / server attach |
| 3 | Project\ | CVE-2026-48738 | 9.6 | Cross-tenant GitHub App installation-token mint |
| 4 | Project\ | CVE-2026-48740 | 8.5 | Cross-tenant environment deletion and name disclosure |
Findings
1. Cross-tenant SSH / GitHub App private-key disclosure
CVSS 9.9 · CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:C/C:H/I:H/A:H · GHSA-xrvp-4pp4-8rrw · no CVE assigned
How it is meant to work. An application built from a private Git repository needs an SSH key to clone it. On that application's Source page, the user picks one of their own team's stored private keys from a dropdown; Livewire calls setPrivateKey($privateKeyId) with the chosen key's ID; the application stores that reference; and on the next deploy Coolify writes that key into the build container so git clone over SSH succeeds. The dropdown is correctly scoped — it only ever lists the caller's own keys.
Where it breaks. At the moment the chosen ID is written. Project\Application\Source::setPrivateKey() (source) accepted an arbitrary private-key ID from the client and attached it to the attacker's own application, authorizing only the application, never the key. Nothing re-checked that the ID had come from that scoped dropdown:
public function setPrivateKey(int $privateKeyId)
{
$this->authorize('update', $this->application); // authorizes the app, not the key
$this->privateKeyId = $privateKeyId; // foreign key attached — no ownership check
$this->syncData(true);
}
There are two independent failures here. The authorize() call gates the application, not the key — so even a working policy would have confirmed only that the attacker owns the application they are attaching a foreign key to. And in this version it decided nothing at all: ApplicationPolicy::update() had its logic commented out and returned an unconditional allow, which Root cause sets out below.
On the next deploy, Coolify writes the referenced private key into the build container (source):
executeInDocker($this->deployment_uuid, "echo '{$private_key}' | base64 -d | tee /root/.ssh/id_rsa > /dev/null");
The build is attacker-controlled, so exfiltrating /root/.ssh/id_rsa is trivial. Coolify stores server SSH keys and GitHub App signing keys in the same PrivateKey table (GithubApp.private_key_id, source), so the stolen key is the victim's root SSH key for its managed servers, or its GitHub App signing key.
The highest-value target is not a neighbouring team's key but the host's own. On a self-hosted instance, ProductionSeeder creates the Coolify host machine's SSH key as PrivateKey id 0 on team 0, and the host itself as Server id 0 (source). Key IDs are auto-increment integers, so no enumeration is needed to reach it.
Steps to reproduce: from the Source page of any application the attacker owns:
const c = Livewire.all().find(x => x.name === 'project.application.source');
await c.$wire.setPrivateKey(<victim_private_key_id>); // auto-increment IDs; 0 is the host's own key The response confirmed the foreign key was attached, and the next deploy materialized it in the build container.
Fix (5dda39e, shipped in v4.1.1): the key is resolved through a team-scoped query before use (source):
$this->authorize('update', $this->application);
$key = PrivateKey::ownedByCurrentTeam()->findOrFail($privateKeyId); // added in v4.1.1
$this->privateKeyId = $key->id; 2. Cross-tenant Docker network / server attach
CVSS 9.9 · CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:C/C:H/I:H/A:H · GHSA-j395-3pqh-9r5g · CVE-2026-48739
How it is meant to work. An application runs on a destination — a Docker network on one of the team's servers. To spread it across more than one machine, the user opens the application's Servers page and adds an additional server from a list of the servers their team owns. Livewire calls addServer($network_id, $server_id), the application is attached to that server's Docker network, and the next deploy schedules a container there.
Where it breaks. Both IDs are taken on trust. Project\Shared\Destination::addServer() (source) attached an arbitrary server and Docker network to the attacker's application with no ownership check on either argument — and, unlike finding 1, here there is not even an ineffective authorize() call to fall back on:
public function addServer(int $network_id, int $server_id)
{
// no authorize() call at all, and no ownership check on either argument
$this->resource->additional_networks()->attach($network_id, ['server_id' => $server_id]);
$this->dispatch('refresh');
} promote(), twenty lines up in the same class (source), accepted the same two unvalidated IDs and additionally rewrote the resource's primary destination. Both were fixed in the same commit.
The pattern they should have followed was the next method down. removeServer() (source) resolves its server argument through a team-scoped query:
// removeServer(), directly below addServer() in the same file, v4.1.0:
$server = Server::ownedByCurrentTeam()->findOrFail($server_id); // server scoped
// ...though $network_id was still passed through unscoped here too The call attached another team's server — on single-host setups, the Coolify orchestrator itself — to the attacker's application. The next deploy then scheduled attacker-controlled containers onto that host, on a Docker network shared with every co-tenant container already running there.
Steps to reproduce: from the Servers page of any application the attacker owns:
const c = Livewire.all().find(x => x.name === 'project.shared.destination');
await c.$wire.addServer(<victim_network_id>, <victim_server_id>); Fix (59111e8, shipped in v4.1.1): in both methods, each argument is validated against the caller's team and the absent actor check is added (source):
$server = Server::ownedByCurrentTeam()->findOrFail($server_id);
$network = StandaloneDocker::ownedByCurrentTeam()->where('server_id', $server->id)->findOrFail($network_id);
$this->authorize('update', $this->resource); // the absent actor check, also added Two further commits before the v4.1.1 cut hardened the same component: f44ace3 validates that the network and server actually belong together, and 8e033c5 makes network promotion atomic.
3. Cross-tenant GitHub App installation-token mint
CVSS 9.6 · CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:C/C:H/I:H/A:N · GHSA-v2wc-fchq-pcgr · CVE-2026-48738
How it is meant to work. Creating an application from a private GitHub repository, the user first picks which of their team's GitHub Apps to connect through. Livewire calls loadRepositories($github_app_id); Coolify signs a JWT with that App's stored RSA private key, exchanges it at api.github.com for a short-lived installation token, and uses the token to fetch the list of repositories the user can then choose from. The component keeps the token on a property because it needs it again a step later, to list the branches of whichever repository gets picked (source).
Where it breaks. Twice, and the second one is what turns a read into a credential leak. Project\New\GithubPrivateRepository::loadRepositories() (source) looked up a GitHub App by client-supplied ID with no team scope, signed an installation JWT with the victim team's RSA key, exchanged it for a live ghs_-prefixed installation token, and stored that token on a public component property (source) — which Livewire serializes straight back to the browser:
public string $token; // public Livewire property → serialized back to the caller
// ...
$this->github_app = GithubApp::where('id', $github_app_id)->first(); // no team scope
$this->token = generateGithubInstallationToken($this->github_app); // victim team's token
The minted token authenticates as the victim's GitHub App directly against api.github.com, carrying that App's installation scopes. Coolify's own App manifest requests contents: read and metadata: read as mandatory, adds pull_requests: write when preview deployments are enabled, and administration: write if the operator ticked the runner-setup option (source). At minimum, then, the attacker can enumerate and clone every private repository the App is installed on; where the operator widened the scopes, the reach extends to pull requests or repository administration. GitHub installation tokens expire after one hour, but on an unpatched instance the attacker simply repeats the call to mint a fresh one.
Steps to reproduce: open /project/<uuid>/<env>/new?type=private-gh-app&destination=<own_destination>. The destination parameter is required — without it the parent page redirects and the component never mounts. The attacker's own GitHub App dropdown may be empty; the handler never consults it.
const c = Livewire.all().find(x => x.name === 'project.new.github-private-repository');
await c.$wire.loadRepositories(<victim_github_app_id>); // token returned in the response Fix (e9b8320, refined by d443758, shipped in v4.1.1): the lookup is team-scoped, and the token is demoted from a public property to a local variable so it is no longer serialized to the client (source):
$this->github_app = GithubApp::ownedByCurrentTeam()
->where('is_public', false)
->whereNotNull('app_id')
->findOrFail($github_app_id);
$token = generateGithubInstallationToken($this->github_app); // now a local variable
One caveat applies to multi-team instances. The ownedByCurrentTeam() scope on GithubApp is deliberately broader than a strict team match (source):
public static function ownedByCurrentTeam()
{
return GithubApp::where(function ($query) {
$query->where('team_id', currentTeam()->id)
->orWhere('is_system_wide', true); // system-wide apps stay shared by design
});
}
That breadth was a deliberate second pass, not an oversight. The first patch used a strict where('team_id', currentTeam()->id); four days later d443758 — "allow system-wide private apps across teams" — widened it back to the shared scope, because a system-wide GitHub App is meant to be usable by every team. The consequence is that any team on the instance can still mint installation tokens for an App flagged is_system_wide. If you rely on that feature, treat a system-wide GitHub App as a credential shared with every tenant on the instance and scope its repository permissions accordingly.
4. Cross-tenant environment deletion and name disclosure
CVSS 8.5 · CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:C/C:L/I:N/A:H · GHSA-qhpw-hc9r-wm4v · CVE-2026-48740
How it is meant to work. A project holds environments — typically production and staging. On an environment's settings page the user clicks Delete; the page has already mounted the component with that environment's ID, and delete() removes the environment if it is empty, or refuses and explains why if it still contains resources. The ID is never something the user types; it comes from the page they are already on.
Where it breaks. The ID is a plain public Livewire property, so the browser can rewrite it before calling delete(). Project\DeleteEnvironment (source) exposed a client-settable public int $environment_id and resolved it with no team scope, in both mount() and delete(). Environment IDs are sequential integers, so the whole instance was enumerable:
public int $environment_id; // client-settable
// ...
public function delete()
{
$environment = Environment::findOrFail($this->environment_id); // no team scope
$this->authorize('delete', $environment);
if ($environment->isEmpty()) { $environment->delete(); }
// non-empty: the error response leaks the name —
return $this->dispatch('error', "<strong>Environment {$environment->name}</strong> has defined resources, please delete them first.");
} Empty environments belonging to other teams were deleted; non-empty ones leaked their name through the error response.
This component is the clearest illustration of the underlying problem. It does hand the correct object to the policy — authorize('delete', $environment) asks about the victim's environment, not the attacker's — yet the check still passes.
Steps to reproduce: from any page that mounts the component:
const c = Livewire.all().find(x => x.name === 'project.delete-environment');
await c.$wire.set('environment_id', <victim_environment_id>);
await c.$wire.delete(); Fix (df166ac, merged as #10349, shipped in v4.1.1): both lookups are scoped to the caller's team, and the property is marked #[Locked] so that Livewire rejects the client-side write the proof of concept depends on (source):
#[Locked] // Livewire rejects client-side writes to this property
public int $environment_id;
// ...both mount() and delete() now resolve through:
$environment = Environment::ownedByCurrentTeam()->findOrFail($this->environment_id); Root cause
The policy layer was inert
Only two of the four components call authorize() at all. Neither call could have stopped anything: the Laravel policies behind them had their logic commented out and replaced with an unconditional allow.
ApplicationPolicy::update(), reached by finding 1 (source):
public function update(User $user, Application $application): Response
{
// Authorization temporarily disabled
/*
if ($user->isAdmin()) {
return Response::allow();
}
return Response::deny('As a member, you cannot update this application.<br/><br/>You need at least admin or owner permissions.');
*/
return Response::allow(); // every authorize('update', $application) passes
} EnvironmentPolicy::delete(), reached by finding 4 (source):
public function delete(User $user, Environment $environment): bool
{
// return $user->isAdmin() && $user->teams->contains('id', $environment->project->team_id);
return true; // unconditional allow
} In both classes the real ownership check is still present in the file, commented out directly above the unconditional allow that replaced it.
This is not confined to those two classes. Of the 24 policies shipped in v4.1.1, 11 are inoperative: ApplicationPolicy and ApiTokenPolicy carry the explicit // Authorization temporarily disabled marker, and nine more keep the team check commented out above an unconditional return true — ApplicationPreviewPolicy, ApplicationSettingPolicy, DatabasePolicy, EnvironmentPolicy, GithubAppPolicy, ProjectPolicy, ServerPolicy, ServicePolicy, SharedEnvironmentVariablePolicy.
Why every fix scopes a query
That explains the shape of the patches: every fix scopes a query rather than tightening a policy, because query scoping was the only control still operating. The four findings are therefore not isolated oversights. They are the reachable components in which that last remaining control was also missing. By failure mode:
- Finding 1 called
authorize()on the wrong object — and on a policy that would have allowed it regardless. - Finding 4 called
authorize()on the right object, against a policy that returnedtrueunconditionally. - Findings 2 and 3 made no authorization call at all; neither component even imports the
AuthorizesRequeststrait.
Only finding 3 has the shape of a textbook insecure direct object reference (IDOR) — fetch by client-supplied ID, return the object's secrets. The rest are what a policy layer that was no longer deciding anything looks like from the outside.
Impact
Chained, these are a full tenant escape. Finding 1 yields a neighbouring team's SSH key and root on its servers — on a single-host instance, the Coolify host itself, where every team's encrypted credentials live. Finding 2 offers a second route onto foreign infrastructure, finding 3 extends reach into the victim's source code, and finding 4 maps and disrupts the remaining tenants.
The prerequisite is easy to overstate, so it is worth stating precisely. is_registration_enabled ships true in the schema, but CreateNewUser flips it to false the moment the first user registers (source), and RootUserSeeder does the same on env-seeded installs. A default self-hosted instance therefore does not hand out accounts; reopening registration is an explicit choice in Settings → Advanced. The realistic attacker is an account the instance already has — a contractor, a junior on one project, a stale membership — or, where an operator has re-enabled registration, anyone at all.
Single-team instances are not exposed to the cross-tenant impact described here, though the disabled policy layer still applies to them.
Remediation
Immediate actions
- Upgrade to a current 4.3.x release: v4.1.1 closes all four cross-tenant vulnerabilities, but the disabled authorization policies described above persist through v4.1.2. They are restored in the v4.2.0 pre-release, and the first stable build carrying that restoration is v4.3.0. Treat v4.1.1 as the minimum, not the target.
- Confirm registration is still closed: Coolify shuts it off automatically after the first user, but it is a one-switch re-enable in Settings → Advanced. On an internet-reachable instance, leaving it open is the lowest-friction way for an attacker to obtain the session these flaws require.
- Rotate credentials: if you ran an affected version on a multi-team instance, rotate the SSH keys and GitHub App keys held by Coolify, along with any secrets reachable from a host those keys open.
- Review any system-wide GitHub App: by design it remains usable by every team on the instance, so its repository scopes should be treated as shared.
For developers of multi-tenant applications
Resolve client-supplied IDs through a tenant-scoped query, so a foreign ID yields a 404 instead of a permission decision. Where the framework can mark a property server-authoritative — Livewire's #[Locked], Rails' strong parameters — use it, so the ID never becomes attacker-controlled to begin with.
Disclosure timeline
- 16 May 2026: Private report emailed to
security@coollabs.io - 18–21 May 2026: The four GitHub Security Advisories filed, one per finding (18, 19, 19 and 21 May)
- 22 May 2026: All four advisories accepted by the maintainer, three CVEs reserved against them, and fixes committed for all four
- 26 May 2026: Further hardening commits to the destination and GitHub App paths
- 27 May 2026: Fixes ship in Coolify v4.1.1
- 21 Jul 2026: Authorization policies restored in the v4.2.0 pre-release; first stable build carrying the restoration is v4.3.0, on 12 Aug 2026
- 26 Aug 2026: Publication of this write-up; the GitHub advisories remain in draft and the CVE records are still pending publication
References
- 5dda39e —
fix(source): scope private key and source selection to current team - 59111e8 —
fix(destination): scope server and network selection to current team - e9b8320 —
Fix source selection flow(GitHub App scoping) - d443758 —
fix(github): allow system-wide private apps across teams - df166ac —
fix(environment): scope DeleteEnvironment lookups to current team(#10349) - f44ace3 —
fix(destination): validate network server pairing - 8e033c5 —
fix(destination): promote networks atomically
Conclusion
With the policy layer inert, the only thing separating one team's infrastructure from another was whether an individual query happened to be scoped — and in these four components it was not. That is the whole path from an ordinary team membership to another team's SSH keys, source code, and servers.
Operators of shared instances should confirm they are on a current 4.3.x release and, if they ran an affected version with more than one team, rotate the credentials Coolify holds on their behalf.
The remediation deserves credit. All four were fixed on 22 May, one to four days after each individual advisory, using the team-scoped lookups we recommended, and in two places the maintainer went further than we asked — #[Locked] on DeleteEnvironment's parameter, and the absent authorize() call added to addServer(). Each fix shipped with a regression test (ApplicationSourceCrossTeamTest, CrossTeamDestinationAttachTest, DeleteEnvironmentTeamScopingTest, GithubPrivateRepositoryTest).
The publication side has not kept pace. All four advisories were accepted and three CVEs reserved against them, but three months after the fixes shipped they are still in draft and the CVE records are still unpublished — CVE-2026-48738, 48739 and 48740 return no record from MITRE or NVD at the time of writing. Anyone tracking Coolify through the advisory database or a software composition analysis (SCA) feed alone therefore has no signal that these existed. The only public trace is the commit log, which is why this write-up cites commits rather than advisory pages.
Vulnerability credits
We found these four vulnerabilities and confirmed them against a live multi-team instance. Each was filed privately with the maintainer before any public detail; this write-up follows three months after the fixes shipped.
Findings: GHSA-xrvp-4pp4-8rrw, GHSA-j395-3pqh-9r5g (CVE-2026-48739), GHSA-v2wc-fchq-pcgr (CVE-2026-48738), GHSA-qhpw-hc9r-wm4v (CVE-2026-48740). Affected: Coolify >= 4.0.0, <= 4.1.0. Fixed: v4.1.1.
If you have questions about this research or would like to discuss security research opportunities, please contact us at research@solidpoint.net.