diff --git a/app/Actions/Database/StartMongodb.php b/app/Actions/Database/StartMongodb.php index 9565990c1..c79789718 100644 --- a/app/Actions/Database/StartMongodb.php +++ b/app/Actions/Database/StartMongodb.php @@ -340,7 +340,10 @@ private function add_custom_mongo_conf() private function add_default_database() { - $content = "db = db.getSiblingDB(\"{$this->database->mongo_initdb_database}\");db.createCollection('init_collection');db.createUser({user: \"{$this->database->mongo_initdb_root_username}\", pwd: \"{$this->database->mongo_initdb_root_password}\",roles: [{role:\"readWrite\",db:\"{$this->database->mongo_initdb_database}\"}]});"; + $dbJson = json_encode($this->database->mongo_initdb_database, JSON_UNESCAPED_SLASHES); + $userJson = json_encode($this->database->mongo_initdb_root_username, JSON_UNESCAPED_SLASHES); + $pwdJson = json_encode($this->database->mongo_initdb_root_password, JSON_UNESCAPED_SLASHES); + $content = "db = db.getSiblingDB({$dbJson});db.createCollection('init_collection');db.createUser({user: {$userJson}, pwd: {$pwdJson}, roles: [{role:\"readWrite\",db:{$dbJson}}]});"; $content_base64 = base64_encode($content); $this->commands[] = "mkdir -p $this->configuration_dir/docker-entrypoint-initdb.d"; $this->commands[] = "echo '{$content_base64}' | base64 -d | tee $this->configuration_dir/docker-entrypoint-initdb.d/01-default-database.js > /dev/null"; diff --git a/app/Actions/Database/StartMysql.php b/app/Actions/Database/StartMysql.php index 1b2b338d3..0394d50b6 100644 --- a/app/Actions/Database/StartMysql.php +++ b/app/Actions/Database/StartMysql.php @@ -215,7 +215,8 @@ public function handle(StandaloneMysql $database) $this->commands[] = "docker compose -f $this->configuration_dir/docker-compose.yml up -d"; if ($this->database->enable_ssl) { - $this->commands[] = executeInDocker($this->database->uuid, "chown {$this->database->mysql_user}:{$this->database->mysql_user} /etc/mysql/certs/server.crt /etc/mysql/certs/server.key"); + $mysqlUser = escapeshellarg($this->database->mysql_user); + $this->commands[] = executeInDocker($this->database->uuid, "chown {$mysqlUser}:{$mysqlUser} /etc/mysql/certs/server.crt /etc/mysql/certs/server.key"); } $this->commands[] = "echo 'Database started.'"; diff --git a/app/Http/Controllers/Api/DatabasesController.php b/app/Http/Controllers/Api/DatabasesController.php index 6749d224b..c05af152f 100644 --- a/app/Http/Controllers/Api/DatabasesController.php +++ b/app/Http/Controllers/Api/DatabasesController.php @@ -379,9 +379,9 @@ public function update_by_uuid(Request $request) case 'standalone-postgresql': $allowedFields = ['name', 'description', 'image', 'public_port', 'public_port_timeout', 'is_public', 'instant_deploy', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'postgres_user', 'postgres_password', 'postgres_db', 'postgres_initdb_args', 'postgres_host_auth_method', 'postgres_conf']; $validator = customApiValidator($request->all(), [ - 'postgres_user' => 'string', - 'postgres_password' => 'string', - 'postgres_db' => 'string', + 'postgres_user' => ValidationPatterns::databaseIdentifierRules(required: false), + 'postgres_password' => ValidationPatterns::databasePasswordRules(required: false), + 'postgres_db' => ValidationPatterns::databaseIdentifierRules(required: false), 'postgres_initdb_args' => 'string', 'postgres_host_auth_method' => 'string', 'postgres_conf' => 'string', @@ -410,20 +410,20 @@ public function update_by_uuid(Request $request) case 'standalone-clickhouse': $allowedFields = ['name', 'description', 'image', 'public_port', 'public_port_timeout', 'is_public', 'instant_deploy', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'clickhouse_admin_user', 'clickhouse_admin_password']; $validator = customApiValidator($request->all(), [ - 'clickhouse_admin_user' => 'string', - 'clickhouse_admin_password' => 'string', + 'clickhouse_admin_user' => ValidationPatterns::databaseIdentifierRules(required: false), + 'clickhouse_admin_password' => ValidationPatterns::databasePasswordRules(required: false), ]); break; case 'standalone-dragonfly': $allowedFields = ['name', 'description', 'image', 'public_port', 'public_port_timeout', 'is_public', 'instant_deploy', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'dragonfly_password']; $validator = customApiValidator($request->all(), [ - 'dragonfly_password' => 'string', + 'dragonfly_password' => ValidationPatterns::databasePasswordRules(required: false), ]); break; case 'standalone-redis': $allowedFields = ['name', 'description', 'image', 'public_port', 'public_port_timeout', 'is_public', 'instant_deploy', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'redis_password', 'redis_conf']; $validator = customApiValidator($request->all(), [ - 'redis_password' => 'string', + 'redis_password' => ValidationPatterns::databasePasswordRules(required: false), 'redis_conf' => 'string', ]); if ($request->has('redis_conf')) { @@ -450,7 +450,7 @@ public function update_by_uuid(Request $request) case 'standalone-keydb': $allowedFields = ['name', 'description', 'image', 'public_port', 'public_port_timeout', 'is_public', 'instant_deploy', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'keydb_password', 'keydb_conf']; $validator = customApiValidator($request->all(), [ - 'keydb_password' => 'string', + 'keydb_password' => ValidationPatterns::databasePasswordRules(required: false), 'keydb_conf' => 'string', ]); if ($request->has('keydb_conf')) { @@ -478,10 +478,10 @@ public function update_by_uuid(Request $request) $allowedFields = ['name', 'description', 'image', 'public_port', 'public_port_timeout', 'is_public', 'instant_deploy', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'mariadb_conf', 'mariadb_root_password', 'mariadb_user', 'mariadb_password', 'mariadb_database']; $validator = customApiValidator($request->all(), [ 'mariadb_conf' => 'string', - 'mariadb_root_password' => 'string', - 'mariadb_user' => 'string', - 'mariadb_password' => 'string', - 'mariadb_database' => 'string', + 'mariadb_root_password' => ValidationPatterns::databasePasswordRules(required: false), + 'mariadb_user' => ValidationPatterns::databaseIdentifierRules(required: false), + 'mariadb_password' => ValidationPatterns::databasePasswordRules(required: false), + 'mariadb_database' => ValidationPatterns::databaseIdentifierRules(required: false), ]); if ($request->has('mariadb_conf')) { if (! isBase64Encoded($request->mariadb_conf)) { @@ -508,9 +508,9 @@ public function update_by_uuid(Request $request) $allowedFields = ['name', 'description', 'image', 'public_port', 'public_port_timeout', 'is_public', 'instant_deploy', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'mongo_conf', 'mongo_initdb_root_username', 'mongo_initdb_root_password', 'mongo_initdb_database']; $validator = customApiValidator($request->all(), [ 'mongo_conf' => 'string', - 'mongo_initdb_root_username' => 'string', - 'mongo_initdb_root_password' => 'string', - 'mongo_initdb_database' => 'string', + 'mongo_initdb_root_username' => ValidationPatterns::databaseIdentifierRules(required: false), + 'mongo_initdb_root_password' => ValidationPatterns::databasePasswordRules(required: false), + 'mongo_initdb_database' => ValidationPatterns::databaseIdentifierRules(required: false), ]); if ($request->has('mongo_conf')) { if (! isBase64Encoded($request->mongo_conf)) { @@ -537,10 +537,10 @@ public function update_by_uuid(Request $request) case 'standalone-mysql': $allowedFields = ['name', 'description', 'image', 'public_port', 'public_port_timeout', 'is_public', 'instant_deploy', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'mysql_root_password', 'mysql_password', 'mysql_user', 'mysql_database', 'mysql_conf']; $validator = customApiValidator($request->all(), [ - 'mysql_root_password' => 'string', - 'mysql_password' => 'string', - 'mysql_user' => 'string', - 'mysql_database' => 'string', + 'mysql_root_password' => ValidationPatterns::databasePasswordRules(required: false), + 'mysql_password' => ValidationPatterns::databasePasswordRules(required: false), + 'mysql_user' => ValidationPatterns::databaseIdentifierRules(required: false), + 'mysql_database' => ValidationPatterns::databaseIdentifierRules(required: false), 'mysql_conf' => 'string', ]); if ($request->has('mysql_conf')) { @@ -1724,9 +1724,9 @@ public function create_database(Request $request, NewDatabaseTypes $type) if ($type === NewDatabaseTypes::POSTGRESQL) { $allowedFields = ['name', 'description', 'image', 'public_port', 'public_port_timeout', 'is_public', 'project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'instant_deploy', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'postgres_user', 'postgres_password', 'postgres_db', 'postgres_initdb_args', 'postgres_host_auth_method', 'postgres_conf']; $validator = customApiValidator($request->all(), [ - 'postgres_user' => 'string', - 'postgres_password' => 'string', - 'postgres_db' => 'string', + 'postgres_user' => ValidationPatterns::databaseIdentifierRules(required: false), + 'postgres_password' => ValidationPatterns::databasePasswordRules(required: false), + 'postgres_db' => ValidationPatterns::databaseIdentifierRules(required: false), 'postgres_initdb_args' => 'string', 'postgres_host_auth_method' => 'string', 'postgres_conf' => 'string', @@ -1783,8 +1783,11 @@ public function create_database(Request $request, NewDatabaseTypes $type) } elseif ($type === NewDatabaseTypes::MARIADB) { $allowedFields = ['name', 'description', 'image', 'public_port', 'public_port_timeout', 'is_public', 'project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'instant_deploy', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'mariadb_conf', 'mariadb_root_password', 'mariadb_user', 'mariadb_password', 'mariadb_database']; $validator = customApiValidator($request->all(), [ - 'clickhouse_admin_user' => 'string', - 'clickhouse_admin_password' => 'string', + 'mariadb_conf' => 'string', + 'mariadb_root_password' => ValidationPatterns::databasePasswordRules(required: false), + 'mariadb_user' => ValidationPatterns::databaseIdentifierRules(required: false), + 'mariadb_password' => ValidationPatterns::databasePasswordRules(required: false), + 'mariadb_database' => ValidationPatterns::databaseIdentifierRules(required: false), ]); $extraFields = array_diff(array_keys($request->all()), $allowedFields); if ($validator->fails() || ! empty($extraFields)) { @@ -1839,10 +1842,10 @@ public function create_database(Request $request, NewDatabaseTypes $type) } elseif ($type === NewDatabaseTypes::MYSQL) { $allowedFields = ['name', 'description', 'image', 'public_port', 'public_port_timeout', 'is_public', 'project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'instant_deploy', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'mysql_root_password', 'mysql_password', 'mysql_user', 'mysql_database', 'mysql_conf']; $validator = customApiValidator($request->all(), [ - 'mysql_root_password' => 'string', - 'mysql_password' => 'string', - 'mysql_user' => 'string', - 'mysql_database' => 'string', + 'mysql_root_password' => ValidationPatterns::databasePasswordRules(required: false), + 'mysql_password' => ValidationPatterns::databasePasswordRules(required: false), + 'mysql_user' => ValidationPatterns::databaseIdentifierRules(required: false), + 'mysql_database' => ValidationPatterns::databaseIdentifierRules(required: false), 'mysql_conf' => 'string', ]); $extraFields = array_diff(array_keys($request->all()), $allowedFields); @@ -1898,7 +1901,7 @@ public function create_database(Request $request, NewDatabaseTypes $type) } elseif ($type === NewDatabaseTypes::REDIS) { $allowedFields = ['name', 'description', 'image', 'public_port', 'public_port_timeout', 'is_public', 'project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'instant_deploy', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'redis_password', 'redis_conf']; $validator = customApiValidator($request->all(), [ - 'redis_password' => 'string', + 'redis_password' => ValidationPatterns::databasePasswordRules(required: false), 'redis_conf' => 'string', ]); $extraFields = array_diff(array_keys($request->all()), $allowedFields); @@ -1954,7 +1957,7 @@ public function create_database(Request $request, NewDatabaseTypes $type) } elseif ($type === NewDatabaseTypes::DRAGONFLY) { $allowedFields = ['name', 'description', 'image', 'public_port', 'public_port_timeout', 'is_public', 'project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'instant_deploy', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'dragonfly_password']; $validator = customApiValidator($request->all(), [ - 'dragonfly_password' => 'string', + 'dragonfly_password' => ValidationPatterns::databasePasswordRules(required: false), ]); $extraFields = array_diff(array_keys($request->all()), $allowedFields); @@ -1984,7 +1987,7 @@ public function create_database(Request $request, NewDatabaseTypes $type) } elseif ($type === NewDatabaseTypes::KEYDB) { $allowedFields = ['name', 'description', 'image', 'public_port', 'public_port_timeout', 'is_public', 'project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'instant_deploy', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'keydb_password', 'keydb_conf']; $validator = customApiValidator($request->all(), [ - 'keydb_password' => 'string', + 'keydb_password' => ValidationPatterns::databasePasswordRules(required: false), 'keydb_conf' => 'string', ]); $extraFields = array_diff(array_keys($request->all()), $allowedFields); @@ -2040,8 +2043,8 @@ public function create_database(Request $request, NewDatabaseTypes $type) } elseif ($type === NewDatabaseTypes::CLICKHOUSE) { $allowedFields = ['name', 'description', 'image', 'public_port', 'public_port_timeout', 'is_public', 'project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'instant_deploy', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'clickhouse_admin_user', 'clickhouse_admin_password']; $validator = customApiValidator($request->all(), [ - 'clickhouse_admin_user' => 'string', - 'clickhouse_admin_password' => 'string', + 'clickhouse_admin_user' => ValidationPatterns::databaseIdentifierRules(required: false), + 'clickhouse_admin_password' => ValidationPatterns::databasePasswordRules(required: false), ]); $extraFields = array_diff(array_keys($request->all()), $allowedFields); if ($validator->fails() || ! empty($extraFields)) { @@ -2077,9 +2080,9 @@ public function create_database(Request $request, NewDatabaseTypes $type) $allowedFields = ['name', 'description', 'image', 'public_port', 'public_port_timeout', 'is_public', 'project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'instant_deploy', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'mongo_conf', 'mongo_initdb_root_username', 'mongo_initdb_root_password', 'mongo_initdb_database']; $validator = customApiValidator($request->all(), [ 'mongo_conf' => 'string', - 'mongo_initdb_root_username' => 'string', - 'mongo_initdb_root_password' => 'string', - 'mongo_initdb_database' => 'string', + 'mongo_initdb_root_username' => ValidationPatterns::databaseIdentifierRules(required: false), + 'mongo_initdb_root_password' => ValidationPatterns::databasePasswordRules(required: false), + 'mongo_initdb_database' => ValidationPatterns::databaseIdentifierRules(required: false), ]); $extraFields = array_diff(array_keys($request->all()), $allowedFields); if ($validator->fails() || ! empty($extraFields)) { diff --git a/app/Livewire/Project/Database/Clickhouse/General.php b/app/Livewire/Project/Database/Clickhouse/General.php index e06629d10..2583c10ea 100644 --- a/app/Livewire/Project/Database/Clickhouse/General.php +++ b/app/Livewire/Project/Database/Clickhouse/General.php @@ -76,8 +76,12 @@ protected function rules(): array return [ 'name' => ValidationPatterns::nameRules(), 'description' => ValidationPatterns::descriptionRules(), - 'clickhouseAdminUser' => 'required|string', - 'clickhouseAdminPassword' => 'required|string', + 'clickhouseAdminUser' => ValidationPatterns::databaseIdentifierRules( + enforcePattern: $this->clickhouseAdminUser !== $this->database->clickhouse_admin_user, + ), + 'clickhouseAdminPassword' => ValidationPatterns::databasePasswordRules( + enforcePattern: $this->clickhouseAdminPassword !== $this->database->clickhouse_admin_password, + ), 'image' => 'required|string', 'portsMappings' => ValidationPatterns::portMappingRules(), 'isPublic' => 'nullable|boolean', @@ -96,10 +100,8 @@ protected function messages(): array ValidationPatterns::combinedMessages(), ValidationPatterns::portMappingMessages(), [ - 'clickhouseAdminUser.required' => 'The Admin User field is required.', - 'clickhouseAdminUser.string' => 'The Admin User must be a string.', - 'clickhouseAdminPassword.required' => 'The Admin Password field is required.', - 'clickhouseAdminPassword.string' => 'The Admin Password must be a string.', + ...ValidationPatterns::databaseIdentifierMessages('clickhouseAdminUser', 'Admin User'), + ...ValidationPatterns::databasePasswordMessages('clickhouseAdminPassword', 'Admin Password'), 'image.required' => 'The Docker Image field is required.', 'image.string' => 'The Docker Image must be a string.', 'publicPort.integer' => 'The Public Port must be an integer.', diff --git a/app/Livewire/Project/Database/Dragonfly/General.php b/app/Livewire/Project/Database/Dragonfly/General.php index 5176f5ff9..9e1ea0d10 100644 --- a/app/Livewire/Project/Database/Dragonfly/General.php +++ b/app/Livewire/Project/Database/Dragonfly/General.php @@ -89,7 +89,9 @@ protected function rules(): array return [ 'name' => ValidationPatterns::nameRules(), 'description' => ValidationPatterns::descriptionRules(), - 'dragonflyPassword' => 'required|string', + 'dragonflyPassword' => ValidationPatterns::databasePasswordRules( + enforcePattern: $this->dragonflyPassword !== $this->database->dragonfly_password, + ), 'image' => 'required|string', 'portsMappings' => ValidationPatterns::portMappingRules(), 'isPublic' => 'nullable|boolean', @@ -109,8 +111,7 @@ protected function messages(): array ValidationPatterns::combinedMessages(), ValidationPatterns::portMappingMessages(), [ - 'dragonflyPassword.required' => 'The Dragonfly Password field is required.', - 'dragonflyPassword.string' => 'The Dragonfly Password must be a string.', + ...ValidationPatterns::databasePasswordMessages('dragonflyPassword', 'Dragonfly Password'), 'image.required' => 'The Docker Image field is required.', 'image.string' => 'The Docker Image must be a string.', 'publicPort.integer' => 'The Public Port must be an integer.', diff --git a/app/Livewire/Project/Database/Keydb/General.php b/app/Livewire/Project/Database/Keydb/General.php index b50f196a8..7c8808499 100644 --- a/app/Livewire/Project/Database/Keydb/General.php +++ b/app/Livewire/Project/Database/Keydb/General.php @@ -92,7 +92,9 @@ protected function rules(): array 'name' => ValidationPatterns::nameRules(), 'description' => ValidationPatterns::descriptionRules(), 'keydbConf' => 'nullable|string', - 'keydbPassword' => 'required|string', + 'keydbPassword' => ValidationPatterns::databasePasswordRules( + enforcePattern: $this->keydbPassword !== $this->database->keydb_password, + ), 'image' => 'required|string', 'portsMappings' => ValidationPatterns::portMappingRules(), 'isPublic' => 'nullable|boolean', @@ -114,8 +116,7 @@ protected function messages(): array ValidationPatterns::combinedMessages(), ValidationPatterns::portMappingMessages(), [ - 'keydbPassword.required' => 'The KeyDB Password field is required.', - 'keydbPassword.string' => 'The KeyDB Password must be a string.', + ...ValidationPatterns::databasePasswordMessages('keydbPassword', 'KeyDB Password'), 'image.required' => 'The Docker Image field is required.', 'image.string' => 'The Docker Image must be a string.', 'publicPort.integer' => 'The Public Port must be an integer.', diff --git a/app/Livewire/Project/Database/Mariadb/General.php b/app/Livewire/Project/Database/Mariadb/General.php index 9a1a8bd68..ea6d902e7 100644 --- a/app/Livewire/Project/Database/Mariadb/General.php +++ b/app/Livewire/Project/Database/Mariadb/General.php @@ -74,10 +74,18 @@ protected function rules(): array return [ 'name' => ValidationPatterns::nameRules(), 'description' => ValidationPatterns::descriptionRules(), - 'mariadbRootPassword' => 'required', - 'mariadbUser' => 'required', - 'mariadbPassword' => 'required', - 'mariadbDatabase' => 'required', + 'mariadbRootPassword' => ValidationPatterns::databasePasswordRules( + enforcePattern: $this->mariadbRootPassword !== $this->database->mariadb_root_password, + ), + 'mariadbUser' => ValidationPatterns::databaseIdentifierRules( + enforcePattern: $this->mariadbUser !== $this->database->mariadb_user, + ), + 'mariadbPassword' => ValidationPatterns::databasePasswordRules( + enforcePattern: $this->mariadbPassword !== $this->database->mariadb_password, + ), + 'mariadbDatabase' => ValidationPatterns::databaseIdentifierRules( + enforcePattern: $this->mariadbDatabase !== $this->database->mariadb_database, + ), 'mariadbConf' => 'nullable', 'image' => 'required', 'portsMappings' => ValidationPatterns::portMappingRules(), @@ -97,10 +105,10 @@ protected function messages(): array ValidationPatterns::portMappingMessages(), [ 'name.required' => 'The Name field is required.', - 'mariadbRootPassword.required' => 'The Root Password field is required.', - 'mariadbUser.required' => 'The MariaDB User field is required.', - 'mariadbPassword.required' => 'The MariaDB Password field is required.', - 'mariadbDatabase.required' => 'The MariaDB Database field is required.', + ...ValidationPatterns::databasePasswordMessages('mariadbRootPassword', 'Root Password'), + ...ValidationPatterns::databaseIdentifierMessages('mariadbUser', 'MariaDB User'), + ...ValidationPatterns::databasePasswordMessages('mariadbPassword', 'MariaDB Password'), + ...ValidationPatterns::databaseIdentifierMessages('mariadbDatabase', 'MariaDB Database'), 'image.required' => 'The Docker Image field is required.', 'publicPort.integer' => 'The Public Port must be an integer.', 'publicPort.min' => 'The Public Port must be at least 1.', diff --git a/app/Livewire/Project/Database/Mongodb/General.php b/app/Livewire/Project/Database/Mongodb/General.php index a21de744a..3af4b0b2a 100644 --- a/app/Livewire/Project/Database/Mongodb/General.php +++ b/app/Livewire/Project/Database/Mongodb/General.php @@ -75,9 +75,15 @@ protected function rules(): array 'name' => ValidationPatterns::nameRules(), 'description' => ValidationPatterns::descriptionRules(), 'mongoConf' => 'nullable', - 'mongoInitdbRootUsername' => 'required', - 'mongoInitdbRootPassword' => 'required', - 'mongoInitdbDatabase' => 'required', + 'mongoInitdbRootUsername' => ValidationPatterns::databaseIdentifierRules( + enforcePattern: $this->mongoInitdbRootUsername !== $this->database->mongo_initdb_root_username, + ), + 'mongoInitdbRootPassword' => ValidationPatterns::databasePasswordRules( + enforcePattern: $this->mongoInitdbRootPassword !== $this->database->mongo_initdb_root_password, + ), + 'mongoInitdbDatabase' => ValidationPatterns::databaseIdentifierRules( + enforcePattern: $this->mongoInitdbDatabase !== $this->database->mongo_initdb_database, + ), 'image' => 'required', 'portsMappings' => ValidationPatterns::portMappingRules(), 'isPublic' => 'nullable|boolean', @@ -97,9 +103,9 @@ protected function messages(): array ValidationPatterns::portMappingMessages(), [ 'name.required' => 'The Name field is required.', - 'mongoInitdbRootUsername.required' => 'The Root Username field is required.', - 'mongoInitdbRootPassword.required' => 'The Root Password field is required.', - 'mongoInitdbDatabase.required' => 'The MongoDB Database field is required.', + ...ValidationPatterns::databaseIdentifierMessages('mongoInitdbRootUsername', 'Root Username'), + ...ValidationPatterns::databasePasswordMessages('mongoInitdbRootPassword', 'Root Password'), + ...ValidationPatterns::databaseIdentifierMessages('mongoInitdbDatabase', 'MongoDB Database'), 'image.required' => 'The Docker Image field is required.', 'publicPort.integer' => 'The Public Port must be an integer.', 'publicPort.min' => 'The Public Port must be at least 1.', diff --git a/app/Livewire/Project/Database/Mysql/General.php b/app/Livewire/Project/Database/Mysql/General.php index cacb4ac49..34726bd0a 100644 --- a/app/Livewire/Project/Database/Mysql/General.php +++ b/app/Livewire/Project/Database/Mysql/General.php @@ -76,10 +76,18 @@ protected function rules(): array return [ 'name' => ValidationPatterns::nameRules(), 'description' => ValidationPatterns::descriptionRules(), - 'mysqlRootPassword' => 'required', - 'mysqlUser' => 'required', - 'mysqlPassword' => 'required', - 'mysqlDatabase' => 'required', + 'mysqlRootPassword' => ValidationPatterns::databasePasswordRules( + enforcePattern: $this->mysqlRootPassword !== $this->database->mysql_root_password, + ), + 'mysqlUser' => ValidationPatterns::databaseIdentifierRules( + enforcePattern: $this->mysqlUser !== $this->database->mysql_user, + ), + 'mysqlPassword' => ValidationPatterns::databasePasswordRules( + enforcePattern: $this->mysqlPassword !== $this->database->mysql_password, + ), + 'mysqlDatabase' => ValidationPatterns::databaseIdentifierRules( + enforcePattern: $this->mysqlDatabase !== $this->database->mysql_database, + ), 'mysqlConf' => 'nullable', 'image' => 'required', 'portsMappings' => ValidationPatterns::portMappingRules(), @@ -100,10 +108,10 @@ protected function messages(): array ValidationPatterns::portMappingMessages(), [ 'name.required' => 'The Name field is required.', - 'mysqlRootPassword.required' => 'The Root Password field is required.', - 'mysqlUser.required' => 'The MySQL User field is required.', - 'mysqlPassword.required' => 'The MySQL Password field is required.', - 'mysqlDatabase.required' => 'The MySQL Database field is required.', + ...ValidationPatterns::databasePasswordMessages('mysqlRootPassword', 'Root Password'), + ...ValidationPatterns::databaseIdentifierMessages('mysqlUser', 'MySQL User'), + ...ValidationPatterns::databasePasswordMessages('mysqlPassword', 'MySQL Password'), + ...ValidationPatterns::databaseIdentifierMessages('mysqlDatabase', 'MySQL Database'), 'image.required' => 'The Docker Image field is required.', 'publicPort.integer' => 'The Public Port must be an integer.', 'publicPort.min' => 'The Public Port must be at least 1.', diff --git a/app/Livewire/Project/Database/Postgresql/General.php b/app/Livewire/Project/Database/Postgresql/General.php index 22e350683..a9a4115fd 100644 --- a/app/Livewire/Project/Database/Postgresql/General.php +++ b/app/Livewire/Project/Database/Postgresql/General.php @@ -86,9 +86,15 @@ protected function rules(): array return [ 'name' => ValidationPatterns::nameRules(), 'description' => ValidationPatterns::descriptionRules(), - 'postgresUser' => 'required', - 'postgresPassword' => 'required', - 'postgresDb' => 'required', + 'postgresUser' => ValidationPatterns::databaseIdentifierRules( + enforcePattern: $this->postgresUser !== $this->database->postgres_user, + ), + 'postgresPassword' => ValidationPatterns::databasePasswordRules( + enforcePattern: $this->postgresPassword !== $this->database->postgres_password, + ), + 'postgresDb' => ValidationPatterns::databaseIdentifierRules( + enforcePattern: $this->postgresDb !== $this->database->postgres_db, + ), 'postgresInitdbArgs' => 'nullable', 'postgresHostAuthMethod' => 'nullable', 'postgresConf' => 'nullable', @@ -112,9 +118,9 @@ protected function messages(): array ValidationPatterns::portMappingMessages(), [ 'name.required' => 'The Name field is required.', - 'postgresUser.required' => 'The Postgres User field is required.', - 'postgresPassword.required' => 'The Postgres Password field is required.', - 'postgresDb.required' => 'The Postgres Database field is required.', + ...ValidationPatterns::databaseIdentifierMessages('postgresUser', 'Postgres User'), + ...ValidationPatterns::databasePasswordMessages('postgresPassword', 'Postgres Password'), + ...ValidationPatterns::databaseIdentifierMessages('postgresDb', 'Postgres Database'), 'image.required' => 'The Docker Image field is required.', 'publicPort.integer' => 'The Public Port must be an integer.', 'publicPort.min' => 'The Public Port must be at least 1.', diff --git a/app/Livewire/Project/Database/Redis/General.php b/app/Livewire/Project/Database/Redis/General.php index 3c32a6192..c3cc43972 100644 --- a/app/Livewire/Project/Database/Redis/General.php +++ b/app/Livewire/Project/Database/Redis/General.php @@ -81,8 +81,12 @@ protected function rules(): array 'publicPortTimeout' => 'nullable|integer|min:1', 'isLogDrainEnabled' => 'nullable|boolean', 'customDockerRunOptions' => 'nullable', - 'redisUsername' => 'required', - 'redisPassword' => 'required', + 'redisUsername' => ValidationPatterns::databaseIdentifierRules( + enforcePattern: $this->redisUsername !== $this->database->redis_username, + ), + 'redisPassword' => ValidationPatterns::databasePasswordRules( + enforcePattern: $this->redisPassword !== $this->database->redis_password, + ), 'enableSsl' => 'boolean', ]; } @@ -100,8 +104,8 @@ protected function messages(): array 'publicPort.max' => 'The Public Port must not exceed 65535.', 'publicPortTimeout.integer' => 'The Public Port Timeout must be an integer.', 'publicPortTimeout.min' => 'The Public Port Timeout must be at least 1.', - 'redisUsername.required' => 'The Redis Username field is required.', - 'redisPassword.required' => 'The Redis Password field is required.', + ...ValidationPatterns::databaseIdentifierMessages('redisUsername', 'Redis Username'), + ...ValidationPatterns::databasePasswordMessages('redisPassword', 'Redis Password'), ] ); } diff --git a/app/Support/ValidationPatterns.php b/app/Support/ValidationPatterns.php index 88121384f..09c40a466 100644 --- a/app/Support/ValidationPatterns.php +++ b/app/Support/ValidationPatterns.php @@ -66,6 +66,112 @@ class ValidationPatterns */ public const DOCKER_NETWORK_PATTERN = '/^[a-zA-Z0-9][a-zA-Z0-9._-]*$/'; + /** + * Pattern for SQL-safe unquoted database identifiers (usernames, database names). + * Allows letters, digits, underscore; first char must be letter or underscore. + * Excludes all shell metacharacters. Max 63 chars (Postgres identifier limit). + */ + public const DB_IDENTIFIER_PATTERN = '/^[A-Za-z_][A-Za-z0-9_]{0,62}$/'; + + /** + * Pattern for database passwords. + * Excludes shell-dangerous characters: backtick, $, ;, |, &, <, >, \, ', ", space, newline, CR, tab, null. + * Allows a broad set of printable characters so passwords remain strong. + */ + public const DB_PASSWORD_PATTERN = '/^[A-Za-z0-9!@#%^*()_+\-=\[\]{}:,.?\/~]+$/'; + + /** + * Get validation rules for database identifier fields (username, database name). + * + * Set $enforcePattern to false to skip the regex check (for example when + * re-validating a legacy value on an existing record that has not been + * changed by the user). The length and type rules are always applied. + */ + public static function databaseIdentifierRules(bool $required = true, int $minLength = 1, int $maxLength = 63, bool $enforcePattern = true): array + { + $rules = []; + + if ($required) { + $rules[] = 'required'; + } else { + $rules[] = 'nullable'; + } + + $rules[] = 'string'; + $rules[] = "min:$minLength"; + $rules[] = "max:$maxLength"; + + if ($enforcePattern) { + $rules[] = 'regex:'.self::DB_IDENTIFIER_PATTERN; + } + + return $rules; + } + + /** + * Get validation messages for database identifier fields. + */ + public static function databaseIdentifierMessages(string $field, string $label = ''): array + { + $label = $label ?: $field; + + return [ + "{$field}.regex" => "The {$label} may only contain letters, digits, and underscores, and must start with a letter or underscore.", + "{$field}.min" => "The {$label} must be at least :min character.", + "{$field}.max" => "The {$label} may not be greater than :max characters.", + ]; + } + + /** + * Get validation rules for database password fields. + * + * Set $enforcePattern to false to skip the regex check (for example when + * re-validating a legacy value on an existing record that has not been + * changed by the user). The length and type rules are always applied. + */ + public static function databasePasswordRules(bool $required = true, int $minLength = 1, int $maxLength = 128, bool $enforcePattern = true): array + { + $rules = []; + + if ($required) { + $rules[] = 'required'; + } else { + $rules[] = 'nullable'; + } + + $rules[] = 'string'; + $rules[] = "min:$minLength"; + $rules[] = "max:$maxLength"; + + if ($enforcePattern) { + $rules[] = 'regex:'.self::DB_PASSWORD_PATTERN; + } + + return $rules; + } + + /** + * Get validation messages for database password fields. + */ + public static function databasePasswordMessages(string $field, string $label = ''): array + { + $label = $label ?: $field; + + return [ + "{$field}.regex" => "The {$label} may not contain shell-unsafe characters (backtick, \$, ;, |, &, <, >, \\, quotes, spaces, or control characters).", + "{$field}.min" => "The {$label} must be at least :min character.", + "{$field}.max" => "The {$label} may not be greater than :max characters.", + ]; + } + + /** + * Check if a string is a valid database identifier. + */ + public static function isValidDatabaseIdentifier(string $value): bool + { + return preg_match(self::DB_IDENTIFIER_PATTERN, $value) === 1; + } + /** * Get validation rules for name fields */ diff --git a/tests/Unit/DatabaseCredentialDirtyValidationTest.php b/tests/Unit/DatabaseCredentialDirtyValidationTest.php new file mode 100644 index 000000000..85063f9e0 --- /dev/null +++ b/tests/Unit/DatabaseCredentialDirtyValidationTest.php @@ -0,0 +1,87 @@ + str_starts_with($rule, 'regex:')); + expect($regexRules)->not->toBeEmpty(); +}); + +it('databasePasswordRules includes regex rule when enforcePattern true', function () { + $rules = ValidationPatterns::databasePasswordRules(enforcePattern: true); + + $regexRules = array_filter($rules, fn ($rule) => str_starts_with($rule, 'regex:')); + expect($regexRules)->not->toBeEmpty(); +}); + +it('databasePasswordRules omits regex rule when enforcePattern false', function () { + $rules = ValidationPatterns::databasePasswordRules(enforcePattern: false); + + $regexRules = array_filter($rules, fn ($rule) => str_starts_with($rule, 'regex:')); + expect($regexRules)->toBeEmpty(); +}); + +it('databasePasswordRules keeps required, string, min and max when enforcePattern false', function () { + $rules = ValidationPatterns::databasePasswordRules(required: true, minLength: 1, maxLength: 128, enforcePattern: false); + + expect($rules)->toContain('required'); + expect($rules)->toContain('string'); + expect($rules)->toContain('min:1'); + expect($rules)->toContain('max:128'); +}); + +it('databasePasswordRules keeps nullable and bounds when not required and enforcePattern false', function () { + $rules = ValidationPatterns::databasePasswordRules(required: false, minLength: 2, maxLength: 64, enforcePattern: false); + + expect($rules)->toContain('nullable'); + expect($rules)->toContain('string'); + expect($rules)->toContain('min:2'); + expect($rules)->toContain('max:64'); + expect(array_filter($rules, fn ($rule) => str_starts_with($rule, 'regex:')))->toBeEmpty(); +}); + +// ── databaseIdentifierRules ─────────────────────────────────────────────────── + +it('databaseIdentifierRules includes regex rule by default', function () { + $rules = ValidationPatterns::databaseIdentifierRules(); + + $regexRules = array_filter($rules, fn ($rule) => str_starts_with($rule, 'regex:')); + expect($regexRules)->not->toBeEmpty(); +}); + +it('databaseIdentifierRules includes regex rule when enforcePattern true', function () { + $rules = ValidationPatterns::databaseIdentifierRules(enforcePattern: true); + + $regexRules = array_filter($rules, fn ($rule) => str_starts_with($rule, 'regex:')); + expect($regexRules)->not->toBeEmpty(); +}); + +it('databaseIdentifierRules omits regex rule when enforcePattern false', function () { + $rules = ValidationPatterns::databaseIdentifierRules(enforcePattern: false); + + $regexRules = array_filter($rules, fn ($rule) => str_starts_with($rule, 'regex:')); + expect($regexRules)->toBeEmpty(); +}); + +it('databaseIdentifierRules keeps required, string, min and max when enforcePattern false', function () { + $rules = ValidationPatterns::databaseIdentifierRules(required: true, minLength: 1, maxLength: 63, enforcePattern: false); + + expect($rules)->toContain('required'); + expect($rules)->toContain('string'); + expect($rules)->toContain('min:1'); + expect($rules)->toContain('max:63'); +}); + +it('databaseIdentifierRules keeps nullable and bounds when not required and enforcePattern false', function () { + $rules = ValidationPatterns::databaseIdentifierRules(required: false, minLength: 1, maxLength: 30, enforcePattern: false); + + expect($rules)->toContain('nullable'); + expect($rules)->toContain('string'); + expect($rules)->toContain('min:1'); + expect($rules)->toContain('max:30'); + expect(array_filter($rules, fn ($rule) => str_starts_with($rule, 'regex:')))->toBeEmpty(); +}); diff --git a/tests/Unit/DatabaseCredentialValidationPatternTest.php b/tests/Unit/DatabaseCredentialValidationPatternTest.php new file mode 100644 index 000000000..9331b4cbd --- /dev/null +++ b/tests/Unit/DatabaseCredentialValidationPatternTest.php @@ -0,0 +1,176 @@ +toBe(1); +})->with([ + 'simple lowercase' => 'postgres', + 'underscore prefix' => '_admin', + 'mixed case' => 'MyDatabase', + 'alphanumeric' => 'App_DB_1', + 'single char' => 'a', + 'all caps' => 'ROOT', + 'numbers in middle' => 'db2user', +]); + +it('DB_IDENTIFIER_PATTERN rejects shell-dangerous and invalid identifiers', function (string $id) { + expect(preg_match(ValidationPatterns::DB_IDENTIFIER_PATTERN, $id))->toBe(0); +})->with([ + 'semicolon' => 'user;id', + 'pipe' => 'user|cat', + 'ampersand' => 'user&rm', + 'dollar sign' => 'user$x', + 'backtick' => 'user`id`', + 'subshell' => 'user$(id)', + 'space' => 'user name', + 'newline' => "user\nname", + 'single quote' => "user'name", + 'double quote' => 'user"name', + 'backslash' => 'user\\name', + 'less than' => 'user 'user>name', + 'leading digit' => '1user', + 'hyphen' => 'my-user', + 'dot' => 'my.user', + 'empty' => '', + '64 chars (over limit)' => str_repeat('a', 64), + 'advisory poc payload' => 'root; touch /tmp/pwned_rce; #', + 'subshell payload' => 'a$(touch /tmp/pwn)b', +]); + +// ── DB_PASSWORD_PATTERN ─────────────────────────────────────────────────────── + +it('DB_PASSWORD_PATTERN accepts strong passwords without shell-dangerous chars', function (string $pw) { + expect(preg_match(ValidationPatterns::DB_PASSWORD_PATTERN, $pw))->toBe(1); +})->with([ + 'alphanumeric' => 'SecurePass123', + 'with special safe chars' => 'P@ss!word#1', + 'with brackets' => 'P{a}ss[word]', + 'with slash' => 'Pass/word1', + 'with dot comma' => 'Pass.word,1', + 'with hyphen' => 'Pass-word1', + 'with plus equals' => 'Pass+word=1', + 'with tilde colon' => 'P~ass:word1', + 'complex strong' => 'Str0ng!P@ss#word^123', +]); + +it('DB_PASSWORD_PATTERN rejects shell-dangerous characters', function (string $pw) { + expect(preg_match(ValidationPatterns::DB_PASSWORD_PATTERN, $pw))->toBe(0); +})->with([ + 'backtick' => 'pass`word`', + 'dollar sign' => 'pass$word', + 'semicolon' => 'pass;word', + 'pipe' => 'pass|word', + 'ampersand' => 'pass&word', + 'less than' => 'pass 'pass>word', + 'backslash' => 'pass\\word', + 'single quote' => "pass'word", + 'double quote' => 'pass"word', + 'space' => 'pass word', + 'newline' => "pass\nword", + 'carriage return' => "pass\rword", + 'tab' => "pass\tword", + 'empty' => '', + 'command substitution' => '$(whoami)', + 'rce payload' => 'root; touch /tmp/pwned; #', +]); + +// ── Rule helpers ────────────────────────────────────────────────────────────── + +it('databaseIdentifierRules returns required by default', function () { + $rules = ValidationPatterns::databaseIdentifierRules(); + + expect($rules)->toContain('required') + ->toContain('string') + ->toContain('min:1') + ->toContain('max:63') + ->toContain('regex:'.ValidationPatterns::DB_IDENTIFIER_PATTERN); +}); + +it('databaseIdentifierRules returns nullable when not required', function () { + $rules = ValidationPatterns::databaseIdentifierRules(required: false); + + expect($rules)->toContain('nullable') + ->not->toContain('required'); +}); + +it('databasePasswordRules returns required by default', function () { + $rules = ValidationPatterns::databasePasswordRules(); + + expect($rules)->toContain('required') + ->toContain('string') + ->toContain('min:1') + ->toContain('max:128') + ->toContain('regex:'.ValidationPatterns::DB_PASSWORD_PATTERN); +}); + +it('databasePasswordRules returns nullable when not required', function () { + $rules = ValidationPatterns::databasePasswordRules(required: false); + + expect($rules)->toContain('nullable') + ->not->toContain('required'); +}); + +it('isValidDatabaseIdentifier returns true for valid identifier', function () { + expect(ValidationPatterns::isValidDatabaseIdentifier('postgres'))->toBeTrue(); + expect(ValidationPatterns::isValidDatabaseIdentifier('_admin'))->toBeTrue(); + expect(ValidationPatterns::isValidDatabaseIdentifier('DB_1'))->toBeTrue(); +}); + +it('isValidDatabaseIdentifier returns false for injection payloads', function () { + expect(ValidationPatterns::isValidDatabaseIdentifier('user; id'))->toBeFalse(); + expect(ValidationPatterns::isValidDatabaseIdentifier('user$(whoami)'))->toBeFalse(); + expect(ValidationPatterns::isValidDatabaseIdentifier(''))->toBeFalse(); +}); + +// ── Validator integration ───────────────────────────────────────────────────── + +it('Laravel Validator rejects advisory PoC postgres_user payload', function () { + $validator = Validator::make( + ['postgres_user' => 'root; touch /tmp/pwned_rce; #'], + ['postgres_user' => ValidationPatterns::databaseIdentifierRules()] + ); + + expect($validator->fails())->toBeTrue(); +}); + +it('Laravel Validator rejects subshell injection in postgres_user', function () { + $validator = Validator::make( + ['postgres_user' => 'a$(touch /tmp/pwn)b'], + ['postgres_user' => ValidationPatterns::databaseIdentifierRules()] + ); + + expect($validator->fails())->toBeTrue(); +}); + +it('Laravel Validator accepts clean postgres_user', function () { + $validator = Validator::make( + ['postgres_user' => 'postgres'], + ['postgres_user' => ValidationPatterns::databaseIdentifierRules()] + ); + + expect($validator->fails())->toBeFalse(); +}); + +it('Laravel Validator rejects shell metachar in password', function () { + $validator = Validator::make( + ['postgres_password' => 'pass$(id)word'], + ['postgres_password' => ValidationPatterns::databasePasswordRules()] + ); + + expect($validator->fails())->toBeTrue(); +}); + +it('Laravel Validator accepts safe password', function () { + $validator = Validator::make( + ['postgres_password' => 'Str0ng!P@ss#123'], + ['postgres_password' => ValidationPatterns::databasePasswordRules()] + ); + + expect($validator->fails())->toBeFalse(); +}); diff --git a/tests/Unit/DatabaseSslCredentialEscapingTest.php b/tests/Unit/DatabaseSslCredentialEscapingTest.php new file mode 100644 index 000000000..578c727da --- /dev/null +++ b/tests/Unit/DatabaseSslCredentialEscapingTest.php @@ -0,0 +1,149 @@ +toContain("'postgres':'postgres'") + ->toContain('docker exec abc123 bash -c'); +}); + +it('advisory PoC postgres_user payload is contained by escapeshellarg in chown command', function () { + // Simulates a legacy row that bypassed validation + $maliciousUser = 'root; touch /tmp/pwned_rce; #'; + $escaped = escapeshellarg($maliciousUser); + + // escapeshellarg must wrap the entire payload in single quotes + // (semicolons inside single-quoted args are NOT shell metacharacters) + expect($escaped)->toBe("'root; touch /tmp/pwned_rce; #'"); + + $cmd = executeInDocker('abc123', "chown {$escaped}:{$escaped} /var/lib/postgresql/certs/server.key"); + + // The cmd contains the payload, but ONLY inside single-quoted segments — cannot break out. + // Verify the chown arg is never an unquoted bare ; — the payload is inside '...' + // The outer executeInDocker further escapes any single-quote chars for the host shell. + expect($cmd)->toContain('docker exec abc123 bash -c'); + + // Before fix: chown root; touch /tmp/pwned_rce; # ... (breaks out of chown, executes touch) + // After fix: chown 'root; touch /tmp/pwned_rce; #':'...' ... (literal arg to chown) + // The unescaped sequence "chown root;" must NOT appear. + expect($cmd)->not->toContain('chown root;'); +}); + +it('subshell payload in mysql_user is contained by escapeshellarg in chown command', function () { + $maliciousUser = 'a$(touch /tmp/pwn)b'; + $escaped = escapeshellarg($maliciousUser); + $cmd = executeInDocker('abc123', "chown {$escaped}:{$escaped} /etc/mysql/certs/server.crt"); + + // escapeshellarg wraps in single quotes — $() is not expanded inside single quotes + expect($escaped)->toBe("'a\$(touch /tmp/pwn)b'"); + + // The cmd must not contain an unquoted $( sequence — it must be inside single quotes + // If the sequence appears at all, it must be single-quoted (the quote precedes it). + expect($cmd)->not->toContain(' $(touch'); +}); + +it('backtick payload in mysql_user is contained by escapeshellarg', function () { + $maliciousUser = 'user`id`'; + $escaped = escapeshellarg($maliciousUser); + $cmd = executeInDocker('abc123', "chown {$escaped}:{$escaped} /etc/mysql/certs/server.crt"); + + // escapeshellarg wraps the whole value in single quotes — backticks not expanded inside '' + expect($escaped)->toBe("'user`id`'"); + + // The unquoted bare backtick sequence `id` must not appear outside single-quoted context. + // Specifically, "chown user`id`" (unquoted) must not appear. + expect($cmd)->not->toContain('chown user`id`'); +}); + +// ── MongoDB JS init script JSON-escaping ────────────────────────────────────── + +it('json_encode prevents JS injection in mongo_initdb_database', function () { + $database = 'x"}); db.dropUser("admin"); //'; + $dbJson = json_encode($database, JSON_UNESCAPED_SLASHES); + + // The double-quotes in the payload MUST be escaped — they cannot close the JS string literal. + // json_encode escapes " as \" so the injected " cannot terminate the surrounding JS string. + expect($dbJson)->toContain('\\"'); + + // The resulting JSON literal, when embedded in JS, forms a valid quoted string. + // It starts and ends with the outermost " added by json_encode. + expect($dbJson)->toStartWith('"') + ->toEndWith('"'); + + // Verify the injected payload is present but neutralised (the " that would close the JS + // string is now escaped as \", preventing breakout). + expect($dbJson)->toContain('x\\"});'); +}); + +it('json_encode prevents JS injection in mongo_initdb_root_username', function () { + $username = 'admin", pwd: "", roles: [{role:"root", db:"admin"}]}); //'; + $userJson = json_encode($username, JSON_UNESCAPED_SLASHES); + + $content = 'db.createUser({user: '.$userJson.', pwd: "secret", roles: []});'; + + // The injected " that would close the JS string must be escaped as \" + expect($userJson)->toContain('\\"'); + + // The raw unescaped sequence admin" (with unescaped quote) must not appear in the JS + expect($content)->not->toContain('admin", pwd'); +}); + +it('json_encode safely encodes a clean mongo username', function () { + $username = 'mongouser'; + $userJson = json_encode($username, JSON_UNESCAPED_SLASHES); + + expect($userJson)->toBe('"mongouser"'); +}); + +it('json_encode safely encodes a mongo password with special chars', function () { + $password = 'P@ss!#word123'; + $pwdJson = json_encode($password, JSON_UNESCAPED_SLASHES); + + expect($pwdJson)->toBe('"P@ss!#word123"'); +}); + +// ── Healthcheck CMD exec-form structure (no shell parsing) ──────────────────── + +it('CMD exec-form healthcheck array does not concatenate user into a shell string', function () { + // The fix uses an array; each element is passed directly as argv — no shell parsing. + // Simulate the post-fix healthcheck array structure. + $user = "admin'; touch /tmp/pwn; #"; + $db = 'mydb'; + + $healthcheck = [ + 'CMD', + 'psql', + '-U', + $user, + '-d', + $db, + '-c', + 'SELECT 1', + ]; + + // The array form means each element is argv — no shell involved. + // The malicious user value is passed as a literal argument to psql, which rejects it. + // Key assertion: the test string is NOT collapsed into a shell command string. + expect($healthcheck[3])->toBe($user) + ->and($healthcheck[0])->toBe('CMD') + ->and(count($healthcheck))->toBe(8); + + // Sanity: if we joined with space it would be dangerous — array form avoids this. + $joinedDangerous = implode(' ', $healthcheck); + expect($joinedDangerous)->toContain('; touch /tmp/pwn'); // proof that join IS dangerous + + // The array form is what Docker Compose uses — it does NOT join with spaces + sh -c. + // Simply verifying the structure is correct proves shell is not involved. + expect($healthcheck[0])->toBe('CMD'); +});