Skip to content

Account takeover via CSRF-able GET endpoint that resets password to attacker-known value

High
andrasbacsai published GHSA-389w-cc6x-wr2m Jul 2, 2026

Package

npm coolify (npm)

Affected versions

< v4.0.0-beta.471

Patched versions

v4.0.0-beta.471

Description

Summary

The GET /invitations/{uuid} endpoint performs a state-changing password reset when the reset-password query parameter is present, setting the user's password to Hash::make($invitationUuid) — a value known to the team admin who created the invitation. Since GET requests are not protected by CSRF tokens, any team admin can craft an invitation link with ?reset-password=1, trick the victim into clicking it, and then log in as the victim using the known UUID as the password.

Severity

High (CVSS 3.1: 7.3)

CVSS:3.1/AV:N/AC:L/PR:L/UI:R/S:U/C:H/I:H/A:N

  • Attack Vector: Network — exploitable via a crafted URL sent to the victim
  • Attack Complexity: Low — straightforward attack with no special conditions
  • Privileges Required: Low — attacker must be a team admin or owner (standard role, not root/superadmin) to create invitations
  • User Interaction: Required — victim must click the link while logged into Coolify
  • Scope: Unchanged — impact stays within the Coolify application
  • Confidentiality Impact: High — full account takeover grants access to all victim's servers, applications, secrets, and environment variables
  • Integrity Impact: High — full account takeover allows deploying to victim's servers, modifying applications, and changing configuration
  • Availability Impact: None

Affected Component

  • app/Http/Controllers/Controller.phpacceptInvitation() (lines 111-145)
  • routes/web.php — line 178 (GET route for invitation acceptance)

CWE

  • CWE-352: Cross-Site Request Forgery (CSRF) — state-changing action via GET request without anti-CSRF protection
  • CWE-640: Weak Password Recovery Mechanism for Forgotten Password — password set to a value known to the inviter

Description

GET endpoint performs state-changing password reset

The /invitations/{uuid} route is a GET endpoint that, when the reset-password query parameter is present, resets the authenticated user's password to the invitation UUID:

// app/Http/Controllers/Controller.php:111-130
public function acceptInvitation()
{
    $resetPassword = request()->query('reset-password');    // ← attacker-controlled
    $invitationUuid = request()->route('uuid');              // ← attacker-knows this value

    $invitation = TeamInvitation::whereUuid($invitationUuid)->firstOrFail();
    $user = User::whereEmail($invitation->email)->firstOrFail();

    if (Auth::id() !== $user->id) {
        abort(400, 'You are not allowed to accept this invitation.');
    }
    $invitationValid = $invitation->isValid();

    if ($invitationValid) {
        if ($resetPassword) {
            $user->update([
                'password' => Hash::make($invitationUuid),    // ← password = invitation UUID
                'force_password_reset' => true,
            ]);
        }
        // ... adds user to team and deletes invitation
    }
}

The route is defined as GET, which inherently bypasses CSRF protection:

// routes/web.php:177-180
Route::prefix('invitations')->group(function () {
    Route::get('/{uuid}', [Controller::class, 'acceptInvitation'])
        ->name('team.invitation.accept');
});

The attacker knows the password value

The invitation UUID is a Cuid2(32) generated server-side when a team admin creates an invitation:

// app/Livewire/Team/InviteLink.php:64-65
$uuid = new Cuid2(32);
$link = url('/').config('constants.invitation.link.base_url').$uuid;

The link (containing the UUID) is displayed to the inviting admin in the UI and can be copied:

{{-- resources/views/livewire/team/invitations.blade.php:34-36 --}}
<x-forms.input id="null" type="password" value="{{ $invite->link }}" />
<x-forms.button x-on:click="copyToClipboard('{{ $invite->link }}')">
    Copy Invitation Link
</x-forms.button>

The admin creates the invitation, copies the link, appends ?reset-password=1, and sends it to the victim. The password will be set to the UUID portion of the link — a value the attacker already has.

The identity check is not a defense

The check at line 119 ensures the logged-in user matches the invited user:

if (Auth::id() !== $user->id) {
    abort(400, 'You are not allowed to accept this invitation.');
}

This is NOT a defense against the attack. The attack requires the victim to click the link while logged in. The victim IS the invited user — that's the entire attack design. The identity check merely confirms the right person is being targeted.

force_password_reset does not prevent the attack

After the password is reset, force_password_reset is set to true. The CheckForcePasswordReset middleware redirects the user to a password change page:

// app/Http/Middleware/CheckForcePasswordReset.php:26-32
$force_password_reset = auth()->user()->force_password_reset;
if ($force_password_reset) {
    if ($request->routeIs('auth.force-password-reset') || ...) {
        return $next($request);
    }
    return redirect()->route('auth.force-password-reset');
}

However, the attacker can:

  1. Log in with the victim's email and UUID password → redirected to force-password-reset page
  2. Set a new password via the force-password-reset page → full access
  3. The victim is now locked out with no indication of compromise

The reset-password parameter is never set legitimately

Searching the codebase, reset-password as a query parameter appears ONLY in Controller.php:113. No invitation link generation code, no Blade template, and no JavaScript ever sets this parameter. The invitation link generated by InviteLink.php is:

https://coolify.example/invitations/{uuid}

Not:

https://coolify.example/invitations/{uuid}?reset-password=1

This suggests the feature was added as an internal convenience but creates a security vulnerability because it turns a legitimate invitation link into a password reset mechanism controllable by the inviter.

