A self-hosted Heroku clone forgot to check whose tenant you're in before handing over the server list
Coolify is an open-source PaaS that manages servers, applications, databases, and deployments — think Heroku/Vercel you run yourself. In versions prior to 4.0.0-beta.471, the server and project lookup endpoints fail to scope queries to the requesting user's team_id. Any authenticated user with a valid session can substitute another team's server ID or project ID into the URL and read that team's resources back. CWE-639 — classic Authorization Bypass Through User-Controlled Key (IDOR). CVSS vector AV:N/AC:L/PR:L/UI:N/S:C/C:H/I:N/A:N reflects confidentiality-only impact but with scope change, because the data crossing the boundary belongs to a different security principal (another team).
Vendor severity HIGH (7.7) is fair *for the multi-tenant deployments that are actually affected*. The catch: most Coolify installs in the wild are single-team (a solo dev or small shop running their own apps), so the population at risk is the subset of Coolify operators who invite users from outside their trust boundary — agencies hosting client environments, internal platform teams hosting multiple product squads, etc. For those operators, the leak isn't theoretical: project objects in Coolify carry environment variables, repo URLs, and references to server connection profiles that hold SSH credentials. That's why we keep this at HIGH despite the auth requirement.
4 steps from start to impact.
Obtain any authenticated session on the target Coolify instance
- Coolify instance reachable on the network the attacker is on
- Open registration OR existing low-priv account in any team
- Most production Coolify instances are behind VPN or basic auth proxy
- Self-hosted PaaS rarely has open registration — would-be tenant must be invited
Enumerate other teams' resource IDs
/servers/{id}) and projects (/projects/{uuid}). Attacker scripts a sequential walk (for integer IDs) or harvests UUIDs from prior responses, leaked references, or shared CI runs. Standard curl + jq loop; no custom tooling.- Knowledge of the API surface (documented at coolify.io/docs)
- ID format is enumerable or guessable
- UUIDs reduce blind enumeration to oracle-style attacks
- Rate limiting at the reverse proxy slows mass walks
/servers/* and /projects/* from a single session is the telltale; default Coolify telemetry does NOT alert on this.Read cross-team server and project objects via direct ID reference
Server::find($id) / Project::find($uuid) without the whereHas('team', currentTeam()) scope. Authenticated request to another team's resource returns 200 with full object — including server hostname, IP, connection settings, project name, repo URL, and references to associated environment variables and deployments.- Target IDs from step 2
- Bug present (i.e., target < 4.0.0-beta.471)
- The advisory specifies *server and project lookups* — secret/env-var endpoints may be separately scoped. Confirm what your version actually leaks before treating as 'full env-var exposure'.
Pivot from leaked metadata to deployment compromise
- Useful intel returned in step 3
- Reachable target server or upstream repo
- MFA on Git provider
- SSH key-based auth on managed servers (default Coolify config) prevents direct hostname-only pivot
coollabsio/coolify image, run the documented upgrade flow. Per noisgate remediation SLA, complete within 180 days; for multi-tenant installs treat as ≤ 30 days.- WAFs / generic IDS rules: the request is structurally identical to a legitimate one — same path, same method, same headers, valid session cookie. Only the resource ID differs. No signature catches this.
- MFA: doesn't help — the attacker IS authenticated. MFA gates session creation, not authorization scope inside the app.
- Network segmentation alone: if all teams reach the same Coolify URL, segmentation doesn't prevent the IDOR. Per-team Coolify instances would, but that's a re-architecture, not a control.
The supporting signals.
| In-the-wild exploitation | None reported as of 2026-06-30. No GreyNoise tags, no campaigns, no incident reports. |
|---|---|
| Public PoC | GitHub Security Advisory GHSA (coollabsio/coolify) describes the flaw; no weaponized PoC repo observed but the bug is trivially reproducible with curl once you have any session. |
| EPSS | Not yet scored (CVE published 2026-06-29). Expect <1% within 30 days given auth requirement and lack of mass exposure. |
| KEV | Not listed. Unlikely to be added — confidentiality-only, auth-gated, narrow population. |
| CVSS vector | CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:C/C:H/I:N/A:N → 7.7 HIGH. Scope change reflects cross-tenant boundary. No I or A impact in the CVE itself. |
| Affected versions | All Coolify releases prior to 4.0.0-beta.471. |
| Fixed version | 4.0.0-beta.471 and later. No distro backports — Coolify ships as Docker images via coollabsio/coolify. |
| Exposure data | Shodan/Censys queries for coolify favicon hash typically return ~2,000–5,000 public Coolify dashboards globally. Of these, the subset that are multi-team is the real attack surface — likely a small minority. |
| Disclosed | 2026-06-29 via coollabsio GitHub Security Advisory. |
| Reporter | Coordinated disclosure via Coolify maintainers (coollabsio). Researcher attribution per the GHSA advisory. |
Sources.
- Coolify GHSA — coollabsio/coolify Security Advisories
- Coolify project — coollabsio/coolify GitHub
- CWE-639: Authorization Bypass Through User-Controlled Key
- The Hacker News — Coolify Discloses Critical Flaws
- heise online — Coolify critical vulnerabilities
- wz-it — Coolify CVE Overview 2025/2026
- Coolify documentation
- FIRST CVSS 3.1 specification
Why this verdict
- Auth-gated (PR:L): the attacker must already hold a valid session on the target instance. That eliminates the drive-by-internet population.
- Single-tenant installs are unaffected in practice: if your Coolify only has one team, there is no other team to enumerate. The bulk of self-hosted Coolify deployments fall here.
- Role multiplier — orchestration platform: Coolify is a deployment broker holding SSH connection profiles, env vars, and Git creds. Cross-tenant read on
ServerandProjectobjects can hand an attacker enough metadata to pivot to managed hosts. This is the reason we hold at HIGH instead of dropping to MEDIUM. - No I or A impact in the CVE itself: the chain ends at *read* of another team's resources. Modification, deletion, deploy-time RCE, and direct credential theft would require a chained second bug or are scope-limited to what the lookup returns.
- No KEV, no PoC weaponization, no campaign telemetry: purely a hygiene patch on the timeline the SLA dictates.
Why not higher?
Not CRITICAL because PR:L (authentication required), no integrity or availability impact, and the population that is actually multi-tenant is a minority of Coolify deployments. CRITICAL would require unauthenticated reachability or direct RCE/credential-theft impact on the typical install.
Why not lower?
Not MEDIUM because Coolify is canonically a high-value-role component (deployment orchestration with secret material). On the subset of installs that ARE multi-tenant (agencies, internal platform teams), the chain credibly extends from metadata read to managed-server pivot. Generic CWE-639 on a CRUD app would be MEDIUM; on a PaaS it earns the role multiplier.
Crowdsourced verification payload.
Run this from an auditor workstation with shell access to the Coolify host (or anywhere docker can reach the running container). Invoke as ./check_coolify.sh <coolify_container_name_or_id> — example: ./check_coolify.sh coolify. No special privileges beyond being in the docker group.
#!/usr/bin/env bash
# noisgate verification: CVE-2026-34592 — Coolify cross-team IDOR
# Outputs: VULNERABLE / PATCHED / UNKNOWN
set -u
CONTAINER="${1:-coolify}"
FIXED="4.0.0-beta.471"
if ! command -v docker >/dev/null 2>&1; then
echo "UNKNOWN — docker CLI not available"
exit 2
fi
if ! docker inspect "$CONTAINER" >/dev/null 2>&1; then
echo "UNKNOWN — container '$CONTAINER' not found"
exit 2
fi
# Extract version from the running container. Coolify exposes /api/health and writes
# a versions.json; fall back to image tag.
VER="$(docker exec "$CONTAINER" sh -c 'cat /var/www/html/versions.json 2>/dev/null | grep -oE "\"coolify\"\s*:\s*\"[^\"]+\"" | head -1 | sed -E "s/.*:\s*\"([^\"]+)\".*/\\1/"' 2>/dev/null)"
if [ -z "$VER" ]; then
VER="$(docker inspect --format '{{ index .Config.Labels "org.opencontainers.image.version" }}' "$CONTAINER" 2>/dev/null)"
fi
if [ -z "$VER" ]; then
VER="$(docker inspect --format '{{.Config.Image}}' "$CONTAINER" | awk -F: '{print $2}')"
fi
if [ -z "$VER" ]; then
echo "UNKNOWN — could not determine Coolify version"
exit 2
fi
# Lexicographic compare works for Coolify's semver-ish 4.0.0-beta.NNN scheme
# Normalize: strip leading 'v'
VER="${VER#v}"
verlte() { [ "$(printf '%s\n%s' "$1" "$2" | sort -V | head -n1)" = "$1" ]; }
if verlte "$FIXED" "$VER"; then
echo "PATCHED — Coolify $VER (fixed in $FIXED)"
exit 0
else
echo "VULNERABLE — Coolify $VER < $FIXED — upgrade to $FIXED or later"
exit 1
fi