From 2e544c44542d4c2c93d64d8492b9ab0426b6d3b2 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 19 Jan 2026 10:26:33 +0000 Subject: [PATCH 1/6] Initial plan From 347182e378704123c0ddc3feefb82deb9d4c1111 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 19 Jan 2026 13:15:57 +0000 Subject: [PATCH 2/6] Add wp db users create command implementation Co-authored-by: swissspidy <841956+swissspidy@users.noreply.github.com> --- composer.json | 4 +- db-command.php | 1 + features/db-users.feature | 79 +++++++++++++ phpcs.xml.dist | 1 + src/DB_Users_Command.php | 228 ++++++++++++++++++++++++++++++++++++++ 5 files changed, 312 insertions(+), 1 deletion(-) create mode 100644 features/db-users.feature create mode 100644 src/DB_Users_Command.php diff --git a/composer.json b/composer.json index e575f01f..8d78d852 100644 --- a/composer.json +++ b/composer.json @@ -50,7 +50,9 @@ "db search", "db tables", "db size", - "db columns" + "db columns", + "db users", + "db users create" ] }, "autoload": { diff --git a/db-command.php b/db-command.php index d08f55bb..3af9a7ec 100644 --- a/db-command.php +++ b/db-command.php @@ -10,3 +10,4 @@ } WP_CLI::add_command( 'db', 'DB_Command' ); +WP_CLI::add_command( 'db users', 'DB_Users_Command' ); diff --git a/features/db-users.feature b/features/db-users.feature new file mode 100644 index 00000000..06e8d917 --- /dev/null +++ b/features/db-users.feature @@ -0,0 +1,79 @@ +Feature: Manage database users + + Scenario: Create database user without privileges + Given an empty directory + And WP files + And wp-config.php + + When I run `wp db create` + Then STDOUT should be: + """ + Success: Database created. + """ + + When I run `wp db users create testuser localhost --password=testpass123` + Then STDOUT should contain: + """ + Success: Database user 'testuser'@'localhost' created. + """ + + When I run `wp db query "SELECT User, Host FROM mysql.user WHERE User='testuser'"` + Then STDOUT should contain: + """ + testuser + """ + + Scenario: Create database user with privileges + Given an empty directory + And WP files + And wp-config.php + + When I run `wp db create` + Then STDOUT should be: + """ + Success: Database created. + """ + + When I run `wp db users create appuser localhost --password=secret123 --grant-privileges` + Then STDOUT should contain: + """ + created with privileges on database + """ + And STDOUT should contain: + """ + appuser + """ + + Scenario: Create database user with custom host + Given an empty directory + And WP files + And wp-config.php + + When I run `wp db create` + Then STDOUT should be: + """ + Success: Database created. + """ + + When I run `wp db users create remoteuser '%' --password=remote123` + Then STDOUT should contain: + """ + Success: Database user 'remoteuser'@'%' created. + """ + + Scenario: Create database user with no password + Given an empty directory + And WP files + And wp-config.php + + When I run `wp db create` + Then STDOUT should be: + """ + Success: Database created. + """ + + When I run `wp db users create nopassuser localhost` + Then STDOUT should contain: + """ + Success: Database user 'nopassuser'@'localhost' created. + """ diff --git a/phpcs.xml.dist b/phpcs.xml.dist index c39cec40..6ab493d8 100644 --- a/phpcs.xml.dist +++ b/phpcs.xml.dist @@ -54,6 +54,7 @@ */src/DB_Command\.php$ + */src/DB_Users_Command\.php$ /tests/phpstan/scan-files diff --git a/src/DB_Users_Command.php b/src/DB_Users_Command.php new file mode 100644 index 00000000..fb158223 --- /dev/null +++ b/src/DB_Users_Command.php @@ -0,0 +1,228 @@ + + * : MySQL username for the new user account. + * + * [] + * : MySQL host for the new user account. + * --- + * default: localhost + * --- + * + * [--password=] + * : Password for the new user account. If not provided, MySQL will use no password. + * + * [--grant-privileges] + * : Grant full privileges on the current database to the new user. + * + * [--dbuser=] + * : Username to connect as (privileged user). Defaults to DB_USER. + * + * [--dbpass=] + * : Password to connect with (privileged user). Defaults to DB_PASSWORD. + * + * [--defaults] + * : Loads the environment's MySQL option files. Default behavior is to skip loading them to avoid failures due to misconfiguration. + * + * ## EXAMPLES + * + * # Create a user without privileges. + * $ wp db users create myuser localhost --password=mypass + * Success: Database user 'myuser'@'localhost' created. + * + * # Create a user with full privileges on the current database. + * $ wp db users create appuser localhost --password=secret123 --grant-privileges + * Success: Database user 'appuser'@'localhost' created with privileges on database 'wp_database'. + */ + public function create( $args, $assoc_args ) { + list( $username, $host ) = array_pad( $args, 2, 'localhost' ); + + $password = Utils\get_flag_value( $assoc_args, 'password', '' ); + $grant_privileges = Utils\get_flag_value( $assoc_args, 'grant-privileges', false ); + + // Escape identifiers for SQL + $username_escaped = $this->esc_sql_ident( $username ); + $host_escaped = $this->esc_sql_ident( $host ); + $user_identifier = "{$username_escaped}@{$host_escaped}"; + + // Create user + $create_query = "CREATE USER {$user_identifier}"; + if ( ! empty( $password ) ) { + $password_escaped = $this->esc_sql_string( $password ); + $create_query .= " IDENTIFIED BY {$password_escaped}"; + } + $create_query .= ';'; + + $this->run_query( $create_query, $assoc_args ); + + // Grant privileges if requested + if ( $grant_privileges ) { + $database = DB_NAME; + $database_escaped = $this->esc_sql_ident( $database ); + $grant_query = "GRANT ALL PRIVILEGES ON {$database_escaped}.* TO {$user_identifier};"; + $this->run_query( $grant_query, $assoc_args ); + + // Flush privileges + $this->run_query( 'FLUSH PRIVILEGES;', $assoc_args ); + + WP_CLI::success( "Database user '{$username}'@'{$host}' created with privileges on database '{$database}'." ); + } else { + WP_CLI::success( "Database user '{$username}'@'{$host}' created." ); + } + } + + /** + * Run a single query via the 'mysql' binary. + * + * @param string $query Query to execute. + * @param array $assoc_args Optional. Associative array of arguments. + */ + private function run_query( $query, $assoc_args = [] ) { + WP_CLI::debug( "Query: {$query}", 'db' ); + + $mysql_args = array_merge( + $this->get_dbuser_dbpass_args( $assoc_args ), + $this->get_mysql_args( $assoc_args ) + ); + + $this->run( + sprintf( + 'mysql%s --no-auto-rehash', + $this->get_defaults_flag_string( $assoc_args ) + ), + array_merge( [ 'execute' => $query ], $mysql_args ) + ); + } + + /** + * Run a MySQL command. + * + * @param string $cmd Command to run. + * @param array $assoc_args Optional. Associative array of arguments to use. + * + * @return array { + * Associative array containing STDOUT and STDERR output. + * + * @type string $stdout Output that was sent to STDOUT. + * @type string $stderr Output that was sent to STDERR. + * @type int $exit_code Exit code of the process. + * } + */ + private function run( $cmd, $assoc_args = [] ) { + $required = [ + 'host' => DB_HOST, + 'user' => DB_USER, + 'pass' => DB_PASSWORD, + ]; + + if ( ! isset( $assoc_args['default-character-set'] ) + && defined( 'DB_CHARSET' ) && constant( 'DB_CHARSET' ) ) { + $required['default-character-set'] = constant( 'DB_CHARSET' ); + } + + // Using 'dbuser' as option name to workaround clash with WP-CLI's global WP 'user' parameter. + if ( isset( $assoc_args['dbuser'] ) ) { + $required['user'] = $assoc_args['dbuser']; + unset( $assoc_args['dbuser'] ); + } + if ( isset( $assoc_args['dbpass'] ) ) { + $required['pass'] = $assoc_args['dbpass']; + unset( $assoc_args['dbpass'], $assoc_args['password'] ); + } + + $final_args = array_merge( $required, $assoc_args ); + + return Utils\run_mysql_command( $cmd, $final_args, null, true, false ); + } + + /** + * Helper to pluck 'dbuser' and 'dbpass' from associative args array. + * + * @param array $assoc_args Associative args array. + * @return array Array with 'dbuser' and 'dbpass' set if in passed-in associative args array. + */ + private function get_dbuser_dbpass_args( $assoc_args ) { + $mysql_args = []; + $dbuser = Utils\get_flag_value( $assoc_args, 'dbuser' ); + if ( null !== $dbuser ) { + $mysql_args['dbuser'] = $dbuser; + } + $dbpass = Utils\get_flag_value( $assoc_args, 'dbpass' ); + if ( null !== $dbpass ) { + $mysql_args['dbpass'] = $dbpass; + } + return $mysql_args; + } + + /** + * Gets the MySQL args from the associative args array. + * + * @param array $assoc_args Associative args array. + * @return array MySQL args. + */ + private function get_mysql_args( $assoc_args ) { + $mysql_args = []; + + if ( isset( $assoc_args['host'] ) ) { + $mysql_args['host'] = $assoc_args['host']; + } + + return $mysql_args; + } + + /** + * Gets the defaults flag string. + * + * @param array $assoc_args Associative args array. + * @return string Defaults flag string. + */ + private function get_defaults_flag_string( $assoc_args ) { + $defaults = Utils\get_flag_value( $assoc_args, 'defaults', false ); + return $defaults ? '' : ' --no-defaults'; + } + + /** + * Escapes a string for use in a SQL query. + * + * @param string $value String to escape. + * @return string Escaped string. + */ + private function esc_sql_string( $value ) { + // Use single quotes and escape single quotes by doubling them. + return "'" . str_replace( "'", "''", $value ) . "'"; + } + + /** + * Escapes (backticks) MySQL identifiers (aka schema object names). + * + * @param string $ident A single identifier. + * @return string An escaped string. + */ + private function esc_sql_ident( $ident ) { + // Escape any backticks in the identifier by doubling. + return '`' . str_replace( '`', '``', $ident ) . '`'; + } +} From 891f7139ece4eda192c03c08259945772465b9c2 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 19 Jan 2026 13:36:22 +0000 Subject: [PATCH 3/6] Refactor DB_Users_Command to extend DB_Command and improve security Co-authored-by: swissspidy <841956+swissspidy@users.noreply.github.com> --- src/DB_Users_Command.php | 121 +++++---------------------------------- test-output.txt | 45 +++++++++++++++ 2 files changed, 58 insertions(+), 108 deletions(-) create mode 100644 test-output.txt diff --git a/src/DB_Users_Command.php b/src/DB_Users_Command.php index fb158223..2d979fb5 100644 --- a/src/DB_Users_Command.php +++ b/src/DB_Users_Command.php @@ -13,7 +13,7 @@ * * @when after_wp_config_load */ -class DB_Users_Command extends WP_CLI_Command { +class DB_Users_Command extends DB_Command { /** * Creates a new database user with optional privileges. @@ -76,17 +76,17 @@ public function create( $args, $assoc_args ) { } $create_query .= ';'; - $this->run_query( $create_query, $assoc_args ); + $this->run_user_query( $create_query, $assoc_args ); // Grant privileges if requested if ( $grant_privileges ) { $database = DB_NAME; $database_escaped = $this->esc_sql_ident( $database ); $grant_query = "GRANT ALL PRIVILEGES ON {$database_escaped}.* TO {$user_identifier};"; - $this->run_query( $grant_query, $assoc_args ); + $this->run_user_query( $grant_query, $assoc_args ); // Flush privileges - $this->run_query( 'FLUSH PRIVILEGES;', $assoc_args ); + $this->run_user_query( 'FLUSH PRIVILEGES;', $assoc_args ); WP_CLI::success( "Database user '{$username}'@'{$host}' created with privileges on database '{$database}'." ); } else { @@ -97,111 +97,14 @@ public function create( $args, $assoc_args ) { /** * Run a single query via the 'mysql' binary. * + * This method adds SQL mode compatibility and delegates to the parent class. + * * @param string $query Query to execute. * @param array $assoc_args Optional. Associative array of arguments. */ - private function run_query( $query, $assoc_args = [] ) { - WP_CLI::debug( "Query: {$query}", 'db' ); - - $mysql_args = array_merge( - $this->get_dbuser_dbpass_args( $assoc_args ), - $this->get_mysql_args( $assoc_args ) - ); - - $this->run( - sprintf( - 'mysql%s --no-auto-rehash', - $this->get_defaults_flag_string( $assoc_args ) - ), - array_merge( [ 'execute' => $query ], $mysql_args ) - ); - } - - /** - * Run a MySQL command. - * - * @param string $cmd Command to run. - * @param array $assoc_args Optional. Associative array of arguments to use. - * - * @return array { - * Associative array containing STDOUT and STDERR output. - * - * @type string $stdout Output that was sent to STDOUT. - * @type string $stderr Output that was sent to STDERR. - * @type int $exit_code Exit code of the process. - * } - */ - private function run( $cmd, $assoc_args = [] ) { - $required = [ - 'host' => DB_HOST, - 'user' => DB_USER, - 'pass' => DB_PASSWORD, - ]; - - if ( ! isset( $assoc_args['default-character-set'] ) - && defined( 'DB_CHARSET' ) && constant( 'DB_CHARSET' ) ) { - $required['default-character-set'] = constant( 'DB_CHARSET' ); - } - - // Using 'dbuser' as option name to workaround clash with WP-CLI's global WP 'user' parameter. - if ( isset( $assoc_args['dbuser'] ) ) { - $required['user'] = $assoc_args['dbuser']; - unset( $assoc_args['dbuser'] ); - } - if ( isset( $assoc_args['dbpass'] ) ) { - $required['pass'] = $assoc_args['dbpass']; - unset( $assoc_args['dbpass'], $assoc_args['password'] ); - } - - $final_args = array_merge( $required, $assoc_args ); - - return Utils\run_mysql_command( $cmd, $final_args, null, true, false ); - } - - /** - * Helper to pluck 'dbuser' and 'dbpass' from associative args array. - * - * @param array $assoc_args Associative args array. - * @return array Array with 'dbuser' and 'dbpass' set if in passed-in associative args array. - */ - private function get_dbuser_dbpass_args( $assoc_args ) { - $mysql_args = []; - $dbuser = Utils\get_flag_value( $assoc_args, 'dbuser' ); - if ( null !== $dbuser ) { - $mysql_args['dbuser'] = $dbuser; - } - $dbpass = Utils\get_flag_value( $assoc_args, 'dbpass' ); - if ( null !== $dbpass ) { - $mysql_args['dbpass'] = $dbpass; - } - return $mysql_args; - } - - /** - * Gets the MySQL args from the associative args array. - * - * @param array $assoc_args Associative args array. - * @return array MySQL args. - */ - private function get_mysql_args( $assoc_args ) { - $mysql_args = []; - - if ( isset( $assoc_args['host'] ) ) { - $mysql_args['host'] = $assoc_args['host']; - } - - return $mysql_args; - } - - /** - * Gets the defaults flag string. - * - * @param array $assoc_args Associative args array. - * @return string Defaults flag string. - */ - private function get_defaults_flag_string( $assoc_args ) { - $defaults = Utils\get_flag_value( $assoc_args, 'defaults', false ); - return $defaults ? '' : ' --no-defaults'; + private function run_user_query( $query, $assoc_args = [] ) { + // Use parent class's run_query method which handles SQL mode compatibility. + parent::run_query( $query, $assoc_args ); } /** @@ -211,8 +114,10 @@ private function get_defaults_flag_string( $assoc_args ) { * @return string Escaped string. */ private function esc_sql_string( $value ) { - // Use single quotes and escape single quotes by doubling them. - return "'" . str_replace( "'", "''", $value ) . "'"; + // Escape backslashes first, then single quotes. + $value = str_replace( '\\', '\\\\', $value ); + $value = str_replace( "'", "''", $value ); + return "'" . $value . "'"; } /** diff --git a/test-output.txt b/test-output.txt new file mode 100644 index 00000000..cef192a4 --- /dev/null +++ b/test-output.txt @@ -0,0 +1,45 @@ +WP_CLI_TEST_DBTYPE is set to 'sqlite', skipping database check. +.F-------.F------.F-----.F----- + +--- Failed steps: + +001 Scenario: Create database user without privileges # features/db-users.feature:3 + And WP files # features/db-users.feature:5 + $ curl -sSfL 'https://downloads.wordpress.org/plugin/sqlite-database-integration.zip' > '/tmp/wp-cli-test-sqlite-integration-cache/sqlite-database-integration.zip' + + curl: (6) Could not resolve host: downloads.wordpress.org + cwd: + run time: 0.0067920684814453 + exit status: 6 (RuntimeException) + +002 Scenario: Create database user with privileges # features/db-users.feature:26 + And WP files # features/db-users.feature:28 + $ cp -r '/tmp/wp-cli-test-core-download-cache'/* '/tmp/wp-cli-test-run--696e307f102bc5.10488262/' + + cp: cannot stat '/tmp/wp-cli-test-core-download-cache/*': No such file or directory + cwd: + run time: 0.0016000270843506 + exit status: 1 (RuntimeException) + +003 Scenario: Create database user with custom host # features/db-users.feature:47 + And WP files # features/db-users.feature:49 + $ cp -r '/tmp/wp-cli-test-core-download-cache'/* '/tmp/wp-cli-test-run--696e307f10e798.58842661/' + + cp: cannot stat '/tmp/wp-cli-test-core-download-cache/*': No such file or directory + cwd: + run time: 0.001478910446167 + exit status: 1 (RuntimeException) + +004 Scenario: Create database user with no password # features/db-users.feature:64 + And WP files # features/db-users.feature:66 + $ cp -r '/tmp/wp-cli-test-core-download-cache'/* '/tmp/wp-cli-test-run--696e307f118931.57377979/' + + cp: cannot stat '/tmp/wp-cli-test-core-download-cache/*': No such file or directory + cwd: + run time: 0.0014328956604004 + exit status: 1 (RuntimeException) + +4 scenarios (4 failed) +31 steps (4 passed, 4 failed, 23 skipped) +0m0.21s (12.58Mb) +Script run-behat-tests handling the behat event returned with error code 1 From 7f2d7d9b210fbf6b127d74d7bf8ea6d46dac2605 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 19 Jan 2026 13:43:43 +0000 Subject: [PATCH 4/6] Simplify code by calling parent::run_query directly and remove test-output.txt Co-authored-by: swissspidy <841956+swissspidy@users.noreply.github.com> --- src/DB_Users_Command.php | 22 ++++++-------------- test-output.txt | 45 ---------------------------------------- 2 files changed, 6 insertions(+), 61 deletions(-) delete mode 100644 test-output.txt diff --git a/src/DB_Users_Command.php b/src/DB_Users_Command.php index 2d979fb5..f6c6737c 100644 --- a/src/DB_Users_Command.php +++ b/src/DB_Users_Command.php @@ -76,17 +76,17 @@ public function create( $args, $assoc_args ) { } $create_query .= ';'; - $this->run_user_query( $create_query, $assoc_args ); + parent::run_query( $create_query, $assoc_args ); // Grant privileges if requested if ( $grant_privileges ) { $database = DB_NAME; $database_escaped = $this->esc_sql_ident( $database ); $grant_query = "GRANT ALL PRIVILEGES ON {$database_escaped}.* TO {$user_identifier};"; - $this->run_user_query( $grant_query, $assoc_args ); + parent::run_query( $grant_query, $assoc_args ); // Flush privileges - $this->run_user_query( 'FLUSH PRIVILEGES;', $assoc_args ); + parent::run_query( 'FLUSH PRIVILEGES;', $assoc_args ); WP_CLI::success( "Database user '{$username}'@'{$host}' created with privileges on database '{$database}'." ); } else { @@ -94,19 +94,6 @@ public function create( $args, $assoc_args ) { } } - /** - * Run a single query via the 'mysql' binary. - * - * This method adds SQL mode compatibility and delegates to the parent class. - * - * @param string $query Query to execute. - * @param array $assoc_args Optional. Associative array of arguments. - */ - private function run_user_query( $query, $assoc_args = [] ) { - // Use parent class's run_query method which handles SQL mode compatibility. - parent::run_query( $query, $assoc_args ); - } - /** * Escapes a string for use in a SQL query. * @@ -123,6 +110,9 @@ private function esc_sql_string( $value ) { /** * Escapes (backticks) MySQL identifiers (aka schema object names). * + * Note: This duplicates functionality from parent DB_Command::esc_sql_ident() + * which is private static and cannot be accessed from child classes. + * * @param string $ident A single identifier. * @return string An escaped string. */ diff --git a/test-output.txt b/test-output.txt deleted file mode 100644 index cef192a4..00000000 --- a/test-output.txt +++ /dev/null @@ -1,45 +0,0 @@ -WP_CLI_TEST_DBTYPE is set to 'sqlite', skipping database check. -.F-------.F------.F-----.F----- - ---- Failed steps: - -001 Scenario: Create database user without privileges # features/db-users.feature:3 - And WP files # features/db-users.feature:5 - $ curl -sSfL 'https://downloads.wordpress.org/plugin/sqlite-database-integration.zip' > '/tmp/wp-cli-test-sqlite-integration-cache/sqlite-database-integration.zip' - - curl: (6) Could not resolve host: downloads.wordpress.org - cwd: - run time: 0.0067920684814453 - exit status: 6 (RuntimeException) - -002 Scenario: Create database user with privileges # features/db-users.feature:26 - And WP files # features/db-users.feature:28 - $ cp -r '/tmp/wp-cli-test-core-download-cache'/* '/tmp/wp-cli-test-run--696e307f102bc5.10488262/' - - cp: cannot stat '/tmp/wp-cli-test-core-download-cache/*': No such file or directory - cwd: - run time: 0.0016000270843506 - exit status: 1 (RuntimeException) - -003 Scenario: Create database user with custom host # features/db-users.feature:47 - And WP files # features/db-users.feature:49 - $ cp -r '/tmp/wp-cli-test-core-download-cache'/* '/tmp/wp-cli-test-run--696e307f10e798.58842661/' - - cp: cannot stat '/tmp/wp-cli-test-core-download-cache/*': No such file or directory - cwd: - run time: 0.001478910446167 - exit status: 1 (RuntimeException) - -004 Scenario: Create database user with no password # features/db-users.feature:64 - And WP files # features/db-users.feature:66 - $ cp -r '/tmp/wp-cli-test-core-download-cache'/* '/tmp/wp-cli-test-run--696e307f118931.57377979/' - - cp: cannot stat '/tmp/wp-cli-test-core-download-cache/*': No such file or directory - cwd: - run time: 0.0014328956604004 - exit status: 1 (RuntimeException) - -4 scenarios (4 failed) -31 steps (4 passed, 4 failed, 23 skipped) -0m0.21s (12.58Mb) -Script run-behat-tests handling the behat event returned with error code 1 From 84b425ff7caca7a1ade1fd56fbb67daa0758a38c Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 19 Jan 2026 15:15:02 +0000 Subject: [PATCH 5/6] Make esc_sql_ident protected in parent class and call it statically Co-authored-by: swissspidy <841956+swissspidy@users.noreply.github.com> --- src/DB_Command.php | 2 +- src/DB_Users_Command.php | 27 ++++++++------------------- 2 files changed, 9 insertions(+), 20 deletions(-) diff --git a/src/DB_Command.php b/src/DB_Command.php index 06781cee..25992fd3 100644 --- a/src/DB_Command.php +++ b/src/DB_Command.php @@ -1923,7 +1923,7 @@ private static function is_text_col( $type ) { * * @phpstan-return ($idents is string ? string : array) */ - private static function esc_sql_ident( $idents ) { + protected static function esc_sql_ident( $idents ) { $backtick = static function ( $v ) { // Escape any backticks in the identifier by doubling. return '`' . str_replace( '`', '``', $v ) . '`'; diff --git a/src/DB_Users_Command.php b/src/DB_Users_Command.php index f6c6737c..7699fc76 100644 --- a/src/DB_Users_Command.php +++ b/src/DB_Users_Command.php @@ -64,9 +64,11 @@ public function create( $args, $assoc_args ) { $grant_privileges = Utils\get_flag_value( $assoc_args, 'grant-privileges', false ); // Escape identifiers for SQL - $username_escaped = $this->esc_sql_ident( $username ); - $host_escaped = $this->esc_sql_ident( $host ); - $user_identifier = "{$username_escaped}@{$host_escaped}"; + $username_escaped = self::esc_sql_ident( $username ); + $host_escaped = self::esc_sql_ident( $host ); + assert( is_string( $username_escaped ) ); + assert( is_string( $host_escaped ) ); + $user_identifier = "{$username_escaped}@{$host_escaped}"; // Create user $create_query = "CREATE USER {$user_identifier}"; @@ -81,8 +83,9 @@ public function create( $args, $assoc_args ) { // Grant privileges if requested if ( $grant_privileges ) { $database = DB_NAME; - $database_escaped = $this->esc_sql_ident( $database ); - $grant_query = "GRANT ALL PRIVILEGES ON {$database_escaped}.* TO {$user_identifier};"; + $database_escaped = self::esc_sql_ident( $database ); + assert( is_string( $database_escaped ) ); + $grant_query = "GRANT ALL PRIVILEGES ON {$database_escaped}.* TO {$user_identifier};"; parent::run_query( $grant_query, $assoc_args ); // Flush privileges @@ -106,18 +109,4 @@ private function esc_sql_string( $value ) { $value = str_replace( "'", "''", $value ); return "'" . $value . "'"; } - - /** - * Escapes (backticks) MySQL identifiers (aka schema object names). - * - * Note: This duplicates functionality from parent DB_Command::esc_sql_ident() - * which is private static and cannot be accessed from child classes. - * - * @param string $ident A single identifier. - * @return string An escaped string. - */ - private function esc_sql_ident( $ident ) { - // Escape any backticks in the identifier by doubling. - return '`' . str_replace( '`', '``', $ident ) . '`'; - } } From 421a9a4ef08a7dc76f4777625de6734953faab55 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 19 Jan 2026 16:24:24 +0000 Subject: [PATCH 6/6] Remove assert calls and improve SQL string escaping for security Co-authored-by: swissspidy <841956+swissspidy@users.noreply.github.com> --- src/DB_Users_Command.php | 20 +++++++++++++------- 1 file changed, 13 insertions(+), 7 deletions(-) diff --git a/src/DB_Users_Command.php b/src/DB_Users_Command.php index 7699fc76..f5fad023 100644 --- a/src/DB_Users_Command.php +++ b/src/DB_Users_Command.php @@ -66,8 +66,8 @@ public function create( $args, $assoc_args ) { // Escape identifiers for SQL $username_escaped = self::esc_sql_ident( $username ); $host_escaped = self::esc_sql_ident( $host ); - assert( is_string( $username_escaped ) ); - assert( is_string( $host_escaped ) ); + /** @var string $username_escaped */ + /** @var string $host_escaped */ $user_identifier = "{$username_escaped}@{$host_escaped}"; // Create user @@ -84,7 +84,7 @@ public function create( $args, $assoc_args ) { if ( $grant_privileges ) { $database = DB_NAME; $database_escaped = self::esc_sql_ident( $database ); - assert( is_string( $database_escaped ) ); + /** @var string $database_escaped */ $grant_query = "GRANT ALL PRIVILEGES ON {$database_escaped}.* TO {$user_identifier};"; parent::run_query( $grant_query, $assoc_args ); @@ -100,13 +100,19 @@ public function create( $args, $assoc_args ) { /** * Escapes a string for use in a SQL query. * + * Follows MySQL's documented string literal escaping rules. + * See https://dev.mysql.com/doc/refman/en/string-literals.html + * * @param string $value String to escape. - * @return string Escaped string. + * @return string Escaped string, wrapped in single quotes. */ private function esc_sql_string( $value ) { - // Escape backslashes first, then single quotes. - $value = str_replace( '\\', '\\\\', $value ); - $value = str_replace( "'", "''", $value ); + // Escape special characters according to MySQL string literal rules. + $value = str_replace( + [ '\\', "\x00", "\n", "\r", "'", '"', "\x1a" ], + [ '\\\\', "\\0", "\\n", "\\r", "\\'", '\\"', '\\Z' ], + $value + ); return "'" . $value . "'"; } }