From eed8f8aa232ef0ba28c2f9fd8fe43b1998aa46fb Mon Sep 17 00:00:00 2001 From: Riddhesh Sanghvi Date: Mon, 12 Oct 2026 07:17:23 +0530 Subject: [PATCH 1/6] fix(site): keep a site's database grant within the 64-character name limit Escaping every `_`, `%` and `\` in the grant's database name can make it longer than 64 characters. MariaDB 10.11 then stores the name cut short, so the grant no longer covers the site's database, and MariaDB 12 rejects the GRANT. Escape as many pattern characters as fit and let `_` stand for each remaining one, which still matches it. The quoting moves to sql_quote_grant_database() so the upgrade migration can use the same grant name. --- src/helper/site-utils.php | 29 ++++++++++++++++++++++++++--- 1 file changed, 26 insertions(+), 3 deletions(-) diff --git a/src/helper/site-utils.php b/src/helper/site-utils.php index 3515b11c..eed0a4a4 100644 --- a/src/helper/site-utils.php +++ b/src/helper/site-utils.php @@ -177,6 +177,31 @@ function sql_quote_identifier( $value ) { return '`' . str_replace( '`', '``', $value ) . '`'; } +/** + * Quotes a database name for a database-level GRANT or REVOKE. + * + * `_`, `%` and `\` are pattern characters there, even inside backticks: unescaped, `a_b` would also match `axb`. + * The name may not exceed 64 characters, so when there is no room to escape them all, each one left over becomes `_`, which still matches it. + * + * @param string $db_name Database name. + * + * @return string + */ +function sql_quote_grant_database( $db_name ) { + + $length = preg_match_all( '/./su', $db_name ); + $spare = 64 - ( false === $length ? strlen( $db_name ) : $length ); + $pattern = preg_replace_callback( + '/[\\\\_%]/', + function ( $match ) use ( &$spare ) { + return $spare-- > 0 ? '\\' . $match[0] : '_'; + }, + $db_name + ); + + return sql_quote_identifier( $pattern ); +} + /** * Runs SQL as root on the global database. * @@ -352,9 +377,7 @@ function create_user_in_db( $db_host, $db_name = '', $db_user = '', $db_pass = ' return false; } - // `_` and `%` are wildcards in a GRANT's database name: unescaped, `a_b` would also grant on `axb`. - $grant_on = sql_quote_identifier( addcslashes( $db_name, '\\_%' ) ); - if ( ! $ok( sprintf( 'GRANT ALL PRIVILEGES ON %s.* TO %s; FLUSH PRIVILEGES;', $grant_on, $user ) ) ) { + if ( ! $ok( sprintf( 'GRANT ALL PRIVILEGES ON %s.* TO %s; FLUSH PRIVILEGES;', sql_quote_grant_database( $db_name ), $user ) ) ) { // Separate calls: a batch stops at its first error. $ok( sprintf( 'DROP DATABASE %s;', $database ) ); $ok( sprintf( 'DROP USER %s;', $user ) ); From ed275d71e669b2a5d490cc2ecefa2cea936eb535 Mon Sep 17 00:00:00 2001 From: Riddhesh Sanghvi Date: Mon, 12 Oct 2026 07:32:54 +0530 Subject: [PATCH 2/6] fix(site): restrict existing sites' database grants to their own database Sites on the global database created before the grant escaped `_` and `%` still hold a grant whose database name is a pattern, and a long escaped name may have been stored cut to 64 characters. A container migration adds the current grant for each such site, then revokes the old one only if it exists, so it is safe to run again. Local and remote database sites are skipped. A site whose grant can't be read or changed gets a warning with the SQL to run, and the upgrade continues. down() keeps the new grant. --- ...site-command_restrict_global_db_grants.php | 223 ++++++++++++++++++ 1 file changed, 223 insertions(+) create mode 100644 migrations/container/20260930120000_site-command_restrict_global_db_grants.php diff --git a/migrations/container/20260930120000_site-command_restrict_global_db_grants.php b/migrations/container/20260930120000_site-command_restrict_global_db_grants.php new file mode 100644 index 00000000..06b0b25f --- /dev/null +++ b/migrations/container/20260930120000_site-command_restrict_global_db_grants.php @@ -0,0 +1,223 @@ +is_first_execution ) { + EE::debug( 'Skipping restrict-global-db-grants migration on a fresh install.' ); + + return; + } + + $sites = array_filter( + Site::all( [ 'site_url', 'db_host', 'db_name', 'db_user' ] ), + function ( $site ) { + // A name without pattern characters already matches only itself. + return GLOBAL_DB === $site->db_host && ! empty( $site->db_user ) && ! empty( $site->db_name ) && false !== strpbrk( $site->db_name, '\\_%' ); + } + ); + + if ( empty( $sites ) ) { + EE::debug( 'Skipping restrict-global-db-grants migration: no global-db site needs it.' ); + + return; + } + + if ( 'running' !== \EE_DOCKER::container_status( GLOBAL_DB_CONTAINER ) ) { + foreach ( $sites as $site ) { + $this->warn( $site, 'the global database is not running' ); + } + + return; + } + + wait_for_global_db(); + + $restricted = 0; + foreach ( $sites as $site ) { + if ( $this->restrict_grant( $site ) ) { + $restricted++; + } + } + + if ( $restricted ) { + EE::log( sprintf( 'Restricted the database grant of %d site(s) to their own database.', $restricted ) ); + } + } + + /** + * Replaces the stale grants of the site's user with the exact one. + * + * @param Site $site Site row. + * + * @return bool Whether the grant was changed. + */ + private function restrict_grant( $site ) { + + $stale = $this->get_stale_grant_names( $site->db_name ); + if ( empty( $stale ) ) { + EE::debug( sprintf( 'Site %s: database name %s is too long to narrow its grant.', $site->site_url, $site->db_name ) ); + + return false; + } + + $user_sql = sql_quote_string( $site->db_user ); + // HEX(): `mysql -B` escapes backslashes in its output. + $state = run_global_db_sql( + sprintf( + "SELECT 'user', COUNT(*) FROM mysql.user WHERE User = %1\$s AND Host = '%%'; SELECT 'db', COUNT(*) FROM information_schema.SCHEMATA WHERE SCHEMA_NAME = %2\$s; SELECT 'grant', HEX(Db) FROM mysql.db WHERE User = %1\$s AND Host = '%%' AND Db IN (%3\$s);", + $user_sql, + sql_quote_string( $site->db_name ), + implode( ', ', array_map( 'EE\\Site\\Utils\\sql_quote_string', $stale ) ) + ) + ); + if ( ! $state || 0 !== $state->return_code || ! preg_match_all( '/^(user|db|grant)\t(\S+)$/m', $state->stdout, $rows, PREG_SET_ORDER ) ) { + $this->debug_result( $state ); + $this->warn( $site, 'its grants could not be read' ); + + return false; + } + + $has_user = false; + $has_db = false; + $found = []; + foreach ( $rows as $row ) { + if ( 'grant' === $row[1] ) { + $found[] = hex2bin( $row[2] ); + } elseif ( 'user' === $row[1] ) { + $has_user = '0' !== $row[2]; + } else { + $has_db = '0' !== $row[2]; + } + } + + if ( ! $has_user ) { + EE::debug( sprintf( 'Site %s: database user %s does not exist, skipping.', $site->site_url, $site->db_user ) ); + + return false; + } + $found = array_values( array_intersect( $stale, $found ) ); + if ( empty( $found ) ) { + EE::debug( sprintf( 'Site %s: database grant is already exact.', $site->site_url ) ); + + return false; + } + if ( ! $has_db ) { + // The stale grant still matches other databases, so it goes anyway. + EE::debug( sprintf( 'Site %s: database %s does not exist, restricting its grant anyway.', $site->site_url, $site->db_name ) ); + } + + // One batch: it stops at the first error, so nothing is revoked unless the new GRANT worked. + $result = run_global_db_sql( $this->get_fix_sql( $site, $found ) ); + if ( ! $result || 0 !== $result->return_code ) { + $this->debug_result( $result ); + $this->warn( $site, 'the grant could not be changed', $found ); + + return false; + } + + EE::debug( sprintf( 'Site %s: database grant restricted to %s.', $site->site_url, $site->db_name ) ); + + return true; + } + + /** + * Database names in grants that earlier versions wrote for this database, other than the current grant. + * + * @param string $db_name Database name. + * + * @return string[] + */ + private function get_stale_grant_names( $db_name ) { + + // Unescaped, as granted before the pattern characters were escaped. + $names = [ $db_name ]; + // MariaDB 10.x cut an escaped name longer than 64 characters to 64 when storing it. + if ( preg_match( '/^(.{64})./su', addcslashes( $db_name, '\\_%' ), $match ) ) { + $names[] = $match[1]; + } + + $current = sql_quote_grant_database( $db_name ); + + return array_values( + array_filter( + array_unique( $names ), + function ( $name ) use ( $current ) { + return sql_quote_identifier( $name ) !== $current; + } + ) + ); + } + + /** + * @param Site $site Site row. + * @param string[] $stale Stale grant names to revoke. + * + * @return string SQL that replaces the stale grants with the exact one. + */ + private function get_fix_sql( $site, array $stale ) { + + $user = sql_quote_string( $site->db_user ) . "@'%'"; + $sql = sprintf( "GRANT ALL PRIVILEGES ON %s.* TO %s;\n", sql_quote_grant_database( $site->db_name ), $user ); + foreach ( $stale as $name ) { + $sql .= sprintf( "REVOKE ALL PRIVILEGES ON %s.* FROM %s;\n", sql_quote_identifier( $name ), $user ); + } + + return $sql . 'FLUSH PRIVILEGES;'; + } + + /** + * @param Site $site Site row. + * @param string $reason Why the grant was not changed. + * @param string[]|null $stale Stale grant names, if known. + */ + private function warn( $site, $reason, $stale = null ) { + + $known = null !== $stale; + EE::warning( + sprintf( + "Could not restrict the database grant of site %s to its own database: %s. Run this as root on the global database (for example in `docker exec -it %s sh -c 'mysql -uroot -p\"\$MYSQL_ROOT_PASSWORD\"'`)%s:\n%s", + $site->site_url, + $reason, + GLOBAL_DB_CONTAINER, + $known ? '' : '; a REVOKE that fails with "There is no such grant" can be ignored', + $this->get_fix_sql( $site, $known ? $stale : $this->get_stale_grant_names( $site->db_name ) ) + ) + ); + } + + /** + * @param bool|object $result Result of run_global_db_sql(). + */ + private function debug_result( $result ) { + + if ( $result ) { + EE::debug( sprintf( 'Global database returned %d: %s', $result->return_code, trim( $result->stderr ) ) ); + } + } + + /** + * Re-granting the pattern would widen access again, so the exact grant stays. + */ + public function down() { + } +} From afd50ecb65bb7f66336bd96733cd4d117c980ac4 Mon Sep 17 00:00:00 2001 From: Riddhesh Sanghvi Date: Mon, 12 Oct 2026 07:43:27 +0530 Subject: [PATCH 3/6] fix(site): keep default database names short enough for an exact grant A default database name is now cut so that its escaped grant name fits in 64 characters, and a suffixed default name respects the same limit, so a new site's grant covers exactly its own database. An explicit --dbname that is too long for that still works through the partial escape, with a warning at create time. --- src/helper/site-utils.php | 62 +++++++++++++++++++++++++++++++++++++-- 1 file changed, 59 insertions(+), 3 deletions(-) diff --git a/src/helper/site-utils.php b/src/helper/site-utils.php index eed0a4a4..97c1ae9c 100644 --- a/src/helper/site-utils.php +++ b/src/helper/site-utils.php @@ -202,6 +202,59 @@ function ( $match ) use ( &$spare ) { return sql_quote_identifier( $pattern ); } +/** + * Length of a database name in a GRANT once its pattern characters are escaped. + * + * @param string $db_name Database name. + * + * @return int Characters. + */ +function get_grant_name_length( $db_name ) { + + $length = preg_match_all( '/./su', $db_name ); + + return ( false === $length ? strlen( $db_name ) : $length ) + preg_match_all( '/[\\\\_%]/', $db_name ); +} + +/** + * Whether a GRANT can cover exactly this database: the escaped name has to fit in 64 characters. + * + * @param string $db_name Database name. + * + * @return bool + */ +function db_name_fits_exact_grant( $db_name ) { + + return get_grant_name_length( $db_name ) <= 64; +} + +/** + * Cuts a database name so that, with $suffix appended, its escaped GRANT name fits in 64 characters. + * + * @param string $db_name Database name. + * @param string $suffix Suffix to append. + * + * @return string + */ +function cut_db_name_for_grant( $db_name, $suffix = '' ) { + + $chars = preg_split( '//u', $db_name, -1, PREG_SPLIT_NO_EMPTY ); + if ( false === $chars ) { + $chars = str_split( $db_name ); + } + $room = 64 - get_grant_name_length( $suffix ); + $name = ''; + foreach ( $chars as $char ) { + $room -= false === strpos( '\\_%', $char ) ? 1 : 2; + if ( $room < 0 ) { + break; + } + $name .= $char; + } + + return $name . $suffix; +} + /** * Runs SQL as root on the global database. * @@ -269,7 +322,7 @@ function global_db_has_user( $db_user ) { } /** - * Default database name of a site, cut to MySQL's 64-character limit. + * Default database name of a site, cut so that a GRANT can cover exactly this database. * * @param string $site_url Name of the site. * @@ -277,7 +330,7 @@ function global_db_has_user( $db_user ) { */ function get_default_db_name( $site_url ) { - return substr( str_replace( [ '.', '-' ], '_', $site_url ), 0, 64 ); + return cut_db_name_for_grant( str_replace( [ '.', '-' ], '_', $site_url ) ); } /** @@ -328,7 +381,7 @@ function reserve_global_db_names( $db_name, $db_user, $explicit_db_name ) { for ( $i = 2; $i <= 100; $i++ ) { $suffix = '_' . $i; - $candidate = substr( $db_name, 0, 64 - strlen( $suffix ) ) . $suffix; + $candidate = cut_db_name_for_grant( $db_name, $suffix ); if ( ! $is_taken( $candidate ) ) { EE::log( sprintf( 'Database `%s` already exists, using `%s` instead.', $db_name, $candidate ) ); @@ -377,6 +430,9 @@ function create_user_in_db( $db_host, $db_name = '', $db_user = '', $db_pass = ' return false; } + if ( ! db_name_fits_exact_grant( $db_name ) ) { + EE::warning( sprintf( 'Database name `%s` is too long for its grant to cover only this database: user `%s` may also get access to databases whose names differ from it only in the last characters. A shorter --dbname avoids this.', $db_name, $db_user ) ); + } if ( ! $ok( sprintf( 'GRANT ALL PRIVILEGES ON %s.* TO %s; FLUSH PRIVILEGES;', sql_quote_grant_database( $db_name ), $user ) ) ) { // Separate calls: a batch stops at its first error. $ok( sprintf( 'DROP DATABASE %s;', $database ) ); From a142337ce9446a3d0eb7f738ba6dc7290c3952b6 Mon Sep 17 00:00:00 2001 From: Riddhesh Sanghvi Date: Mon, 12 Oct 2026 08:01:27 +0530 Subject: [PATCH 4/6] fix(site): leave customised and shared database grants to the admin in the grant migration The migration now only rewrites old grant rows that are exactly the ALL PRIVILEGES grant EasyEngine creates, checked against the privilege columns the server reports. A grant that was changed by hand, a grant that another site of the same database user relies on, and a user that exists only for other hosts are reported with a warning naming the site instead. Sites whose database name is too long for an exact grant get a warning too. The grants of all sites are read in one round trip, and a stopped or unreadable global database gives a single warning with SQL only for the sites it applies to. --- ...site-command_restrict_global_db_grants.php | 321 +++++++++++++----- 1 file changed, 236 insertions(+), 85 deletions(-) diff --git a/migrations/container/20260930120000_site-command_restrict_global_db_grants.php b/migrations/container/20260930120000_site-command_restrict_global_db_grants.php index 06b0b25f..a794e048 100644 --- a/migrations/container/20260930120000_site-command_restrict_global_db_grants.php +++ b/migrations/container/20260930120000_site-command_restrict_global_db_grants.php @@ -4,6 +4,7 @@ use EE; use EE\Model\Site; +use function EE\Site\Utils\db_name_fits_exact_grant; use function EE\Site\Utils\run_global_db_sql; use function EE\Site\Utils\sql_quote_grant_database; use function EE\Site\Utils\sql_quote_identifier; @@ -14,6 +15,7 @@ * Restricts the database grant of each global-db site to its own database. * * Sites created before the grant escaped `_` and `%` hold a grant whose database name is a pattern, and a long escaped name was stored cut to 64 characters. This adds the current grant, then revokes those. + * Grants that differ from the one EasyEngine creates, or that another site of the same user relies on, are left to the admin. */ class RestrictGlobalDbGrants extends Base { @@ -28,13 +30,21 @@ public function up() { return; } - $sites = array_filter( + $global_sites = array_filter( Site::all( [ 'site_url', 'db_host', 'db_name', 'db_user' ] ), function ( $site ) { - // A name without pattern characters already matches only itself. - return GLOBAL_DB === $site->db_host && ! empty( $site->db_user ) && ! empty( $site->db_name ) && false !== strpbrk( $site->db_name, '\\_%' ); + return GLOBAL_DB === $site->db_host && ! empty( $site->db_user ) && ! empty( $site->db_name ); } ); + // A name without pattern characters already matches only itself. + $sites = array_values( + array_filter( + $global_sites, + function ( $site ) { + return false !== strpbrk( $site->db_name, '\\_%' ); + } + ) + ); if ( empty( $sites ) ) { EE::debug( 'Skipping restrict-global-db-grants migration: no global-db site needs it.' ); @@ -43,18 +53,24 @@ function ( $site ) { } if ( 'running' !== \EE_DOCKER::container_status( GLOBAL_DB_CONTAINER ) ) { - foreach ( $sites as $site ) { - $this->warn( $site, 'the global database is not running' ); - } + $this->warn_manual( $sites, 'the global database is not running' ); return; } wait_for_global_db(); + $columns = $this->get_privilege_columns(); + $state = $columns ? $this->read_state( $sites, $columns ) : false; + if ( false === $state ) { + $this->warn_manual( $sites, 'their grants could not be read' ); + + return; + } + $restricted = 0; - foreach ( $sites as $site ) { - if ( $this->restrict_grant( $site ) ) { + foreach ( $sites as $i => $site ) { + if ( $this->restrict_grant( $site, $state[ $i ], $global_sites ) ) { $restricted++; } } @@ -65,79 +81,222 @@ function ( $site ) { } /** - * Replaces the stale grants of the site's user with the exact one. - * - * @param Site $site Site row. + * Privilege columns of `mysql.db`, which differ between MariaDB versions. * - * @return bool Whether the grant was changed. + * @return string[] */ - private function restrict_grant( $site ) { + private function get_privilege_columns() { - $stale = $this->get_stale_grant_names( $site->db_name ); - if ( empty( $stale ) ) { - EE::debug( sprintf( 'Site %s: database name %s is too long to narrow its grant.', $site->site_url, $site->db_name ) ); + $result = run_global_db_sql( "SELECT COLUMN_NAME FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = 'mysql' AND TABLE_NAME = 'db' AND COLUMN_NAME LIKE '%\\\\_priv' ORDER BY ORDINAL_POSITION;" ); + if ( ! $result || 0 !== $result->return_code ) { + $this->debug_result( $result ); - return false; + return []; + } + $columns = preg_split( '/\s+/', trim( $result->stdout ), -1, PREG_SPLIT_NO_EMPTY ); + if ( ! in_array( 'Grant_priv', $columns, true ) || preg_grep( '/^\w+_priv$/', $columns, PREG_GREP_INVERT ) ) { + return []; } - $user_sql = sql_quote_string( $site->db_user ); - // HEX(): `mysql -B` escapes backslashes in its output. - $state = run_global_db_sql( - sprintf( - "SELECT 'user', COUNT(*) FROM mysql.user WHERE User = %1\$s AND Host = '%%'; SELECT 'db', COUNT(*) FROM information_schema.SCHEMATA WHERE SCHEMA_NAME = %2\$s; SELECT 'grant', HEX(Db) FROM mysql.db WHERE User = %1\$s AND Host = '%%' AND Db IN (%3\$s);", - $user_sql, - sql_quote_string( $site->db_name ), - implode( ', ', array_map( 'EE\\Site\\Utils\\sql_quote_string', $stale ) ) - ) - ); - if ( ! $state || 0 !== $state->return_code || ! preg_match_all( '/^(user|db|grant)\t(\S+)$/m', $state->stdout, $rows, PREG_SET_ORDER ) ) { - $this->debug_result( $state ); - $this->warn( $site, 'its grants could not be read' ); + return $columns; + } + + /** + * Reads what every site needs in one round trip: its users, grant rows and the other databases its old grants match. + * + * @param Site[] $sites Sites to check. + * @param string[] $columns Privilege columns of `mysql.db`. + * + * @return array|false Per site: users, hosts, grants (name => whether it is the grant EasyEngine creates), matches (old name => database names). + */ + private function read_state( array $sites, array $columns ) { + + // The GRANT ALL row EasyEngine creates: every privilege except GRANT OPTION. + $checks = []; + foreach ( $columns as $column ) { + $checks[] = sprintf( "%s = '%s'", sql_quote_identifier( $column ), 'Grant_priv' === $column ? 'N' : 'Y' ); + } + $is_ee_grant = implode( ' AND ', $checks ); + + $sql = ''; + $state = []; + foreach ( $sites as $i => $site ) { + $user = sql_quote_string( $site->db_user ); + $names = array_merge( [ $this->get_grant_name( $site->db_name ) ], $this->get_stale_grant_names( $site->db_name ) ); + // HEX(): `mysql -B` escapes backslashes in its output. + $sql .= sprintf( "SELECT 'user', %d, COUNT(*), COUNT(IF(Host = '%%', 1, NULL)) FROM mysql.user WHERE User = %s;\n", $i, $user ); + $sql .= sprintf( + "SELECT 'grant', %d, HEX(Db), IF(%s, 1, 0) FROM mysql.db WHERE User = %s AND Host = '%%' AND Db IN (%s);\n", + $i, + $is_ee_grant, + $user, + implode( ', ', array_map( 'EE\\Site\\Utils\\sql_quote_string', $names ) ) + ); + // BINARY LIKE matches databases the way a GRANT pattern does (case-sensitive, byte-wise `_`). + foreach ( $this->get_stale_grant_names( $site->db_name ) as $name ) { + $sql .= sprintf( + "SELECT 'match', %d, HEX(%s), HEX(SCHEMA_NAME) FROM information_schema.SCHEMATA WHERE BINARY SCHEMA_NAME LIKE %s AND SCHEMA_NAME <> %s;\n", + $i, + sql_quote_string( $name ), + sql_quote_string( $name ), + sql_quote_string( $site->db_name ) + ); + } + $state[ $i ] = [ + 'users' => 0, + 'hosts' => 0, + 'grants' => [], + 'matches' => [], + ]; + } + + $result = run_global_db_sql( $sql ); + if ( ! $result || 0 !== $result->return_code ) { + $this->debug_result( $result ); return false; } - $has_user = false; - $has_db = false; - $found = []; - foreach ( $rows as $row ) { - if ( 'grant' === $row[1] ) { - $found[] = hex2bin( $row[2] ); - } elseif ( 'user' === $row[1] ) { - $has_user = '0' !== $row[2]; - } else { - $has_db = '0' !== $row[2]; + foreach ( explode( "\n", trim( $result->stdout ) ) as $line ) { + $row = explode( "\t", $line ); + if ( 4 !== count( $row ) || ! isset( $state[ $row[1] ] ) ) { + continue; + } + $i = (int) $row[1]; + if ( 'user' === $row[0] ) { + $state[ $i ]['users'] = (int) $row[2]; + $state[ $i ]['hosts'] = (int) $row[3]; + } elseif ( 'grant' === $row[0] ) { + $state[ $i ]['grants'][ hex2bin( $row[2] ) ] = '1' === $row[3]; + } elseif ( 'match' === $row[0] ) { + $state[ $i ]['matches'][ hex2bin( $row[2] ) ][] = hex2bin( $row[3] ); } } - if ( ! $has_user ) { + return $state; + } + + /** + * Replaces the stale grants of the site's user with the exact one, where that is safe. + * + * @param Site $site Site row. + * @param array $state What read_state() found for it. + * @param Site[] $global_sites All global-db site rows. + * + * @return bool Whether the grant was changed. + */ + private function restrict_grant( $site, array $state, array $global_sites ) { + + $user = sprintf( "%s@'%%'", sql_quote_string( $site->db_user ) ); + + if ( ! $state['users'] ) { EE::debug( sprintf( 'Site %s: database user %s does not exist, skipping.', $site->site_url, $site->db_user ) ); return false; } - $found = array_values( array_intersect( $stale, $found ) ); - if ( empty( $found ) ) { - EE::debug( sprintf( 'Site %s: database grant is already exact.', $site->site_url ) ); + if ( ! $state['hosts'] ) { + EE::warning( sprintf( "Did not restrict the database grant of site %s: its database user %s exists only for hosts other than '%%'. Please check its grants with SHOW GRANTS.", $site->site_url, $site->db_user ) ); return false; } - if ( ! $has_db ) { - // The stale grant still matches other databases, so it goes anyway. - EE::debug( sprintf( 'Site %s: database %s does not exist, restricting its grant anyway.', $site->site_url, $site->db_name ) ); + + $current = $this->get_grant_name( $site->db_name ); + $exact = db_name_fits_exact_grant( $site->db_name ); + $stale = array_values( array_intersect( $this->get_stale_grant_names( $site->db_name ), array_keys( $state['grants'] ) ) ); + + if ( empty( $stale ) ) { + if ( ! $exact && isset( $state['grants'][ $current ] ) ) { + $this->warn_not_exact( $site ); + } else { + EE::debug( sprintf( 'Site %s: database grant is already exact.', $site->site_url ) ); + } + + return false; } - // One batch: it stops at the first error, so nothing is revoked unless the new GRANT worked. - $result = run_global_db_sql( $this->get_fix_sql( $site, $found ) ); - if ( ! $result || 0 !== $result->return_code ) { - $this->debug_result( $result ); - $this->warn( $site, 'the grant could not be changed', $found ); + $custom = array_keys( array_diff( $state['grants'], [ true ] ) ); + if ( $custom ) { + $steps = ''; + foreach ( $stale as $name ) { + $steps .= sprintf( "REVOKE ALL PRIVILEGES ON %1\$s.* FROM %2\$s; -- and, if it holds GRANT OPTION: REVOKE GRANT OPTION ON %1\$s.* FROM %2\$s;\n", sql_quote_identifier( $name ), $user ); + } + EE::warning( + sprintf( + "Did not restrict the database grant of site %s: the grants of database user %s on %s differ from the ALL PRIVILEGES grant EasyEngine creates. To restrict them by hand, grant the privileges the site needs on %s, then run as root on the global database:\n%sFLUSH PRIVILEGES;", + $site->site_url, + $site->db_user, + implode( ', ', array_map( 'EE\\Site\\Utils\\sql_quote_identifier', $custom ) ), + sql_quote_grant_database( $site->db_name ), + $steps + ) + ); return false; } + // Another site of the same user may reach its database only through this grant. + $others = []; + foreach ( $global_sites as $other ) { + if ( $other->db_user === $site->db_user && $other->site_url !== $site->site_url ) { + $others[ $other->db_name ] = $other->site_url; + } + } + $revoke = []; + $kept = []; + foreach ( $stale as $name ) { + $matches = isset( $state['matches'][ $name ] ) ? $state['matches'][ $name ] : []; + $shared = array_intersect_key( $others, array_flip( $matches ) ); + if ( $shared ) { + $kept[ $name ] = $shared; + } else { + $revoke[] = $name; + } + } + + // Nothing to change when the only stale grants are kept and the current one is there already. + if ( $revoke || ! isset( $state['grants'][ $current ] ) ) { + $sql = sprintf( "GRANT ALL PRIVILEGES ON %s.* TO %s;\n", sql_quote_grant_database( $site->db_name ), $user ); + foreach ( $revoke as $name ) { + $sql .= sprintf( "REVOKE ALL PRIVILEGES ON %s.* FROM %s;\n", sql_quote_identifier( $name ), $user ); + } + // One batch: it stops at the first error, so nothing is revoked unless the new GRANT worked. + $result = run_global_db_sql( $sql . 'FLUSH PRIVILEGES;' ); + if ( ! $result || 0 !== $result->return_code ) { + $this->debug_result( $result ); + EE::warning( sprintf( "Could not restrict the database grant of site %s to its own database. Please run this as root on the global database:\n%sFLUSH PRIVILEGES;", $site->site_url, $sql ) ); + + return false; + } + } + + foreach ( $kept as $name => $shared ) { + EE::warning( + sprintf( + 'Kept the database grant of site %1$s on %2$s: it also covers the database of site(s) %3$s, which use the same database user %4$s. Once that user has its own grant on each of those databases, run REVOKE ALL PRIVILEGES ON %2$s.* FROM %5$s.', + $site->site_url, + sql_quote_identifier( $name ), + implode( ', ', $shared ), + $site->db_user, + $user + ) + ); + } + if ( ! $exact ) { + $this->warn_not_exact( $site ); + } EE::debug( sprintf( 'Site %s: database grant restricted to %s.', $site->site_url, $site->db_name ) ); - return true; + return ! empty( $revoke ); + } + + /** + * @param string $db_name Database name. + * + * @return string Database name as the current GRANT writes it. + */ + private function get_grant_name( $db_name ) { + + return str_replace( '``', '`', substr( sql_quote_grant_database( $db_name ), 1, -1 ) ); } /** @@ -156,51 +315,43 @@ private function get_stale_grant_names( $db_name ) { $names[] = $match[1]; } - $current = sql_quote_grant_database( $db_name ); - - return array_values( - array_filter( - array_unique( $names ), - function ( $name ) use ( $current ) { - return sql_quote_identifier( $name ) !== $current; - } - ) - ); + return array_values( array_diff( array_unique( $names ), [ $this->get_grant_name( $db_name ) ] ) ); } /** - * @param Site $site Site row. - * @param string[] $stale Stale grant names to revoke. - * - * @return string SQL that replaces the stale grants with the exact one. + * @param Site $site Site row. */ - private function get_fix_sql( $site, array $stale ) { - - $user = sql_quote_string( $site->db_user ) . "@'%'"; - $sql = sprintf( "GRANT ALL PRIVILEGES ON %s.* TO %s;\n", sql_quote_grant_database( $site->db_name ), $user ); - foreach ( $stale as $name ) { - $sql .= sprintf( "REVOKE ALL PRIVILEGES ON %s.* FROM %s;\n", sql_quote_identifier( $name ), $user ); - } + private function warn_not_exact( $site ) { - return $sql . 'FLUSH PRIVILEGES;'; + EE::warning( sprintf( 'The database grant of site %s may also cover databases whose names differ from %s only in the last characters: the name is too long for a grant on exactly that database.', $site->site_url, $site->db_name ) ); } /** - * @param Site $site Site row. - * @param string $reason Why the grant was not changed. - * @param string[]|null $stale Stale grant names, if known. + * One warning for sites whose grants could not be checked, with the SQL for the common case. + * + * @param Site[] $sites Sites. + * @param string $reason Why they were not checked. */ - private function warn( $site, $reason, $stale = null ) { + private function warn_manual( array $sites, $reason ) { + + $sql = ''; + $urls = []; + foreach ( $sites as $site ) { + $urls[] = $site->site_url; + // Only the unescaped grant of older versions, and only where the new grant differs from it. + if ( ! in_array( $site->db_name, $this->get_stale_grant_names( $site->db_name ), true ) ) { + continue; + } + $user = sprintf( "%s@'%%'", sql_quote_string( $site->db_user ) ); + $sql .= sprintf( "GRANT ALL PRIVILEGES ON %s.* TO %s; REVOKE ALL PRIVILEGES ON %s.* FROM %s;\n", sql_quote_grant_database( $site->db_name ), $user, sql_quote_identifier( $site->db_name ), $user ); + } - $known = null !== $stale; EE::warning( sprintf( - "Could not restrict the database grant of site %s to its own database: %s. Run this as root on the global database (for example in `docker exec -it %s sh -c 'mysql -uroot -p\"\$MYSQL_ROOT_PASSWORD\"'`)%s:\n%s", - $site->site_url, + 'Could not restrict the database grants of site(s) %s to their own database: %s.%s', + implode( ', ', $urls ), $reason, - GLOBAL_DB_CONTAINER, - $known ? '' : '; a REVOKE that fails with "There is no such grant" can be ignored', - $this->get_fix_sql( $site, $known ? $stale : $this->get_stale_grant_names( $site->db_name ) ) + '' === $sql ? '' : " For each user whose SHOW GRANTS lists the old grant, run as root on the global database:\n" . $sql . 'FLUSH PRIVILEGES;' ) ); } From 32f3d042d259aaf81df4b655079ed495816ee67c Mon Sep 17 00:00:00 2001 From: Riddhesh Sanghvi Date: Mon, 12 Oct 2026 08:09:17 +0530 Subject: [PATCH 5/6] fix(site): keep a cut default database name within 64 bytes reserve_global_db_names() refuses names longer than 64 bytes, so the default name is now also cut on a character boundary to fit 64 bytes, not only 64 characters. The create-time warning for a long explicit --dbname now says the grant may match other names of the same length. --- src/helper/site-utils.php | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/src/helper/site-utils.php b/src/helper/site-utils.php index 97c1ae9c..672bdd65 100644 --- a/src/helper/site-utils.php +++ b/src/helper/site-utils.php @@ -229,7 +229,7 @@ function db_name_fits_exact_grant( $db_name ) { } /** - * Cuts a database name so that, with $suffix appended, its escaped GRANT name fits in 64 characters. + * Cuts a database name so that, with $suffix appended, its escaped GRANT name fits in 64 characters and the name in 64 bytes. * * @param string $db_name Database name. * @param string $suffix Suffix to append. @@ -242,11 +242,13 @@ function cut_db_name_for_grant( $db_name, $suffix = '' ) { if ( false === $chars ) { $chars = str_split( $db_name ); } - $room = 64 - get_grant_name_length( $suffix ); - $name = ''; + $room = 64 - get_grant_name_length( $suffix ); + $bytes = 64 - strlen( $suffix ); + $name = ''; foreach ( $chars as $char ) { - $room -= false === strpos( '\\_%', $char ) ? 1 : 2; - if ( $room < 0 ) { + $room -= false === strpos( '\\_%', $char ) ? 1 : 2; + $bytes -= strlen( $char ); + if ( $room < 0 || $bytes < 0 ) { break; } $name .= $char; @@ -431,7 +433,7 @@ function create_user_in_db( $db_host, $db_name = '', $db_user = '', $db_pass = ' return false; } if ( ! db_name_fits_exact_grant( $db_name ) ) { - EE::warning( sprintf( 'Database name `%s` is too long for its grant to cover only this database: user `%s` may also get access to databases whose names differ from it only in the last characters. A shorter --dbname avoids this.', $db_name, $db_user ) ); + EE::warning( sprintf( 'Database name `%s` is too long for its grant to cover only this database: user `%s` may also get access to other databases whose names have the same length. A shorter --dbname avoids this.', $db_name, $db_user ) ); } if ( ! $ok( sprintf( 'GRANT ALL PRIVILEGES ON %s.* TO %s; FLUSH PRIVILEGES;', sql_quote_grant_database( $db_name ), $user ) ) ) { // Separate calls: a batch stops at its first error. From 57ed6c155df74de93666067f8b66a9a7b0b3dc5d Mon Sep 17 00:00:00 2001 From: Riddhesh Sanghvi Date: Mon, 12 Oct 2026 08:20:15 +0530 Subject: [PATCH 6/6] fix(site): accept upgraded grant rows and make the grant migration's manual SQL safe Privilege columns added after MariaDB 10.2 are set to 'N' on existing rows when mariadb-upgrade adds them, so the migration now requires 'Y' only on the 10.2 privilege columns (and 'N' on Grant_priv) when it decides whether a row is the grant EasyEngine created. When the global database is stopped or its grants can't be read, the warning now states when the SQL applies, gives one statement per line and the command to open a root shell, and lists sites that share a database user with only the GRANT. The not-exact warning now says the grant may still match other names of the same length. --- ...site-command_restrict_global_db_grants.php | 66 ++++++++++++------- 1 file changed, 44 insertions(+), 22 deletions(-) diff --git a/migrations/container/20260930120000_site-command_restrict_global_db_grants.php b/migrations/container/20260930120000_site-command_restrict_global_db_grants.php index a794e048..46be1acd 100644 --- a/migrations/container/20260930120000_site-command_restrict_global_db_grants.php +++ b/migrations/container/20260930120000_site-command_restrict_global_db_grants.php @@ -19,6 +19,12 @@ */ class RestrictGlobalDbGrants extends Base { + /** + * Privilege columns of `mysql.db` in MariaDB 10.2, the oldest global DB. Columns added later come in as 'N' when + * mariadb-upgrade adds them to an existing datadir, so only these must be 'Y' in the grant EasyEngine created. + */ + const BASE_PRIVILEGES = [ 'Select_priv', 'Insert_priv', 'Update_priv', 'Delete_priv', 'Create_priv', 'Drop_priv', 'References_priv', 'Index_priv', 'Alter_priv', 'Create_tmp_table_priv', 'Lock_tables_priv', 'Create_view_priv', 'Show_view_priv', 'Create_routine_priv', 'Alter_routine_priv', 'Execute_priv', 'Event_priv', 'Trigger_priv' ]; + /** * @throws EE\ExitException */ @@ -53,7 +59,7 @@ function ( $site ) { } if ( 'running' !== \EE_DOCKER::container_status( GLOBAL_DB_CONTAINER ) ) { - $this->warn_manual( $sites, 'the global database is not running' ); + $this->warn_manual( $sites, $global_sites, 'the global database is not running' ); return; } @@ -63,7 +69,7 @@ function ( $site ) { $columns = $this->get_privilege_columns(); $state = $columns ? $this->read_state( $sites, $columns ) : false; if ( false === $state ) { - $this->warn_manual( $sites, 'their grants could not be read' ); + $this->warn_manual( $sites, $global_sites, 'their grants could not be read' ); return; } @@ -111,10 +117,14 @@ private function get_privilege_columns() { */ private function read_state( array $sites, array $columns ) { - // The GRANT ALL row EasyEngine creates: every privilege except GRANT OPTION. + // The GRANT ALL row EasyEngine creates: every privilege except GRANT OPTION; newer columns may be either. $checks = []; foreach ( $columns as $column ) { - $checks[] = sprintf( "%s = '%s'", sql_quote_identifier( $column ), 'Grant_priv' === $column ? 'N' : 'Y' ); + if ( 'Grant_priv' === $column ) { + $checks[] = "`Grant_priv` = 'N'"; + } elseif ( in_array( $column, self::BASE_PRIVILEGES, true ) ) { + $checks[] = sprintf( "%s = 'Y'", sql_quote_identifier( $column ) ); + } } $is_ee_grant = implode( ' AND ', $checks ); @@ -323,37 +333,49 @@ private function get_stale_grant_names( $db_name ) { */ private function warn_not_exact( $site ) { - EE::warning( sprintf( 'The database grant of site %s may also cover databases whose names differ from %s only in the last characters: the name is too long for a grant on exactly that database.', $site->site_url, $site->db_name ) ); + EE::warning( sprintf( 'The database grant of site %s may still match other database names of the same length as %s: the name is too long for a grant on exactly that database.', $site->site_url, $site->db_name ) ); } /** - * One warning for sites whose grants could not be checked, with the SQL for the common case. + * One warning for sites whose grants could not be checked, with SQL for the case the migration would handle. * - * @param Site[] $sites Sites. - * @param string $reason Why they were not checked. + * @param Site[] $sites Sites. + * @param Site[] $global_sites All global-db site rows. + * @param string $reason Why they were not checked. */ - private function warn_manual( array $sites, $reason ) { + private function warn_manual( array $sites, array $global_sites, $reason ) { - $sql = ''; - $urls = []; + $users = []; + foreach ( $global_sites as $other ) { + $users[ $other->db_user ] = isset( $users[ $other->db_user ] ) ? $users[ $other->db_user ] + 1 : 1; + } + $sql = ''; + $shared = ''; + $urls = []; foreach ( $sites as $site ) { $urls[] = $site->site_url; - // Only the unescaped grant of older versions, and only where the new grant differs from it. + // Only where the new grant differs from the unescaped grant of older versions. if ( ! in_array( $site->db_name, $this->get_stale_grant_names( $site->db_name ), true ) ) { continue; } - $user = sprintf( "%s@'%%'", sql_quote_string( $site->db_user ) ); - $sql .= sprintf( "GRANT ALL PRIVILEGES ON %s.* TO %s; REVOKE ALL PRIVILEGES ON %s.* FROM %s;\n", sql_quote_grant_database( $site->db_name ), $user, sql_quote_identifier( $site->db_name ), $user ); + $user = sprintf( "%s@'%%'", sql_quote_string( $site->db_user ) ); + $grant = sprintf( "GRANT ALL PRIVILEGES ON %s.* TO %s;\n", sql_quote_grant_database( $site->db_name ), $user ); + if ( $users[ $site->db_user ] > 1 ) { + $shared .= $grant; + } else { + $sql .= $grant . sprintf( "REVOKE ALL PRIVILEGES ON %s.* FROM %s;\n", sql_quote_identifier( $site->db_name ), $user ); + } } - EE::warning( - sprintf( - 'Could not restrict the database grants of site(s) %s to their own database: %s.%s', - implode( ', ', $urls ), - $reason, - '' === $sql ? '' : " For each user whose SHOW GRANTS lists the old grant, run as root on the global database:\n" . $sql . 'FLUSH PRIVILEGES;' - ) - ); + $message = sprintf( 'Could not restrict the database grants of site(s) %s to their own database: %s.', implode( ', ', $urls ), $reason ); + if ( '' !== $sql . $shared ) { + $message .= sprintf( " Once it runs, open a root shell with `docker exec -it %s sh -c 'mysql -uroot -p\"\$MYSQL_ROOT_PASSWORD\"'` and check each user with SHOW GRANTS. Run these lines one at a time, only for a user whose grant on the old name is still the ALL PRIVILEGES grant EasyEngine created; a REVOKE that fails with \"There is no such grant\" can be skipped:\n%s", GLOBAL_DB_CONTAINER, $sql ); + if ( '' !== $shared ) { + $message .= "These users are shared with another site, so keep their old grant until they have a grant of their own on each site's database:\n" . $shared; + } + $message .= 'FLUSH PRIVILEGES;'; + } + EE::warning( $message ); } /**