Vulnerability Summary
- Product: Coolify — open-source, self-hostable 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 through user-controlled keys (CWE-639, CWE-862)
- 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. It manages servers, applications, databases, and Git integrations on behalf of teams, and a single instance routinely hosts several teams at once. The team is the tenancy boundary: SSH keys, GitHub App credentials, servers, and environments are scoped to it. Every issue below crosses that boundary.
The object model matters for what follows, because the flaws are all confusions about which team owns which object. 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, mint 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 box is the entire prerequisite.
All four have similar root causes, all in Livewire components: a client-supplied integer ID reaches an Eloquent lookup 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 hijack |
| 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 + name disclosure |
The Vulnerabilities
1. Cross-tenant SSH / GitHub App private-key hijack — 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
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); // gates 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 the call gates nothing at all, for reasons covered in Root Cause Analysis 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 a one-liner. 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 you own:
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 confirms the foreign key is attached; the next deploy materialises 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 (CVE-2026-48739)
CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:C/C:H/I:H/A:H
GHSA-j395-3pqh-9r5g
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, without even an authorize() call to be ineffective:
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), took the same two IDs on the same terms 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 This attaches another team's server — on single-host setups, the Coolify orchestrator itself — to the attacker's application. The next deploy schedules 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 you own:
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 (CVE-2026-48738)
CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:C/C:H/I:H/A:N
GHSA-v2wc-fchq-pcgr
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_* token, and stored that token on a public component property (source) — which Livewire serialises 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 serialised 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 is worth knowing if you operate a multi-team instance. 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 box and scope its repository permissions accordingly.
4. Cross-tenant environment deletion and name disclosure — CVSS 8.5 (CVE-2026-48740)
CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:C/C:L/I:N/A:H
GHSA-qhpw-hc9r-wm4v
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 is 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 — and 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 Analysis
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; // the real ownership check is right there, commented out
}
This is not confined to those two classes. Of the 24 policies shipped in v4.1.1, 11 are neutered: 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 not isolated oversights, then, but the reachable components where that last remaining control was also absent. By failure mode:
- #1 called
authorize()on the wrong object — and on a policy that would have allowed it regardless. - #4 called
authorize()on the right object, against a policy that returnedtrueunconditionally. - #2 and #3 made no authorization call at all; neither component even imports the
AuthorizesRequeststrait.
Only #3 has the shape of a textbook IDOR — fetch by client-supplied ID, return the object's secrets. The rest are what an inert policy layer looks like from the outside.
Impact Analysis
Chained, these are a full tenant escape. #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. #3 extends reach into the victim's source code, #2 offers a second route onto foreign infrastructure, and #4 maps and disrupts the remaining tenants.
The prerequisite is worth stating precisely, because it is easy to overstate. 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 anyone at all where an operator has turned registration back on.
Instances running a single team are not exposed to the cross-tenant impact described here, though the inert policy layer still applies to them.
Remediation Recommendations
Immediate Actions
- Upgrade to a current 4.3.x release: v4.1.1 closes all four cross-tenant issues, 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 bugs need.
- 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, 19, 19 and 21 May 2026: The four GitHub Security Advisories filed, one per finding
- By 22 May 2026: All four advisories accepted by the maintainer; three CVEs reserved against them
- 22 May 2026: Maintainer commits fixes for all four, within one to four days of each individual advisory
- 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 as of this writing. Anyone tracking Coolify through the advisory database or an 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
These four issues were found, confirmed against a live multi-team instance, and reported by the SolidPoint security research team. 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.