Merge remote-tracking branch 'origin/next' into improve-s3-storage-handling

This commit is contained in:
Andras Bacsai committed 2026-07-02 15:06:11 +02:00
commit 20b5b90cf9
49 files changed
+1979 -257

No files matched your search

@@ -18,7 +18,9 @@ use Livewire\Livewire;
uses(RefreshDatabase::class);
beforeEach(function () {
InstanceSettings::updateOrCreate(['id' => 0]);
$this->withoutVite();
InstanceSettings::unguarded(fn () => InstanceSettings::updateOrCreate(['id' => 0], ['id' => 0]));
$this->team = Team::factory()->create();
@@ -0,0 +1,89 @@
<?php
use App\Http\Middleware\CanUpdateResource;
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\Http\Request;
use Symfony\Component\HttpKernel\Exception\HttpException;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
uses(RefreshDatabase::class);
function requestWithCanUpdateResourceRouteParameter(string $parameter, ?string $value): Request
{
$parameters = [
'application_uuid' => null,
'database_uuid' => null,
'stack_service_uuid' => null,
'service_uuid' => null,
'server_uuid' => null,
'environment_uuid' => null,
'project_uuid' => null,
$parameter => $value,
];
$request = Mockery::mock(Request::class)->makePartial();
$request->shouldReceive('route')->andReturnUsing(fn (string $key): ?string => $parameters[$key] ?? null);
return $request;
}
beforeEach(function () {
InstanceSettings::unguarded(fn () => InstanceSettings::updateOrCreate(['id' => 0], ['id' => 0]));
$this->team = Team::factory()->create();
$this->project = Project::factory()->create(['team_id' => $this->team->id]);
$this->server = Server::factory()->create(['team_id' => $this->team->id]);
$this->admin = User::factory()->create();
$this->admin->teams()->attach($this->team, ['role' => 'admin']);
$this->member = User::factory()->create();
$this->member->teams()->attach($this->team, ['role' => 'member']);
});
it('blocks members from update-only project routes before the page renders', function () {
$this->actingAs($this->member);
session(['currentTeam' => $this->team]);
(new CanUpdateResource)->handle(
requestWithCanUpdateResourceRouteParameter('project_uuid', $this->project->uuid),
fn () => response('ok')
);
})->throws(HttpException::class, 'You do not have permission to update this resource.');
it('allows admins through update-only project routes', function () {
$this->actingAs($this->admin);
session(['currentTeam' => $this->team]);
$response = (new CanUpdateResource)->handle(
requestWithCanUpdateResourceRouteParameter('project_uuid', $this->project->uuid),
fn () => response('ok')
);
expect($response->getContent())->toBe('ok');
});
it('blocks members from update-only server routes before the page renders', function () {
$this->actingAs($this->member);
session(['currentTeam' => $this->team]);
(new CanUpdateResource)->handle(
requestWithCanUpdateResourceRouteParameter('server_uuid', $this->server->uuid),
fn () => response('ok')
);
})->throws(HttpException::class, 'You do not have permission to update this resource.');
it('returns not found when an update-only route references an unknown resource', function () {
$this->actingAs($this->admin);
session(['currentTeam' => $this->team]);
(new CanUpdateResource)->handle(
requestWithCanUpdateResourceRouteParameter('project_uuid', 'not-a-project'),
fn () => response('ok')
);
})->throws(NotFoundHttpException::class, 'Resource not found.');
@@ -161,6 +161,33 @@ test('member cannot update email notification settings', function () {
->assertForbidden();
});
test('member cannot update smtp email transport directly', function () {
$this->actingAs($this->member);
session(['currentTeam' => $this->team]);
Livewire::test(EmailNotification::class)
->set('smtpFromAddress', 'member@example.com')
->set('smtpFromName', 'Member')
->set('smtpHost', 'smtp.example.com')
->set('smtpPort', '587')
->set('smtpEncryption', 'starttls')
->set('smtpPassword', 'member-smtp-password')
->call('submitSmtp')
->assertForbidden();
});
test('member cannot update resend email transport directly', function () {
$this->actingAs($this->member);
session(['currentTeam' => $this->team]);
Livewire::test(EmailNotification::class)
->set('smtpFromAddress', 'member@example.com')
->set('smtpFromName', 'Member')
->set('resendApiKey', 'member-resend-api-key')
->call('submitResend')
->assertForbidden();
});
test('member cannot copy instance email settings', function () {
$this->actingAs($this->member);
session(['currentTeam' => $this->team]);
@@ -312,6 +339,10 @@ test('member cannot view notification secrets', function (string $component, str
'generic webhook' => [WebhookNotification::class, 'webhookNotificationSettings', [
'webhook_url' => 'https://example.com/secret-webhook',
]],
'email credentials' => [EmailNotification::class, 'emailNotificationSettings', [
'smtp_password' => 'smtp-secret-password',
'resend_api_key' => 'resend-secret-api-key',
]],
]);
test('admin can view notification secrets', function (string $component, string $settingsRelation, array $secrets) {
@@ -346,4 +377,8 @@ test('admin can view notification secrets', function (string $component, string
'generic webhook' => [WebhookNotification::class, 'webhookNotificationSettings', [
'webhook_url' => 'https://example.com/admin-webhook',
]],
'email credentials' => [EmailNotification::class, 'emailNotificationSettings', [
'smtp_password' => 'smtp-admin-password',
'resend_api_key' => 'resend-admin-api-key',
]],
]);
+124 -2
View File
@@ -4,6 +4,7 @@ use App\Livewire\Project\Database\BackupEdit;
use App\Models\Environment;
use App\Models\InstanceSettings;
use App\Models\Project;
use App\Models\S3Storage;
use App\Models\ScheduledDatabaseBackup;
use App\Models\Server;
use App\Models\StandaloneDocker;
@@ -43,6 +44,20 @@ function createBackupForEditValidationTest(Team $team, array $overrides = []): S
], $overrides));
}
function createS3StorageForBackupEditValidationTest(Team|int $team, string $name = 'Backup Edit S3'): S3Storage
{
return S3Storage::create([
'name' => $name,
'region' => 'us-east-1',
'key' => 'test-key',
'secret' => 'test-secret',
'bucket' => 'test-bucket',
'endpoint' => 'https://s3.example.com',
'is_usable' => true,
'team_id' => $team instanceof Team ? $team->id : $team,
]);
}
beforeEach(function () {
if (InstanceSettings::find(0) === null) {
$settings = new InstanceSettings;
@@ -60,7 +75,7 @@ beforeEach(function () {
it('disables S3 backup when saved without a selected S3 storage', function () {
$backup = createBackupForEditValidationTest($this->team);
Livewire::test(BackupEdit::class, ['backup' => $backup->fresh(), 's3s' => $this->team->s3s])
Livewire::test(BackupEdit::class, ['backup' => $backup->fresh(), 'availableS3Storages' => $this->team->s3s])
->call('submit')
->assertDispatched('success');
@@ -74,7 +89,7 @@ it('cascades to disabling local backup deletion when S3 is force-disabled', func
'disable_local_backup' => true,
]);
Livewire::test(BackupEdit::class, ['backup' => $backup->fresh(), 's3s' => $this->team->s3s])
Livewire::test(BackupEdit::class, ['backup' => $backup->fresh(), 'availableS3Storages' => $this->team->s3s])
->call('submit')
->assertDispatched('success');
@@ -83,3 +98,110 @@ it('cascades to disabling local backup deletion when S3 is force-disabled', func
expect($backup->s3_storage_id)->toBeNull();
expect($backup->disable_local_backup)->toBeFalsy();
});
it('keeps S3 enabled by selecting the only available team storage when none is selected yet', function () {
createS3StorageForBackupEditValidationTest(Team::factory()->create());
$s3 = createS3StorageForBackupEditValidationTest($this->team);
$backup = createBackupForEditValidationTest($this->team, [
'save_s3' => false,
's3_storage_id' => null,
]);
Livewire::test(BackupEdit::class, ['backup' => $backup->fresh(), 'availableS3Storages' => $this->team->s3s])
->set('saveS3', true)
->call('instantSave')
->assertDispatched('success');
$backup->refresh();
expect($backup->save_s3)->toBeTruthy();
expect($backup->s3_storage_id)->toBe($s3->id);
});
it('defaults to the first available storage when multiple storages are available', function () {
$firstS3 = createS3StorageForBackupEditValidationTest($this->team, 'First S3');
createS3StorageForBackupEditValidationTest($this->team, 'Second S3');
$backup = createBackupForEditValidationTest($this->team, [
'save_s3' => false,
's3_storage_id' => null,
]);
Livewire::test(BackupEdit::class, ['backup' => $backup->fresh(), 'availableS3Storages' => $this->team->s3s])
->assertSet('s3StorageId', $firstS3->id)
->set('saveS3', true)
->call('instantSave')
->assertDispatched('success');
$backup->refresh();
expect($backup->save_s3)->toBeTruthy();
expect($backup->s3_storage_id)->toBe($firstS3->id);
});
it('accepts the S3 storage scope passed to the component', function () {
$s3 = createS3StorageForBackupEditValidationTest(0);
$backup = createBackupForEditValidationTest($this->team, [
'save_s3' => false,
's3_storage_id' => null,
]);
Livewire::test(BackupEdit::class, ['backup' => $backup->fresh(), 'availableS3Storages' => collect([$s3])])
->set('saveS3', true)
->set('s3StorageId', $s3->id)
->call('instantSave')
->assertDispatched('success');
$backup->refresh();
expect($backup->save_s3)->toBeTruthy();
expect($backup->s3_storage_id)->toBe($s3->id);
});
it('shows available S3 storages even when S3 backup is disabled', function () {
createS3StorageForBackupEditValidationTest($this->team, 'First S3');
createS3StorageForBackupEditValidationTest($this->team, 'Second S3');
$backup = createBackupForEditValidationTest($this->team, [
'save_s3' => false,
's3_storage_id' => null,
]);
Livewire::test(BackupEdit::class, ['backup' => $backup->fresh(), 'availableS3Storages' => $this->team->s3s])
->assertSee('First S3')
->assertSee('Second S3');
});
it('shows disabled S3 storage dropdown when no storages are available', function () {
$backup = createBackupForEditValidationTest($this->team, [
'save_s3' => false,
's3_storage_id' => null,
]);
Livewire::test(BackupEdit::class, ['backup' => $backup->fresh(), 'availableS3Storages' => $this->team->s3s])
->assertSee('No S3 storage available');
});
it('shows when S3 backups are currently disabled', function () {
createS3StorageForBackupEditValidationTest($this->team);
$backup = createBackupForEditValidationTest($this->team, [
'save_s3' => false,
's3_storage_id' => null,
]);
Livewire::test(BackupEdit::class, ['backup' => $backup->fresh(), 'availableS3Storages' => $this->team->s3s])
->assertSee('S3 Storage')
->assertSee('(currently disabled)');
});
it('saves selected S3 storage immediately when it changes', function () {
createS3StorageForBackupEditValidationTest($this->team, 'First S3');
$secondS3 = createS3StorageForBackupEditValidationTest($this->team, 'Second S3');
$backup = createBackupForEditValidationTest($this->team, [
'save_s3' => false,
's3_storage_id' => null,
]);
Livewire::test(BackupEdit::class, ['backup' => $backup->fresh(), 'availableS3Storages' => $this->team->s3s])
->set('s3StorageId', $secondS3->id)
->assertDispatched('success');
$backup->refresh();
expect($backup->save_s3)->toBeFalsy();
expect($backup->s3_storage_id)->toBe($secondS3->id);
});
+123
View File
@@ -0,0 +1,123 @@
<?php
use App\Jobs\ServerStorageSaveJob;
use App\Livewire\Project\Service\Storage;
use App\Models\Application;
use App\Models\Environment;
use App\Models\InstanceSettings;
use App\Models\LocalFileVolume;
use App\Models\Project;
use App\Models\Server;
use App\Models\StandaloneDocker;
use App\Models\Team;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Bus;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
use Livewire\Livewire;
uses(RefreshDatabase::class);
beforeEach(function () {
config(['app.maintenance.store' => 'array', 'cache.default' => 'array']);
Bus::fake();
InstanceSettings::unguarded(fn () => InstanceSettings::updateOrCreate(['id' => 0], ['id' => 0]));
$this->team = Team::factory()->create();
$this->admin = User::factory()->create();
$this->admin->teams()->attach($this->team, ['role' => 'admin']);
$keyId = DB::table('private_keys')->insertGetId([
'uuid' => (string) Str::uuid(),
'name' => 'Test Key',
'private_key' => 'test-key',
'team_id' => $this->team->id,
'created_at' => now(),
'updated_at' => now(),
]);
$this->server = Server::factory()->create([
'team_id' => $this->team->id,
'private_key_id' => $keyId,
]);
StandaloneDocker::withoutEvents(function () {
$this->destination = StandaloneDocker::firstOrCreate(
['server_id' => $this->server->id, 'network' => 'coolify'],
['uuid' => (string) Str::uuid(), 'name' => 'test-docker']
);
});
$this->project = Project::create([
'uuid' => (string) Str::uuid(),
'name' => 'Test Project',
'team_id' => $this->team->id,
]);
$this->environment = $this->project->environments()->first()
?? Environment::factory()->create(['project_id' => $this->project->id]);
$this->application = Application::factory()->create([
'uuid' => (string) Str::uuid(),
'name' => 'Test App',
'environment_id' => $this->environment->id,
'destination_id' => $this->destination->id,
'destination_type' => $this->destination->getMorphClass(),
]);
$this->actingAs($this->admin);
session(['currentTeam' => $this->team]);
});
test('livewire file storage rejects parent segments and does not create a local file volume', function () {
Livewire::test(Storage::class, ['resource' => $this->application])
->set('file_storage_path', '/../../../../../../etc/example.conf')
->set('file_storage_content', 'owned')
->call('submitFileStorage')
->assertDispatched('error');
expect(LocalFileVolume::query()->count())->toBe(0);
});
test('file mount modal shows the calculated host file path above the destination input', function () {
Livewire::test(Storage::class, ['resource' => $this->application])
->assertSeeText('This file will be created on the host, then mounted into the container.')
->assertSeeText('Host file path')
->assertSeeText($this->application->workdir().'/')
->set('file_storage_path', '/etc/nginx/nginx.conf')
->assertSeeText($this->application->workdir().'/etc/nginx/nginx.conf')
->assertDontSeeText('Actual file mounted from the host system to the container.');
});
test('livewire file storage stores safe file mounts under the application configuration root', function () {
Livewire::test(Storage::class, ['resource' => $this->application])
->set('file_storage_path', '/etc/nginx/nginx.conf')
->set('file_storage_content', 'server {}')
->call('submitFileStorage')
->assertDispatched('success');
$volume = LocalFileVolume::query()->sole();
expect($volume->mount_path)->toBe('/etc/nginx/nginx.conf')
->and($volume->fs_path)->toBe(application_configuration_dir().'/'.$this->application->uuid.'/etc/nginx/nginx.conf')
->and($volume->is_directory)->toBeFalse();
});
test('livewire host file storage stores an existing host file path without managed content', function () {
Livewire::test(Storage::class, ['resource' => $this->application])
->set('host_file_storage_source', '/etc/nginx/nginx.conf')
->set('host_file_storage_destination', '/etc/nginx/nginx.conf')
->call('submitHostFileStorage')
->assertDispatched('success');
$volume = LocalFileVolume::query()->sole();
expect($volume->fs_path)->toBe('/etc/nginx/nginx.conf')
->and($volume->mount_path)->toBe('/etc/nginx/nginx.conf')
->and($volume->content)->toBeNull()
->and($volume->is_host_file)->toBeTrue()
->and($volume->is_directory)->toBeFalse();
Bus::assertNotDispatched(ServerStorageSaveJob::class);
});
@@ -28,6 +28,7 @@ it('keeps changelog and appearance options out of the preferences dropdown', fun
->toContain('wire:click="openWhatsNewModal"')
->toContain('class="relative text-left menu-item"')
->toContain('class="text-left menu-item-label"')
->toContain('class="absolute right-2 top-1/2 -translate-y-1/2 bg-error')
->toContain("What's New</span>")
->toContain('M9.813 15.904 9 18.75')
->not->toContain('<span>Changelog</span>')
+159 -1
View File
@@ -1,5 +1,6 @@
<?php
use App\Jobs\ServerStorageSaveJob;
use App\Models\Application;
use App\Models\Environment;
use App\Models\InstanceSettings;
@@ -7,6 +8,8 @@ use App\Models\LocalFileVolume;
use App\Models\LocalPersistentVolume;
use App\Models\Project;
use App\Models\Server;
use App\Models\Service;
use App\Models\ServiceApplication;
use App\Models\StandaloneDocker;
use App\Models\StandalonePostgresql;
use App\Models\Team;
@@ -18,8 +21,9 @@ use Illuminate\Support\Str;
uses(RefreshDatabase::class);
beforeEach(function () {
config(['app.maintenance.store' => 'array', 'cache.default' => 'array']);
Bus::fake();
InstanceSettings::updateOrCreate(['id' => 0]);
InstanceSettings::unguarded(fn () => InstanceSettings::updateOrCreate(['id' => 0], ['id' => 0]));
$this->team = Team::factory()->create();
$this->user = User::factory()->create();
@@ -61,6 +65,24 @@ function createTestDatabase($context): StandalonePostgresql
]);
}
function createTestServiceApplication($context): array
{
$service = Service::factory()->create([
'environment_id' => $context->environment->id,
'destination_id' => $context->destination->id,
'destination_type' => $context->destination->getMorphClass(),
]);
$serviceApplication = ServiceApplication::create([
'uuid' => (string) Str::uuid(),
'name' => 'test-service-app',
'service_id' => $service->id,
'image' => 'nginx:alpine',
]);
return [$service, $serviceApplication];
}
// ──────────────────────────────────────────────────────────────
// Application Storage Endpoints
// ──────────────────────────────────────────────────────────────
@@ -140,6 +162,56 @@ describe('POST /api/v1/applications/{uuid}/storages', function () {
expect($vol)->not->toBeNull();
expect($vol->mount_path)->toBe('/app/config.json');
expect($vol->is_directory)->toBeFalse();
expect($vol->fs_path)->toBe(application_configuration_dir().'/'.$app->uuid.'/app/config.json');
});
test('creates bind only host file storage for application', function () {
$app = createTestApplication($this);
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$this->bearerToken,
'Content-Type' => 'application/json',
])->postJson("/api/v1/applications/{$app->uuid}/storages", [
'type' => 'file',
'is_host_file' => true,
'fs_path' => '/etc/nginx/nginx.conf',
'mount_path' => '/etc/nginx/nginx.conf',
]);
$response->assertStatus(201);
$vol = LocalFileVolume::where('resource_id', $app->id)
->where('resource_type', get_class($app))
->first();
expect($vol)->not->toBeNull();
expect($vol->fs_path)->toBe('/etc/nginx/nginx.conf');
expect($vol->mount_path)->toBe('/etc/nginx/nginx.conf');
expect($vol->content)->toBeNull();
expect($vol->is_host_file)->toBeTrue();
expect($vol->is_directory)->toBeFalse();
Bus::assertNotDispatched(ServerStorageSaveJob::class);
});
test('rejects file storage paths with parent segments', function () {
$app = createTestApplication($this);
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$this->bearerToken,
'Content-Type' => 'application/json',
])->postJson("/api/v1/applications/{$app->uuid}/storages", [
'type' => 'file',
'mount_path' => '/../../../../../../etc/example.conf',
'content' => 'owned',
]);
$response->assertStatus(422);
$response->assertJsonPath('message', 'Validation failed.');
expect(LocalFileVolume::where('resource_id', $app->id)
->where('resource_type', get_class($app))
->exists())->toBeFalse();
});
test('rejects persistent storage without name', function () {
@@ -331,6 +403,92 @@ describe('POST /api/v1/databases/{uuid}/storages', function () {
expect($vol)->not->toBeNull();
expect($vol->mount_path)->toBe('/extra');
});
test('creates a file storage for a database under the database configuration root', function () {
$db = createTestDatabase($this);
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$this->bearerToken,
'Content-Type' => 'application/json',
])->postJson("/api/v1/databases/{$db->uuid}/storages", [
'type' => 'file',
'mount_path' => '/postgres/postgresql.conf',
'content' => 'listen_addresses = "*"',
]);
$response->assertStatus(201);
$vol = LocalFileVolume::where('resource_id', $db->id)
->where('resource_type', get_class($db))
->first();
expect($vol)->not->toBeNull();
expect($vol->fs_path)->toBe(database_configuration_dir().'/'.$db->uuid.'/postgres/postgresql.conf');
});
test('rejects file storage paths with parent segments for a database', function () {
$db = createTestDatabase($this);
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$this->bearerToken,
'Content-Type' => 'application/json',
])->postJson("/api/v1/databases/{$db->uuid}/storages", [
'type' => 'file',
'mount_path' => '/postgres/../../../etc/shadow',
'content' => 'owned',
]);
$response->assertStatus(422);
expect(LocalFileVolume::where('resource_id', $db->id)
->where('resource_type', get_class($db))
->exists())->toBeFalse();
});
});
describe('POST /api/v1/services/{uuid}/storages', function () {
test('creates a file storage for a service resource under the service configuration root', function () {
[$service, $serviceApplication] = createTestServiceApplication($this);
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$this->bearerToken,
'Content-Type' => 'application/json',
])->postJson("/api/v1/services/{$service->uuid}/storages", [
'type' => 'file',
'resource_uuid' => $serviceApplication->uuid,
'mount_path' => '/etc/nginx/nginx.conf',
'content' => 'server {}',
]);
$response->assertStatus(201);
$vol = LocalFileVolume::where('resource_id', $serviceApplication->id)
->where('resource_type', get_class($serviceApplication))
->first();
expect($vol)->not->toBeNull();
expect($vol->fs_path)->toBe(service_configuration_dir().'/'.$service->uuid.'/etc/nginx/nginx.conf');
});
test('rejects file storage paths with parent segments for a service resource', function () {
[$service, $serviceApplication] = createTestServiceApplication($this);
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$this->bearerToken,
'Content-Type' => 'application/json',
])->postJson("/api/v1/services/{$service->uuid}/storages", [
'type' => 'file',
'resource_uuid' => $serviceApplication->uuid,
'mount_path' => '/../../../../../../root/.ssh/authorized_keys',
'content' => 'owned',
]);
$response->assertStatus(422);
expect(LocalFileVolume::where('resource_id', $serviceApplication->id)
->where('resource_type', get_class($serviceApplication))
->exists())->toBeFalse();
});
});
describe('PATCH /api/v1/databases/{uuid}/storages', function () {
+74
View File
@@ -141,3 +141,77 @@ test('file storage accepts relative dot-prefixed paths', function () {
expect(fn () => validateShellSafePath('./data', 'storage path'))
->not->toThrow(Exception::class);
});
test('file mount path validator rejects parent segments and unsafe separators', function (string $path) {
expect(fn () => validateFileMountPath($path, 'file storage path'))
->toThrow(Exception::class);
})->with([
'parent segment to etc' => ['/../../etc/passwd'],
'embedded parent segment' => ['/foo/../bar'],
'parent segment' => ['/..'],
'double slash before parent segment' => ['/foo//../bar'],
'current directory segment' => ['/foo/./bar'],
'backslash parent segment' => ['\\..\\etc\\passwd'],
'null byte' => ["/app/config\0/../../etc/passwd"],
]);
test('file mount path validator accepts safe absolute container file paths', function (string $path, string $expected) {
expect(validateFileMountPath($path, 'file storage path'))->toBe($expected);
})->with([
'nginx config' => ['/etc/nginx/nginx.conf', '/etc/nginx/nginx.conf'],
'app env filename' => ['/app/.env', '/app/.env'],
'relative input becomes absolute' => ['config/app.yaml', '/config/app.yaml'],
'duplicate slashes collapse' => ['/opt//app///config.json', '/opt/app/config.json'],
]);
test('host file mount path validator accepts absolute host file paths', function () {
expect(validateHostFileMountPath('/etc/nginx/nginx.conf', 'host file path'))
->toBe('/etc/nginx/nginx.conf');
});
test('host file mount path validator rejects ambiguous or directory paths', function (string $path) {
expect(fn () => validateHostFileMountPath($path, 'host file path'))
->toThrow(Exception::class);
})->with([
'relative path' => ['etc/nginx/nginx.conf'],
'root directory' => ['/'],
'trailing slash' => ['/etc/nginx/'],
'parent segment' => ['/etc/../shadow'],
'current segment' => ['/etc/./nginx.conf'],
'backslash' => ['\\etc\\nginx.conf'],
]);
test('confined path resolver keeps file mounts inside their resource configuration root', function () {
expect(confineFileMountPath('/data/coolify/applications/app-uuid', '/etc/nginx/nginx.conf', 'file storage path'))
->toBe('/data/coolify/applications/app-uuid/etc/nginx/nginx.conf');
expect(confineFileMountPath('/data/coolify/databases/db-uuid/', 'postgres/postgresql.conf', 'file storage path'))
->toBe('/data/coolify/databases/db-uuid/postgres/postgresql.conf');
expect(confineFileMountPath('/data/coolify/services/service-uuid', '/config.yaml', 'file storage path'))
->toBe('/data/coolify/services/service-uuid/config.yaml');
});
test('confined path resolver rejects paths that escape the resource configuration root', function (string $base, string $path) {
expect(fn () => confineFileMountPath($base, $path, 'file storage path'))
->toThrow(Exception::class);
})->with([
'application parent segment' => ['/data/coolify/applications/app-uuid', '/../../etc/passwd'],
'database parent segment' => ['/data/coolify/databases/db-uuid', '/postgres/../../../etc/shadow'],
'service dot segment' => ['/data/coolify/services/service-uuid', '/./config.yaml'],
]);
test('local file volume write sink keeps saved managed file paths for compatibility', function () {
$source = file_get_contents(__DIR__.'/../../app/Models/LocalFileVolume.php');
expect($source)->not->toContain('confinePathToBase($workdir, $path->value(), \'storage path\')')
->and($source)->toContain('tee {$escapedPath}');
});
test('host file mounts are bind-only and skipped by server storage writes', function () {
$source = file_get_contents(__DIR__.'/../../app/Models/LocalFileVolume.php');
expect($source)->toContain('if ($this->is_host_file) {')
->and($source)->toContain('return;')
->and($source)->toContain('tee {$escapedPath}');
});
@@ -0,0 +1,36 @@
<?php
use App\Jobs\SendMessageToDiscordJob;
use App\Jobs\SendMessageToSlackJob;
use App\Notifications\Dto\DiscordMessage;
use App\Notifications\Dto\SlackMessage;
use Illuminate\Support\Facades\Http;
use Tests\TestCase;
uses(TestCase::class);
it('blocks queued Slack notifications to IPv4-mapped link-local URLs', function () {
Http::fake();
$job = new SendMessageToSlackJob(
new SlackMessage('Test', 'Description'),
'http://[::ffff:169.254.169.254]/'
);
$job->handle();
Http::assertNothingSent();
});
it('blocks queued Discord notifications to IPv4-mapped link-local URLs', function () {
Http::fake();
$job = new SendMessageToDiscordJob(
new DiscordMessage('Test', 'Description', DiscordMessage::infoColor()),
'http://[::ffff:169.254.169.254]/'
);
$job->handle();
Http::assertNothingSent();
});
+92
View File
@@ -0,0 +1,92 @@
<?php
use App\Models\Team;
use App\Models\User;
use App\Policies\TeamPolicy;
function teamPolicyUserWithTeams(array $teamIds): User
{
$user = Mockery::mock(User::class)->makePartial();
$user->shouldReceive('getAttribute')->with('teams')->andReturn(collect(
array_map(fn (int $teamId): object => (object) ['id' => $teamId], $teamIds)
));
return $user;
}
function teamPolicyTeam(int $teamId): Team
{
$team = Mockery::mock(Team::class)->makePartial();
$team->shouldReceive('getAttribute')->with('id')->andReturn($teamId);
return $team;
}
it('allows any authenticated user to view any teams list', function () {
$user = Mockery::mock(User::class)->makePartial();
expect((new TeamPolicy)->viewAny($user))->toBeTrue();
});
it('allows authenticated users to create teams', function () {
$user = Mockery::mock(User::class)->makePartial();
expect((new TeamPolicy)->create($user))->toBeTrue();
});
it('allows target team members to view the team', function () {
$user = teamPolicyUserWithTeams([1]);
$team = teamPolicyTeam(1);
expect((new TeamPolicy)->view($user, $team))->toBeTrue();
});
it('denies non-members from viewing the team', function () {
$user = teamPolicyUserWithTeams([2]);
$team = teamPolicyTeam(1);
expect((new TeamPolicy)->view($user, $team))->toBeFalse();
});
it('allows target team admins to perform privileged team actions', function (string $ability) {
$user = teamPolicyUserWithTeams([1]);
$user->shouldReceive('isAdminOfTeam')->with(1)->andReturn(true);
$team = teamPolicyTeam(1);
expect((new TeamPolicy)->{$ability}($user, $team))->toBeTrue();
})->with([
'update',
'delete',
'manageMembers',
'viewAdmin',
'manageInvitations',
]);
it('denies target team members even when their current session role is admin elsewhere', function (string $ability) {
$user = teamPolicyUserWithTeams([1, 2]);
$user->shouldReceive('isAdmin')->andReturn(true);
$user->shouldReceive('isOwner')->andReturn(false);
$user->shouldReceive('isAdminOfTeam')->with(1)->andReturn(false);
$team = teamPolicyTeam(1);
expect((new TeamPolicy)->{$ability}($user, $team))->toBeFalse();
})->with([
'update',
'delete',
'manageMembers',
'viewAdmin',
'manageInvitations',
]);
it('denies non-members from privileged team actions', function (string $ability) {
$user = teamPolicyUserWithTeams([2]);
$team = teamPolicyTeam(1);
expect((new TeamPolicy)->{$ability}($user, $team))->toBeFalse();
})->with([
'update',
'delete',
'manageMembers',
'viewAdmin',
'manageInvitations',
]);
@@ -23,8 +23,9 @@ it('rejects SSRF payloads on the S3 endpoint', function (string $endpoint) {
expect($validator->fails())->toBeTrue("Expected rejection: {$endpoint}");
})->with([
'AWS IMDS' => 'http://169.254.169.254/latest/meta-data/',
'AWS IMDS bare' => 'http://169.254.169.254',
'link-local address' => 'http://169.254.169.254/',
'link-local address bare' => 'http://169.254.169.254',
'link-local address IPv4-mapped IPv6' => 'http://[::ffff:169.254.169.254]/',
'GCP metadata via link-local' => 'http://169.254.0.1',
'loopback v4' => 'http://127.0.0.1',
'loopback Redis' => 'http://127.0.0.1:6379',
@@ -87,5 +88,6 @@ it('blocks testConnection() for loopback endpoints', function (string $endpoint)
'http loopback' => 'http://127.0.0.1:6379',
'localhost' => 'http://localhost:9000',
'IPv6 loopback' => 'http://[::1]',
'IPv4-mapped IPv6 link-local' => 'http://[::ffff:169.254.169.254]',
'internal TLD' => 'http://backend.internal',
]);
+1
View File
@@ -75,6 +75,7 @@ test('S3Storage connection validation uses short s3 client timeouts', function (
->with(Mockery::on(function (array $config) {
expect($config['http']['connect_timeout'])->toBe(15);
expect($config['http']['timeout'])->toBe(15);
expect($config['http']['allow_redirects'])->toBeFalse();
return true;
}))
+40 -1
View File
@@ -11,7 +11,7 @@ it('accepts valid public URLs', function () {
$validUrls = [
'https://api.github.com',
'https://github.example.com/api/v3',
'https://github.com/api/v3',
'https://example.com',
'http://example.com',
];
@@ -22,6 +22,14 @@ it('accepts valid public URLs', function () {
}
});
it('accepts custom external hostnames that resolve to public IPs', function () {
$rule = new SafeExternalUrl(fn (string $host): array => ['93.184.216.34']);
$validator = Validator::make(['url' => 'https://github.example.com/api/v3'], ['url' => $rule]);
expect($validator->passes())->toBeTrue('Expected valid custom external hostname');
});
it('rejects private IPv4 addresses', function (string $url) {
$rule = new SafeExternalUrl;
@@ -42,6 +50,34 @@ it('rejects cloud metadata IP', function () {
expect($validator->fails())->toBeTrue('Expected rejection: cloud metadata IP');
});
it('rejects hostnames that resolve to private or reserved addresses', function (string $url, array $resolvedIps) {
$rule = new SafeExternalUrl(fn (string $host): array => $resolvedIps);
$validator = Validator::make(['url' => $url], ['url' => $rule]);
expect($validator->fails())->toBeTrue("Expected rejection after DNS resolution: {$url}");
})->with([
'hostname to link-local IP' => ['http://169.254.169.254.nip.io/', ['169.254.169.254']],
'hostname to loopback' => ['http://loopback.example.test/', ['127.0.0.1']],
'hostname to private IPv4' => ['http://private.example.test/', ['10.0.0.1']],
'hostname to IPv6 loopback' => ['http://ipv6-loopback.example.test/', ['::1']],
'hostname to IPv6 link-local' => ['http://ipv6-link-local.example.test/', ['fe80::1']],
'hostname to IPv6 ULA' => ['http://ipv6-ula.example.test/', ['fc00::1']],
'hostname to mapped private IPv4' => ['http://mapped-private.example.test/', ['::ffff:10.0.0.1']],
]);
it('rejects IPv4-mapped IPv6 literals for private or reserved IPv4 ranges', function (string $url) {
$rule = new SafeExternalUrl;
$validator = Validator::make(['url' => $url], ['url' => $rule]);
expect($validator->fails())->toBeTrue("Expected rejection: {$url}");
})->with([
'mapped link-local IP' => 'http://[::ffff:169.254.169.254]/',
'mapped loopback' => 'http://[::ffff:127.0.0.1]/',
'mapped private' => 'http://[::ffff:10.0.0.1]/',
]);
it('rejects localhost and internal hostnames', function (string $url) {
$rule = new SafeExternalUrl;
@@ -50,9 +86,12 @@ it('rejects localhost and internal hostnames', function (string $url) {
})->with([
'localhost' => 'http://localhost',
'localhost with port' => 'http://localhost:8080',
'localhost with trailing dot' => 'http://localhost.',
'zero address' => 'http://0.0.0.0',
'.local domain' => 'http://myservice.local',
'.local domain with trailing dot' => 'http://myservice.local.',
'.internal domain' => 'http://myservice.internal',
'.internal domain with trailing dot' => 'http://myservice.internal.',
]);
it('rejects non-URL strings', function (string $value) {
+29
View File
@@ -59,6 +59,33 @@ it('rejects link-local range', function () {
expect($validator->fails())->toBeTrue('Expected rejection: link-local IP');
});
it('rejects hostnames that resolve to blocked addresses', function (string $url, array $resolvedIps) {
$rule = new SafeWebhookUrl(fn (string $host): array => $resolvedIps);
$validator = Validator::make(['url' => $url], ['url' => $rule]);
expect($validator->fails())->toBeTrue("Expected rejection after DNS resolution: {$url}");
})->with([
'hostname to link-local IP' => ['http://169.254.169.254.nip.io/', ['169.254.169.254']],
'hostname to loopback' => ['http://loopback.example.test/', ['127.0.0.1']],
'hostname to IPv6 loopback' => ['http://ipv6-loopback.example.test/', ['::1']],
'hostname to IPv6 link-local' => ['http://ipv6-link-local.example.test/', ['fe80::1']],
'hostname to IPv6 ULA' => ['http://ipv6-ula.example.test/', ['fc00::1']],
'hostname to mapped link-local IP' => ['http://mapped-link-local.example.test/', ['::ffff:169.254.169.254']],
]);
it('rejects IPv4-mapped IPv6 literals for blocked IPv4 ranges', function (string $url) {
$rule = new SafeWebhookUrl;
$validator = Validator::make(['url' => $url], ['url' => $rule]);
expect($validator->fails())->toBeTrue("Expected rejection: {$url}");
})->with([
'mapped link-local IP' => 'http://[::ffff:169.254.169.254]/',
'mapped loopback' => 'http://[::ffff:127.0.0.1]/',
'mapped zero' => 'http://[::ffff:0.0.0.0]/',
]);
it('rejects localhost and internal hostnames', function (string $url) {
$rule = new SafeWebhookUrl;
@@ -67,7 +94,9 @@ it('rejects localhost and internal hostnames', function (string $url) {
})->with([
'localhost' => 'http://localhost',
'localhost with port' => 'http://localhost:8080',
'localhost with trailing dot' => 'http://localhost.',
'.internal domain' => 'http://myservice.internal',
'.internal domain with trailing dot' => 'http://myservice.internal.',
]);
it('rejects non-http schemes', function (string $value) {
+14 -17
View File
@@ -2,7 +2,6 @@
use App\Jobs\SendWebhookJob;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
use Tests\TestCase;
uses(TestCase::class);
@@ -24,11 +23,6 @@ it('sends webhook to valid URLs', function () {
it('blocks webhook to loopback address', function () {
Http::fake();
Log::shouldReceive('warning')
->once()
->withArgs(function ($message) {
return str_contains($message, 'blocked unsafe webhook URL');
});
$job = new SendWebhookJob(
payload: ['event' => 'test'],
@@ -42,15 +36,23 @@ it('blocks webhook to loopback address', function () {
it('blocks webhook to cloud metadata endpoint', function () {
Http::fake();
Log::shouldReceive('warning')
->once()
->withArgs(function ($message) {
return str_contains($message, 'blocked unsafe webhook URL');
});
$job = new SendWebhookJob(
payload: ['event' => 'test'],
webhookUrl: 'http://169.254.169.254/latest/meta-data/'
webhookUrl: 'http://169.254.169.254/'
);
$job->handle();
Http::assertNothingSent();
});
it('blocks webhook to IPv4-mapped IPv6 link-local endpoint', function () {
Http::fake();
$job = new SendWebhookJob(
payload: ['event' => 'test'],
webhookUrl: 'http://[::ffff:169.254.169.254]/'
);
$job->handle();
@@ -60,11 +62,6 @@ it('blocks webhook to cloud metadata endpoint', function () {
it('blocks webhook to localhost', function () {
Http::fake();
Log::shouldReceive('warning')
->once()
->withArgs(function ($message) {
return str_contains($message, 'blocked unsafe webhook URL');
});
$job = new SendWebhookJob(
payload: ['event' => 'test'],