From 03313e54cc790f3a6df6cb4fa9274c27437083e7 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Mon, 20 Apr 2026 13:58:36 +0200 Subject: [PATCH 1/2] fix(database): enforce credential format validation and sanitize init/SSL arguments Add ValidationPatterns helpers for database identifiers and passwords, apply them across database Livewire components and the API controller, encode MongoDB init script values via json_encode, and pass the MySQL user through escapeshellarg when generating SSL chown commands. --- app/Actions/Database/StartMongodb.php | 5 +- app/Actions/Database/StartMysql.php | 3 +- .../Controllers/Api/DatabasesController.php | 75 ++++---- .../Project/Database/Clickhouse/General.php | 10 +- .../Project/Database/Dragonfly/General.php | 5 +- .../Project/Database/Keydb/General.php | 5 +- .../Project/Database/Mariadb/General.php | 16 +- .../Project/Database/Mongodb/General.php | 12 +- .../Project/Database/Mysql/General.php | 16 +- .../Project/Database/Postgresql/General.php | 12 +- .../Project/Database/Redis/General.php | 8 +- app/Support/ValidationPatterns.php | 92 +++++++++ ...atabaseCredentialValidationPatternTest.php | 176 ++++++++++++++++++ .../DatabaseSslCredentialEscapingTest.php | 149 +++++++++++++++ 14 files changed, 502 insertions(+), 82 deletions(-) create mode 100644 tests/Unit/DatabaseCredentialValidationPatternTest.php create mode 100644 tests/Unit/DatabaseSslCredentialEscapingTest.php 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..3a367844a 100644 --- a/app/Livewire/Project/Database/Clickhouse/General.php +++ b/app/Livewire/Project/Database/Clickhouse/General.php @@ -76,8 +76,8 @@ protected function rules(): array return [ 'name' => ValidationPatterns::nameRules(), 'description' => ValidationPatterns::descriptionRules(), - 'clickhouseAdminUser' => 'required|string', - 'clickhouseAdminPassword' => 'required|string', + 'clickhouseAdminUser' => ValidationPatterns::databaseIdentifierRules(), + 'clickhouseAdminPassword' => ValidationPatterns::databasePasswordRules(), 'image' => 'required|string', 'portsMappings' => ValidationPatterns::portMappingRules(), 'isPublic' => 'nullable|boolean', @@ -96,10 +96,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..38f11ec21 100644 --- a/app/Livewire/Project/Database/Dragonfly/General.php +++ b/app/Livewire/Project/Database/Dragonfly/General.php @@ -89,7 +89,7 @@ protected function rules(): array return [ 'name' => ValidationPatterns::nameRules(), 'description' => ValidationPatterns::descriptionRules(), - 'dragonflyPassword' => 'required|string', + 'dragonflyPassword' => ValidationPatterns::databasePasswordRules(), 'image' => 'required|string', 'portsMappings' => ValidationPatterns::portMappingRules(), 'isPublic' => 'nullable|boolean', @@ -109,8 +109,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..1832091b3 100644 --- a/app/Livewire/Project/Database/Keydb/General.php +++ b/app/Livewire/Project/Database/Keydb/General.php @@ -92,7 +92,7 @@ protected function rules(): array 'name' => ValidationPatterns::nameRules(), 'description' => ValidationPatterns::descriptionRules(), 'keydbConf' => 'nullable|string', - 'keydbPassword' => 'required|string', + 'keydbPassword' => ValidationPatterns::databasePasswordRules(), 'image' => 'required|string', 'portsMappings' => ValidationPatterns::portMappingRules(), 'isPublic' => 'nullable|boolean', @@ -114,8 +114,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..b178dd969 100644 --- a/app/Livewire/Project/Database/Mariadb/General.php +++ b/app/Livewire/Project/Database/Mariadb/General.php @@ -74,10 +74,10 @@ protected function rules(): array return [ 'name' => ValidationPatterns::nameRules(), 'description' => ValidationPatterns::descriptionRules(), - 'mariadbRootPassword' => 'required', - 'mariadbUser' => 'required', - 'mariadbPassword' => 'required', - 'mariadbDatabase' => 'required', + 'mariadbRootPassword' => ValidationPatterns::databasePasswordRules(), + 'mariadbUser' => ValidationPatterns::databaseIdentifierRules(), + 'mariadbPassword' => ValidationPatterns::databasePasswordRules(), + 'mariadbDatabase' => ValidationPatterns::databaseIdentifierRules(), 'mariadbConf' => 'nullable', 'image' => 'required', 'portsMappings' => ValidationPatterns::portMappingRules(), @@ -97,10 +97,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..5c08b76e8 100644 --- a/app/Livewire/Project/Database/Mongodb/General.php +++ b/app/Livewire/Project/Database/Mongodb/General.php @@ -75,9 +75,9 @@ protected function rules(): array 'name' => ValidationPatterns::nameRules(), 'description' => ValidationPatterns::descriptionRules(), 'mongoConf' => 'nullable', - 'mongoInitdbRootUsername' => 'required', - 'mongoInitdbRootPassword' => 'required', - 'mongoInitdbDatabase' => 'required', + 'mongoInitdbRootUsername' => ValidationPatterns::databaseIdentifierRules(), + 'mongoInitdbRootPassword' => ValidationPatterns::databasePasswordRules(), + 'mongoInitdbDatabase' => ValidationPatterns::databaseIdentifierRules(), 'image' => 'required', 'portsMappings' => ValidationPatterns::portMappingRules(), 'isPublic' => 'nullable|boolean', @@ -97,9 +97,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..7671ec572 100644 --- a/app/Livewire/Project/Database/Mysql/General.php +++ b/app/Livewire/Project/Database/Mysql/General.php @@ -76,10 +76,10 @@ protected function rules(): array return [ 'name' => ValidationPatterns::nameRules(), 'description' => ValidationPatterns::descriptionRules(), - 'mysqlRootPassword' => 'required', - 'mysqlUser' => 'required', - 'mysqlPassword' => 'required', - 'mysqlDatabase' => 'required', + 'mysqlRootPassword' => ValidationPatterns::databasePasswordRules(), + 'mysqlUser' => ValidationPatterns::databaseIdentifierRules(), + 'mysqlPassword' => ValidationPatterns::databasePasswordRules(), + 'mysqlDatabase' => ValidationPatterns::databaseIdentifierRules(), 'mysqlConf' => 'nullable', 'image' => 'required', 'portsMappings' => ValidationPatterns::portMappingRules(), @@ -100,10 +100,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..8c23bce8e 100644 --- a/app/Livewire/Project/Database/Postgresql/General.php +++ b/app/Livewire/Project/Database/Postgresql/General.php @@ -86,9 +86,9 @@ protected function rules(): array return [ 'name' => ValidationPatterns::nameRules(), 'description' => ValidationPatterns::descriptionRules(), - 'postgresUser' => 'required', - 'postgresPassword' => 'required', - 'postgresDb' => 'required', + 'postgresUser' => ValidationPatterns::databaseIdentifierRules(), + 'postgresPassword' => ValidationPatterns::databasePasswordRules(), + 'postgresDb' => ValidationPatterns::databaseIdentifierRules(), 'postgresInitdbArgs' => 'nullable', 'postgresHostAuthMethod' => 'nullable', 'postgresConf' => 'nullable', @@ -112,9 +112,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..114c73a42 100644 --- a/app/Livewire/Project/Database/Redis/General.php +++ b/app/Livewire/Project/Database/Redis/General.php @@ -81,8 +81,8 @@ protected function rules(): array 'publicPortTimeout' => 'nullable|integer|min:1', 'isLogDrainEnabled' => 'nullable|boolean', 'customDockerRunOptions' => 'nullable', - 'redisUsername' => 'required', - 'redisPassword' => 'required', + 'redisUsername' => ValidationPatterns::databaseIdentifierRules(), + 'redisPassword' => ValidationPatterns::databasePasswordRules(), 'enableSsl' => 'boolean', ]; } @@ -100,8 +100,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..c8f4171eb 100644 --- a/app/Support/ValidationPatterns.php +++ b/app/Support/ValidationPatterns.php @@ -66,6 +66,98 @@ 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). + */ + public static function databaseIdentifierRules(bool $required = true, int $minLength = 1, int $maxLength = 63): array + { + $rules = []; + + if ($required) { + $rules[] = 'required'; + } else { + $rules[] = 'nullable'; + } + + $rules[] = 'string'; + $rules[] = "min:$minLength"; + $rules[] = "max:$maxLength"; + $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. + */ + public static function databasePasswordRules(bool $required = true, int $minLength = 1, int $maxLength = 128): array + { + $rules = []; + + if ($required) { + $rules[] = 'required'; + } else { + $rules[] = 'nullable'; + } + + $rules[] = 'string'; + $rules[] = "min:$minLength"; + $rules[] = "max:$maxLength"; + $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/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'); +}); From 40a9881ef2381f3f4db2dc004dd11b8a0dd3a6a2 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Mon, 20 Apr 2026 13:45:57 +0200 Subject: [PATCH 2/2] fix(database): skip credential pattern validation for unchanged values Pattern enforcement now conditional on field being dirty (changed vs saved value). Prevents false validation failures when existing records hold legacy credential formats that pre-date the stricter regex rules. --- .../Project/Database/Clickhouse/General.php | 8 +- .../Project/Database/Dragonfly/General.php | 4 +- .../Project/Database/Keydb/General.php | 4 +- .../Project/Database/Mariadb/General.php | 16 +++- .../Project/Database/Mongodb/General.php | 12 ++- .../Project/Database/Mysql/General.php | 16 +++- .../Project/Database/Postgresql/General.php | 12 ++- .../Project/Database/Redis/General.php | 8 +- app/Support/ValidationPatterns.php | 22 ++++- .../DatabaseCredentialDirtyValidationTest.php | 87 +++++++++++++++++++ 10 files changed, 165 insertions(+), 24 deletions(-) create mode 100644 tests/Unit/DatabaseCredentialDirtyValidationTest.php diff --git a/app/Livewire/Project/Database/Clickhouse/General.php b/app/Livewire/Project/Database/Clickhouse/General.php index 3a367844a..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' => ValidationPatterns::databaseIdentifierRules(), - 'clickhouseAdminPassword' => ValidationPatterns::databasePasswordRules(), + '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', diff --git a/app/Livewire/Project/Database/Dragonfly/General.php b/app/Livewire/Project/Database/Dragonfly/General.php index 38f11ec21..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' => ValidationPatterns::databasePasswordRules(), + 'dragonflyPassword' => ValidationPatterns::databasePasswordRules( + enforcePattern: $this->dragonflyPassword !== $this->database->dragonfly_password, + ), 'image' => 'required|string', 'portsMappings' => ValidationPatterns::portMappingRules(), 'isPublic' => 'nullable|boolean', diff --git a/app/Livewire/Project/Database/Keydb/General.php b/app/Livewire/Project/Database/Keydb/General.php index 1832091b3..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' => ValidationPatterns::databasePasswordRules(), + 'keydbPassword' => ValidationPatterns::databasePasswordRules( + enforcePattern: $this->keydbPassword !== $this->database->keydb_password, + ), 'image' => 'required|string', 'portsMappings' => ValidationPatterns::portMappingRules(), 'isPublic' => 'nullable|boolean', diff --git a/app/Livewire/Project/Database/Mariadb/General.php b/app/Livewire/Project/Database/Mariadb/General.php index b178dd969..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' => ValidationPatterns::databasePasswordRules(), - 'mariadbUser' => ValidationPatterns::databaseIdentifierRules(), - 'mariadbPassword' => ValidationPatterns::databasePasswordRules(), - 'mariadbDatabase' => ValidationPatterns::databaseIdentifierRules(), + '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(), diff --git a/app/Livewire/Project/Database/Mongodb/General.php b/app/Livewire/Project/Database/Mongodb/General.php index 5c08b76e8..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' => ValidationPatterns::databaseIdentifierRules(), - 'mongoInitdbRootPassword' => ValidationPatterns::databasePasswordRules(), - 'mongoInitdbDatabase' => ValidationPatterns::databaseIdentifierRules(), + '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', diff --git a/app/Livewire/Project/Database/Mysql/General.php b/app/Livewire/Project/Database/Mysql/General.php index 7671ec572..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' => ValidationPatterns::databasePasswordRules(), - 'mysqlUser' => ValidationPatterns::databaseIdentifierRules(), - 'mysqlPassword' => ValidationPatterns::databasePasswordRules(), - 'mysqlDatabase' => ValidationPatterns::databaseIdentifierRules(), + '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(), diff --git a/app/Livewire/Project/Database/Postgresql/General.php b/app/Livewire/Project/Database/Postgresql/General.php index 8c23bce8e..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' => ValidationPatterns::databaseIdentifierRules(), - 'postgresPassword' => ValidationPatterns::databasePasswordRules(), - 'postgresDb' => ValidationPatterns::databaseIdentifierRules(), + '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', diff --git a/app/Livewire/Project/Database/Redis/General.php b/app/Livewire/Project/Database/Redis/General.php index 114c73a42..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' => ValidationPatterns::databaseIdentifierRules(), - 'redisPassword' => ValidationPatterns::databasePasswordRules(), + 'redisUsername' => ValidationPatterns::databaseIdentifierRules( + enforcePattern: $this->redisUsername !== $this->database->redis_username, + ), + 'redisPassword' => ValidationPatterns::databasePasswordRules( + enforcePattern: $this->redisPassword !== $this->database->redis_password, + ), 'enableSsl' => 'boolean', ]; } diff --git a/app/Support/ValidationPatterns.php b/app/Support/ValidationPatterns.php index c8f4171eb..09c40a466 100644 --- a/app/Support/ValidationPatterns.php +++ b/app/Support/ValidationPatterns.php @@ -82,8 +82,12 @@ class ValidationPatterns /** * 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): array + public static function databaseIdentifierRules(bool $required = true, int $minLength = 1, int $maxLength = 63, bool $enforcePattern = true): array { $rules = []; @@ -96,7 +100,10 @@ public static function databaseIdentifierRules(bool $required = true, int $minLe $rules[] = 'string'; $rules[] = "min:$minLength"; $rules[] = "max:$maxLength"; - $rules[] = 'regex:'.self::DB_IDENTIFIER_PATTERN; + + if ($enforcePattern) { + $rules[] = 'regex:'.self::DB_IDENTIFIER_PATTERN; + } return $rules; } @@ -117,8 +124,12 @@ public static function databaseIdentifierMessages(string $field, string $label = /** * 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): array + public static function databasePasswordRules(bool $required = true, int $minLength = 1, int $maxLength = 128, bool $enforcePattern = true): array { $rules = []; @@ -131,7 +142,10 @@ public static function databasePasswordRules(bool $required = true, int $minLeng $rules[] = 'string'; $rules[] = "min:$minLength"; $rules[] = "max:$maxLength"; - $rules[] = 'regex:'.self::DB_PASSWORD_PATTERN; + + if ($enforcePattern) { + $rules[] = 'regex:'.self::DB_PASSWORD_PATTERN; + } return $rules; } 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(); +});