diff --git a/.changes/built-team-invitation-links-from-the.md b/.changes/built-team-invitation-links-from-the.md new file mode 100644 index 0000000000..b31495cf38 --- /dev/null +++ b/.changes/built-team-invitation-links-from-the.md @@ -0,0 +1,4 @@ +--- +type: security +--- +Built team invitation links from the instance URL instead of the request host, when no instance domain is set diff --git a/app/Livewire/Team/InviteLink.php b/app/Livewire/Team/InviteLink.php index 6d950245a9..c3cb9844e1 100644 --- a/app/Livewire/Team/InviteLink.php +++ b/app/Livewire/Team/InviteLink.php @@ -66,14 +66,12 @@ class InviteLink extends Component $this->generateInviteLink(sendEmail: false); } + /** + * Build the link from the instance URL, not from the request host, which a client can set. + */ 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); + return rtrim(base_url(), '/').route($routeName, $parameters, false); } private function generateInviteLink(bool $sendEmail = false) diff --git a/tests/Feature/Team/TeamInvitationPrivilegeEscalationTest.php b/tests/Feature/Team/TeamInvitationPrivilegeEscalationTest.php index 06013935ff..406ed528f9 100644 --- a/tests/Feature/Team/TeamInvitationPrivilegeEscalationTest.php +++ b/tests/Feature/Team/TeamInvitationPrivilegeEscalationTest.php @@ -6,6 +6,7 @@ use App\Models\Team; use App\Models\TeamInvitation; use App\Models\User; use Illuminate\Foundation\Testing\RefreshDatabase; +use Illuminate\Support\Facades\URL; use Livewire\Livewire; uses(RefreshDatabase::class); @@ -185,15 +186,19 @@ describe('privilege escalation prevention', function () { 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 () { + test('new user invitation magic link ignores a spoofed request host when instance fqdn is not configured', function () { InstanceSettings::unguarded(fn () => InstanceSettings::query()->updateOrCreate( ['id' => 0], - ['fqdn' => null] + ['fqdn' => null, 'public_ipv4' => null, 'public_ipv6' => null] )); + config(['app.url' => 'http://coolify.internal:8000']); $this->actingAs($this->owner); session(['currentTeam' => $this->team]); + // A forwarded Host header from the client sets the request root URL. + URL::forceRootUrl('http://evil.example'); + Livewire::test(InviteLink::class) ->set('email', 'fallback-invitee@example.com') ->set('role', 'member') @@ -202,8 +207,8 @@ describe('privilege escalation prevention', function () { $invitation = TeamInvitation::whereEmail('fallback-invitee@example.com')->firstOrFail(); - $expectedPrefix = route('auth.link', ['token' => '']); - expect($invitation->link)->toStartWith($expectedPrefix); + expect($invitation->link)->toStartWith('http://coolify.internal:8000/auth/link?token=') + ->not->toContain('evil.example'); }); test('member cannot bypass policy by calling viaEmail', function () {