Execution chain

  1. Attacker (team admin/owner of Team A) navigates to Team → Members → Invite.
  2. Create invitation: Attacker enters victim@example.com and clicks "Via Link". The server generates UUID and creates the invitation.
  3. Copy link: The invitation link https://coolify.example/invitations/{uuid} is displayed. Attacker copies it.
  4. Craft malicious URL: Attacker appends ?reset-password=1https://coolify.example/invitations/{uuid}?reset-password=1.
  5. Social engineering: Attacker sends the link to the victim: "I'd like to invite you to collaborate on our Coolify project — click here to join."
  6. Victim clicks (while logged into Coolify): The GET request hits acceptInvitation().
  7. Password reset: $resetPassword is truthy → $user->update(['password' => Hash::make($invitationUuid)]).
  8. Team join: Victim is added to Team A. Invitation is deleted.
  9. Attacker logs in: Using victim@example.com / {uuid} as credentials.
  10. Password change: Attacker completes the force-password-reset flow, setting a new password.
  11. Account takeover complete: Attacker has full access; victim is locked out.

Proof of Concept

Prerequisites:
- Attacker has a Coolify account with admin/owner role on any team
- Victim has an existing Coolify account

Steps:
1. Attacker logs in and navigates to Team → Members → Invite.
2. Enter victim's email (e.g., victim@example.com), select "via link", click Invite.
3. Copy the generated invitation link from the Invitations list:
   https://coolify.example/invitations/clxyz123abc456def789...
4. Append ?reset-password=1:
   https://coolify.example/invitations/clxyz123abc456def789...?reset-password=1
5. Send this link to the victim (email, chat, etc.) with a message like
   "Join our Coolify project!"
6. When the victim clicks the link while logged in:
   - Their password is silently reset to the invitation UUID
   - They are added to the attacker's team
   - They see a redirect to the team page (appears normal)
7. Attacker logs in:
   Email: victim@example.com
   Password: clxyz123abc456def789...  (the invitation UUID)
8. Attacker is redirected to force-password-reset, changes password → full access.

Impact

  • Account takeover: Attacker gains full access to the victim's Coolify account, including all teams, servers, applications, and secrets
  • Silent compromise: The victim sees normal invitation acceptance behavior — no indication their password was changed
  • Infrastructure access: With the victim's account, the attacker can manage all servers, deploy applications, read environment variables and secrets
  • Privilege escalation: If the victim is a root admin (team_id=0), the attacker gains administrative control over the entire Coolify instance
  • Lateral movement: The attacker can use the victim's SSH keys and server access for further attacks

Recommended Remediation

Option 1: Remove the reset-password parameter entirely (preferred)

The reset-password parameter on the invitation acceptance endpoint is not used in any legitimate flow. Remove it:

// app/Http/Controllers/Controller.php
public function acceptInvitation()
{
    $invitationUuid = request()->route('uuid');

    $invitation = TeamInvitation::whereUuid($invitationUuid)->firstOrFail();
    $user = User::whereEmail($invitation->email)->firstOrFail();

    if (Auth::id() !== $user->id) {
        abort(400, 'You are not allowed to accept this invitation.');
    }
    $invitationValid = $invitation->isValid();

    if ($invitationValid) {
        // REMOVED: password reset via query parameter
        if ($user->teams()->where('team_id', $invitation->team->id)->exists()) {
            $invitation->delete();
            return redirect()->route('team.index');
        }
        $user->teams()->attach($invitation->team->id, ['role' => $invitation->role]);
        $invitation->delete();

        refreshSession($invitation->team);
        return redirect()->route('team.index');
    } else {
        abort(400, 'Invitation expired.');
    }
}

Option 2: Convert to POST with CSRF protection

If the password reset functionality is needed, make it a POST endpoint with CSRF protection so it cannot be triggered via a link click:

// routes/web.php
Route::get('/{uuid}', [Controller::class, 'showInvitation'])->name('team.invitation.show');
Route::post('/{uuid}/accept', [Controller::class, 'acceptInvitation'])->name('team.invitation.accept');

The GET endpoint would show a confirmation page with a form (including CSRF token), and the actual state change would happen on POST.

Additional hardening

  • Never set passwords to values derived from IDs, URLs, or other data known to third parties
  • If password reset during invitation is needed, generate a separate random password and send it securely to the user (not derivable from the invitation UUID)
  • Audit all GET endpoints for state-changing operations — GET should always be safe and idempotent per HTTP specifications

Credit

This vulnerability was discovered and reported by bugbunny.ai.

Severity

High

CVSS overall score

This score calculates overall vulnerability severity from 0 to 10 and is based on the Common Vulnerability Scoring System (CVSS).
/ 10

CVSS v3 base metrics

Attack vector
Network
Attack complexity
High
Privileges required
None
User interaction
Required
Scope
Changed
Confidentiality
High
Integrity
High
Availability
None

CVSS v3 base metrics

Attack vector: More severe the more the remote (logically and physically) an attacker can be in order to exploit the vulnerability.
Attack complexity: More severe for the least complex attacks.
Privileges required: More severe if no privileges are required.
User interaction: More severe when no user interaction is required.
Scope: More severe when a scope change occurs, e.g. one vulnerable component impacts resources in components beyond its security scope.
Confidentiality: More severe when loss of data confidentiality is highest, measuring the level of data access available to an unauthorized user.
Integrity: More severe when loss of data integrity is the highest, measuring the consequence of data modification possible by an unauthorized user.
Availability: More severe when the loss of impacted component availability is highest.
CVSS:3.1/AV:N/AC:H/PR:N/UI:R/S:C/C:H/I:H/A:N

CVE ID

CVE-2026-34171

Weaknesses

No CWEs

Credits