diff --git a/app/Http/Controllers/Controller.php b/app/Http/Controllers/Controller.php index 3090538c3..a567c6244 100644 --- a/app/Http/Controllers/Controller.php +++ b/app/Http/Controllers/Controller.php @@ -98,7 +98,7 @@ class Controller extends BaseController public function link() { $token = request()->get('token'); - if ($token) { + if (is_string($token) && $token !== '') { try { $decrypted = Crypt::decryptString($token); } catch (DecryptException) { @@ -126,9 +126,8 @@ class Controller extends BaseController $invitation = TeamInvitation::query() ->where('email', $email) ->when($invitationUuid, fn ($query) => $query->where('uuid', $invitationUuid)) - ->where('link', request()->fullUrl()) ->first(); - if (! $invitation || ! $invitation->isValid()) { + if (! $invitation || ! $this->invitationLinkMatchesToken($invitation, $token) || ! $invitation->isValid()) { return redirect()->route('login')->with('error', 'Invitation has expired or been revoked.'); } @@ -152,6 +151,19 @@ class Controller extends BaseController return redirect()->route('login')->with('error', 'Invalid credentials.'); } + private function invitationLinkMatchesToken(TeamInvitation $invitation, string $token): bool + { + $query = parse_url($invitation->link, PHP_URL_QUERY); + if (! is_string($query)) { + return false; + } + + parse_str($query, $parameters); + $storedToken = $parameters['token'] ?? null; + + return is_string($storedToken) && hash_equals($storedToken, $token); + } + public function showInvitation() { $invitationUuid = request()->route('uuid'); diff --git a/app/Livewire/Team/InviteLink.php b/app/Livewire/Team/InviteLink.php index fb30961e9..be4d36e38 100644 --- a/app/Livewire/Team/InviteLink.php +++ b/app/Livewire/Team/InviteLink.php @@ -40,6 +40,16 @@ class InviteLink extends Component $this->generateInviteLink(sendEmail: false); } + private function invitationUrl(string $routeName, array $parameters): string + { + $fqdn = instanceSettings()->fqdn; + if (filled($fqdn)) { + return rtrim($fqdn, '/').route($routeName, $parameters, false); + } + + return route($routeName, $parameters); + } + private function generateInviteLink(bool $sendEmail = false) { try { @@ -62,7 +72,7 @@ class InviteLink extends Component return handleError(livewire: $this, customErrorMessage: "$this->email is already a member of ".currentTeam()->name.'.'); } $uuid = (string) new Cuid2(32); - $link = url('/').config('constants.invitation.link.base_url').$uuid; + $link = $this->invitationUrl('team.invitation.show', ['uuid' => $uuid]); $user = User::whereEmail($this->email)->first(); if (is_null($user)) { @@ -74,7 +84,7 @@ class InviteLink extends Component 'force_password_reset' => true, ]); $token = Crypt::encryptString("{$user->email}@@@{$uuid}@@@{$password}"); - $link = route('auth.link', ['token' => $token]); + $link = $this->invitationUrl('auth.link', ['token' => $token]); } $invitation = TeamInvitation::whereEmail($this->email)->first(); if (! is_null($invitation)) { diff --git a/config/constants.php b/config/constants.php index 4a956b31e..bb231967a 100644 --- a/config/constants.php +++ b/config/constants.php @@ -86,7 +86,6 @@ return [ 'invitation' => [ 'link' => [ - 'base_url' => '/invitations/', 'expiration_days' => 3, ], ], diff --git a/tests/Feature/InvitationLinkHandlingTest.php b/tests/Feature/InvitationLinkHandlingTest.php index e45207cc5..184dfd77f 100644 --- a/tests/Feature/InvitationLinkHandlingTest.php +++ b/tests/Feature/InvitationLinkHandlingTest.php @@ -77,6 +77,34 @@ it('accepts a valid magic link invitation only once and rotates the temporary pa $this->assertGuest(); }); +it('accepts a magic link when opened from a different public origin', function () { + [$team, $user, $password, $token] = createInvitationLinkFixture(); + + $this->get('https://coolify.example.com/auth/link?token='.urlencode($token)) + ->assertRedirect(route('dashboard')); + + $this->assertAuthenticatedAs($user); + $this->assertDatabaseMissing('team_invitations', ['email' => $user->email]); + expect($user->teams()->where('team_id', $team->id)->exists())->toBeTrue(); + + $user->refresh(); + expect(Hash::check($password, $user->password))->toBeFalse(); +}); + +it('rejects a magic link when the stored invitation token differs', function () { + [, $user, , $token, $invitation] = createInvitationLinkFixture(); + $differentToken = Crypt::encryptString("{$user->email}@@@{$invitation->uuid}@@@different-password"); + + $invitation->forceFill([ + 'link' => route('auth.link', ['token' => $differentToken]), + ])->save(); + + $this->get(route('auth.link', ['token' => $token])) + ->assertRedirect(route('login')); + + $this->assertGuest(); +}); + it('rejects a magic link when the invitation was revoked', function () { [, $user, , $token, $invitation] = createInvitationLinkFixture(); $invitation->delete(); diff --git a/tests/Feature/TeamInvitationPrivilegeEscalationTest.php b/tests/Feature/TeamInvitationPrivilegeEscalationTest.php index 9e011965a..af8a57cdd 100644 --- a/tests/Feature/TeamInvitationPrivilegeEscalationTest.php +++ b/tests/Feature/TeamInvitationPrivilegeEscalationTest.php @@ -1,7 +1,9 @@ InstanceSettings::query()->updateOrCreate(['id' => 0], ['fqdn' => null])); + // Create a team with owner, admin, and member $this->team = Team::factory()->create(); @@ -161,6 +165,46 @@ describe('privilege escalation prevention', function () { ]); }); + test('new user invitation magic link uses instance fqdn when configured', function () { + InstanceSettings::unguarded(fn () => InstanceSettings::query()->updateOrCreate( + ['id' => 0], + ['fqdn' => 'https://coolify.example.com'] + )); + + $this->actingAs($this->owner); + session(['currentTeam' => $this->team]); + + Livewire::test(InviteLink::class) + ->set('email', 'fqdn-invitee@example.com') + ->set('role', 'member') + ->call('viaLink') + ->assertDispatched('success'); + + $invitation = TeamInvitation::whereEmail('fqdn-invitee@example.com')->firstOrFail(); + + expect($invitation->link)->toStartWith('https://coolify.example.com/auth/link?token='); + }); + + test('new user invitation magic link falls back to route url when instance fqdn is not configured', function () { + InstanceSettings::unguarded(fn () => InstanceSettings::query()->updateOrCreate( + ['id' => 0], + ['fqdn' => null] + )); + + $this->actingAs($this->owner); + session(['currentTeam' => $this->team]); + + Livewire::test(InviteLink::class) + ->set('email', 'fallback-invitee@example.com') + ->set('role', 'member') + ->call('viaLink') + ->assertDispatched('success'); + + $invitation = TeamInvitation::whereEmail('fallback-invitee@example.com')->firstOrFail(); + + expect($invitation->link)->toStartWith('http://localhost/auth/link?token='); + }); + test('member cannot bypass policy by calling viaEmail', function () { // Login as member $this->actingAs($this->member);