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.php — acceptInvitation() (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:
- Log in with the victim's email and UUID password → redirected to force-password-reset page
- Set a new password via the force-password-reset page → full access
- 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
- Attacker (team admin/owner of Team A) navigates to Team → Members → Invite.
- Create invitation: Attacker enters
victim@example.com and clicks "Via Link". The server generates UUID and creates the invitation.
- Copy link: The invitation link
https://coolify.example/invitations/{uuid} is displayed. Attacker copies it.
- Craft malicious URL: Attacker appends
?reset-password=1 → https://coolify.example/invitations/{uuid}?reset-password=1.
- 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."
- Victim clicks (while logged into Coolify): The GET request hits
acceptInvitation().
- Password reset:
$resetPassword is truthy → $user->update(['password' => Hash::make($invitationUuid)]).
- Team join: Victim is added to Team A. Invitation is deleted.
- Attacker logs in: Using
victim@example.com / {uuid} as credentials.
- Password change: Attacker completes the force-password-reset flow, setting a new password.
- 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.
Summary
The
GET /invitations/{uuid}endpoint performs a state-changing password reset when thereset-passwordquery parameter is present, setting the user's password toHash::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:NAffected Component
app/Http/Controllers/Controller.php—acceptInvitation()(lines 111-145)routes/web.php— line 178 (GET route for invitation acceptance)CWE
Description
GET endpoint performs state-changing password reset
The
/invitations/{uuid}route is a GET endpoint that, when thereset-passwordquery parameter is present, resets the authenticated user's password to the invitation UUID:The route is defined as GET, which inherently bypasses CSRF protection:
The attacker knows the password value
The invitation UUID is a Cuid2(32) generated server-side when a team admin creates an invitation:
The link (containing the UUID) is displayed to the inviting admin in the UI and can be copied:
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:
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_resetdoes not prevent the attackAfter the password is reset,
force_password_resetis set totrue. TheCheckForcePasswordResetmiddleware redirects the user to a password change page:However, the attacker can:
The
reset-passwordparameter is never set legitimatelySearching the codebase,
reset-passwordas a query parameter appears ONLY inController.php:113. No invitation link generation code, no Blade template, and no JavaScript ever sets this parameter. The invitation link generated byInviteLink.phpis:Not:
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
victim@example.comand clicks "Via Link". The server generates UUID and creates the invitation.https://coolify.example/invitations/{uuid}is displayed. Attacker copies it.?reset-password=1→https://coolify.example/invitations/{uuid}?reset-password=1.acceptInvitation().$resetPasswordis truthy →$user->update(['password' => Hash::make($invitationUuid)]).victim@example.com/{uuid}as credentials.Proof of Concept
Impact
Recommended Remediation
Option 1: Remove the
reset-passwordparameter entirely (preferred)The
reset-passwordparameter on the invitation acceptance endpoint is not used in any legitimate flow. Remove it: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:
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
Credit
This vulnerability was discovered and reported by bugbunny.ai.