fix(database): credential format validation with dirty-value escape hatch (#9676)
This commit is contained in:
commit
bff6d85370
15 changed files with 643 additions and 82 deletions
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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.'";
|
||||
|
|
|
|||
|
|
@ -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)) {
|
||||
|
|
|
|||
|
|
@ -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.',
|
||||
|
|
|
|||
|
|
@ -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.',
|
||||
|
|
|
|||
|
|
@ -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.',
|
||||
|
|
|
|||
|
|
@ -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.',
|
||||
|
|
|
|||
|
|
@ -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.',
|
||||
|
|
|
|||
|
|
@ -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.',
|
||||
|
|
|
|||
|
|
@ -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.',
|
||||
|
|
|
|||
|
|
@ -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'),
|
||||
]
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
*/
|
||||
|
|
|
|||
87
tests/Unit/DatabaseCredentialDirtyValidationTest.php
Normal file
87
tests/Unit/DatabaseCredentialDirtyValidationTest.php
Normal file
|
|
@ -0,0 +1,87 @@
|
|||
<?php
|
||||
|
||||
use App\Support\ValidationPatterns;
|
||||
|
||||
// ── databasePasswordRules ─────────────────────────────────────────────────────
|
||||
|
||||
it('databasePasswordRules includes regex rule by default', function () {
|
||||
$rules = ValidationPatterns::databasePasswordRules();
|
||||
|
||||
$regexRules = array_filter($rules, fn ($rule) => 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();
|
||||
});
|
||||
176
tests/Unit/DatabaseCredentialValidationPatternTest.php
Normal file
176
tests/Unit/DatabaseCredentialValidationPatternTest.php
Normal file
|
|
@ -0,0 +1,176 @@
|
|||
<?php
|
||||
|
||||
use App\Support\ValidationPatterns;
|
||||
use Illuminate\Support\Facades\Validator;
|
||||
|
||||
// ── DB_IDENTIFIER_PATTERN ─────────────────────────────────────────────────────
|
||||
|
||||
it('DB_IDENTIFIER_PATTERN accepts valid SQL identifiers', function (string $id) {
|
||||
expect(preg_match(ValidationPatterns::DB_IDENTIFIER_PATTERN, $id))->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<name',
|
||||
'greater than' => '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<word',
|
||||
'greater than' => '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();
|
||||
});
|
||||
149
tests/Unit/DatabaseSslCredentialEscapingTest.php
Normal file
149
tests/Unit/DatabaseSslCredentialEscapingTest.php
Normal file
|
|
@ -0,0 +1,149 @@
|
|||
<?php
|
||||
|
||||
/**
|
||||
* GHSA-rcch-8c74-7f29 — Sink-side escaping tests
|
||||
*
|
||||
* Verifies that credentials reaching shell commands are properly escaped
|
||||
* even if validation is bypassed (e.g. legacy rows, direct DB writes).
|
||||
*/
|
||||
|
||||
// ── executeInDocker + escapeshellarg chown pattern ────────────────────────────
|
||||
|
||||
it('escapeshellarg wraps postgres_user in single quotes for chown command', function () {
|
||||
$user = 'postgres';
|
||||
$escaped = escapeshellarg($user);
|
||||
$cmd = executeInDocker('abc123', "chown {$escaped}:{$escaped} /var/lib/postgresql/certs/server.key");
|
||||
|
||||
expect($cmd)->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');
|
||||
});
|
||||
Loading…
Reference in a new issue