fix(team): build invite links from instance URL, not request host

When no instance domain is set, invitation links were built from the
request host, which a client can spoof through a forwarded Host header.
Links now always use base_url(). The fallback test now forces a spoofed
root URL and checks that the link ignores it.
This commit is contained in:
Andras Bacsai
2026-11-09 11:06:52 +01:00
parent a44193b6e4
commit b31c06a7eb
3 changed files with 17 additions and 10 deletions
@@ -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
+4 -6
View File
@@ -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)
@@ -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 () {