From 0ed5acbedc40ad88a772809271e0ce387ab063f0 Mon Sep 17 00:00:00 2001 From: Vladislav Vaintroub Date: Thu, 6 Aug 2026 17:56:15 +0200 Subject: [PATCH] MDEV-40656 Bypass REVOKE DENY ... FROM PUBLIC privilege check. DENY ... TO PUBLIC denies everyone, including whoever tries to revoke it, via the "deny wins" merge at every scope (global, db, table, column, routine). Allow REVOKE DENY ... FROM PUBLIC when the revoker can UPDATE mysql.global_priv (same as hand-editing). Assisted-by: Claude:claude-5-sonnet --- mysql-test/main/deny_revoke_public.result | 193 ++++++++++++++++++ mysql-test/main/deny_revoke_public.test | 227 ++++++++++++++++++++++ sql/sql_acl.cc | 53 ++++- sql/sql_acl.h | 1 + 4 files changed, 469 insertions(+), 5 deletions(-) create mode 100644 mysql-test/main/deny_revoke_public.result create mode 100644 mysql-test/main/deny_revoke_public.test diff --git a/mysql-test/main/deny_revoke_public.result b/mysql-test/main/deny_revoke_public.result new file mode 100644 index 0000000000000..a82947a61c4b1 --- /dev/null +++ b/mysql-test/main/deny_revoke_public.result @@ -0,0 +1,193 @@ +# +# MDEV-40656 REVOKE DENY FROM PUBLIC can lock itself out +# +# DENY TO PUBLIC denies everyone, including whoever tries to revoke +# it. Allowed anyway when the revoker can UPDATE mysql.global_priv, +# same as hand-editing it. Checked at every scope: global, db, table, +# column, routine. +# +CREATE TABLE test.t1 (id INT, data INT); +INSERT INTO test.t1 VALUES (1, 100); +CREATE PROCEDURE test.p1() BEGIN SELECT 1; END| +# +# Same session: no bypass needed, privileges aren't re-read yet +# +DENY SELECT ON test.t1 TO PUBLIC; +REVOKE DENY SELECT ON test.t1 FROM PUBLIC; +# +# Table level +# +CREATE USER u_table@localhost; +GRANT SELECT ON test.t1 TO u_table@localhost; +DENY SELECT ON test.t1 TO PUBLIC; +connect con_table1, localhost, u_table,,test; +SELECT * FROM t1; +ERROR 42000: SELECT command denied to user 'u_table'@'localhost' for table `test`.`t1` +REVOKE DENY SELECT ON test.t1 FROM PUBLIC; +ERROR 42000: SELECT, GRANT command denied to user 'u_table'@'localhost' for table `test`.`t1` +disconnect con_table1; +connection default; +GRANT UPDATE ON mysql.global_priv TO u_table@localhost; +connect con_table2, localhost, u_table,,test; +SELECT * FROM t1; +ERROR 42000: SELECT command denied to user 'u_table'@'localhost' for table `test`.`t1` +REVOKE DENY SELECT ON test.t1 FROM PUBLIC; +SELECT * FROM t1; +id data +1 100 +disconnect con_table2; +connection default; +REVOKE UPDATE ON mysql.global_priv FROM u_table@localhost; +DROP USER u_table@localhost; +# +# Column level +# +CREATE USER u_col@localhost; +GRANT SELECT ON test.t1 TO u_col@localhost; +DENY SELECT (data) ON test.t1 TO PUBLIC; +connect con_col1, localhost, u_col,,test; +SELECT data FROM t1; +ERROR 42000: SELECT command denied to user 'u_col'@'localhost' for column 'data' in table 't1' +REVOKE DENY SELECT (data) ON test.t1 FROM PUBLIC; +ERROR 42000: GRANT command denied to user 'u_col'@'localhost' for table `test`.`t1` +disconnect con_col1; +connection default; +GRANT UPDATE ON mysql.global_priv TO u_col@localhost; +connect con_col2, localhost, u_col,,test; +SELECT data FROM t1; +ERROR 42000: SELECT command denied to user 'u_col'@'localhost' for column 'data' in table 't1' +REVOKE DENY SELECT (data) ON test.t1 FROM PUBLIC; +SELECT data FROM t1; +data +100 +disconnect con_col2; +connection default; +REVOKE UPDATE ON mysql.global_priv FROM u_col@localhost; +DROP USER u_col@localhost; +# +# DB level +# +CREATE USER u_db@localhost; +GRANT SELECT ON test.* TO u_db@localhost; +DENY SELECT ON test.* TO PUBLIC; +connect con_db1, localhost, u_db,,; +SELECT * FROM test.t1; +ERROR 42000: SELECT command denied to user 'u_db'@'localhost' for table `test`.`t1` +REVOKE DENY SELECT ON test.* FROM PUBLIC; +ERROR 42000: Access denied for user 'u_db'@'localhost' to database 'test' +disconnect con_db1; +connection default; +GRANT UPDATE ON mysql.global_priv TO u_db@localhost; +connect con_db2, localhost, u_db,,; +SELECT * FROM test.t1; +ERROR 42000: SELECT command denied to user 'u_db'@'localhost' for table `test`.`t1` +REVOKE DENY SELECT ON test.* FROM PUBLIC; +SELECT * FROM test.t1; +id data +1 100 +disconnect con_db2; +connection default; +REVOKE UPDATE ON mysql.global_priv FROM u_db@localhost; +DROP USER u_db@localhost; +# +# Global level +# +CREATE USER u_global@localhost; +GRANT SELECT ON *.* TO u_global@localhost; +DENY SELECT ON *.* TO PUBLIC; +connect con_global1, localhost, u_global,,; +SELECT * FROM test.t1; +ERROR 42000: SELECT command denied to user 'u_global'@'localhost' for table `test`.`t1` +REVOKE DENY SELECT ON *.* FROM PUBLIC; +ERROR 28000: Access denied for user 'u_global'@'localhost' (using password: NO) +disconnect con_global1; +connection default; +GRANT UPDATE ON mysql.global_priv TO u_global@localhost; +connect con_global2, localhost, u_global,,; +SELECT * FROM test.t1; +ERROR 42000: SELECT command denied to user 'u_global'@'localhost' for table `test`.`t1` +REVOKE DENY SELECT ON *.* FROM PUBLIC; +disconnect con_global2; +connection default; +# master_access is cached at connect; needs a reconnect to take effect +connect con_global3, localhost, u_global,,; +SELECT * FROM test.t1; +id data +1 100 +disconnect con_global3; +connection default; +REVOKE UPDATE ON mysql.global_priv FROM u_global@localhost; +DROP USER u_global@localhost; +# +# Routine level +# +CREATE USER u_proc@localhost; +GRANT EXECUTE ON PROCEDURE test.p1 TO u_proc@localhost; +DENY EXECUTE ON PROCEDURE test.p1 TO PUBLIC; +connect con_proc1, localhost, u_proc,,test; +CALL p1(); +ERROR 42000: execute command denied to user 'u_proc'@'localhost' for routine 'test.p1' +REVOKE DENY EXECUTE ON PROCEDURE test.p1 FROM PUBLIC; +ERROR 42000: execute command denied to user 'u_proc'@'localhost' for routine 'test.p1' +disconnect con_proc1; +connection default; +GRANT UPDATE ON mysql.global_priv TO u_proc@localhost; +connect con_proc2, localhost, u_proc,,test; +CALL p1(); +ERROR 42000: execute command denied to user 'u_proc'@'localhost' for routine 'test.p1' +REVOKE DENY EXECUTE ON PROCEDURE test.p1 FROM PUBLIC; +CALL p1(); +1 +1 +disconnect con_proc2; +connection default; +REVOKE UPDATE ON mysql.global_priv FROM u_proc@localhost; +DROP USER u_proc@localhost; +# +# A role is not PUBLIC: no bypass, even with UPDATE on mysql.global_priv +# +CREATE ROLE r1; +CREATE USER u_role@localhost; +GRANT r1 TO u_role@localhost; +GRANT SELECT ON test.t1 TO r1; +DENY SELECT ON test.t1 TO r1; +GRANT UPDATE ON mysql.global_priv TO u_role@localhost; +connect con_role, localhost, u_role,,; +SET ROLE r1; +SELECT * FROM test.t1; +ERROR 42000: SELECT command denied to user 'u_role'@'localhost' for table `test`.`t1` +REVOKE DENY SELECT ON test.t1 FROM r1; +ERROR 42000: SELECT, GRANT command denied to user 'u_role'@'localhost' for table `test`.`t1` +disconnect con_role; +connection default; +REVOKE UPDATE ON mysql.global_priv FROM u_role@localhost; +REVOKE DENY SELECT ON test.t1 FROM r1; +DROP USER u_role@localhost; +DROP ROLE r1; +# +# Known limitation: denying the bypass's own privilege is unrecoverable +# +DENY UPDATE ON *.* TO PUBLIC; +disconnect default; +connect default, localhost, root,,; +# Should FAIL - root can't UPDATE anywhere either, including this table +UPDATE mysql.global_priv SET Priv=Priv WHERE User='PUBLIC'; +ERROR 42000: UPDATE command denied to user 'root'@'localhost' for table `mysql`.`global_priv` +# Should FAIL - no recovery via REVOKE DENY once UPDATE itself is denied +REVOKE DENY UPDATE ON *.* FROM PUBLIC; +ERROR 28000: Access denied for user 'root'@'localhost' (using password: NO) +# Recovery: DELETE was never denied +DELETE FROM mysql.global_priv WHERE User='PUBLIC'; +FLUSH PRIVILEGES; +disconnect default; +connect default, localhost, root,,; +# UPDATE works again after reconnecting +UPDATE mysql.global_priv SET Priv=Priv WHERE User='root' AND Host='localhost'; +# +# Cleanup +# +DROP PROCEDURE test.p1; +DROP TABLE test.t1; +DELETE FROM mysql.global_priv WHERE User='PUBLIC'; +FLUSH PRIVILEGES; +# End of 13.1 tests diff --git a/mysql-test/main/deny_revoke_public.test b/mysql-test/main/deny_revoke_public.test new file mode 100644 index 0000000000000..9df13f0a9fbed --- /dev/null +++ b/mysql-test/main/deny_revoke_public.test @@ -0,0 +1,227 @@ +--source include/not_embedded.inc + +--echo # +--echo # MDEV-40656 REVOKE DENY FROM PUBLIC can lock itself out +--echo # +--echo # DENY TO PUBLIC denies everyone, including whoever tries to revoke +--echo # it. Allowed anyway when the revoker can UPDATE mysql.global_priv, +--echo # same as hand-editing it. Checked at every scope: global, db, table, +--echo # column, routine. +--echo # + +CREATE TABLE test.t1 (id INT, data INT); +INSERT INTO test.t1 VALUES (1, 100); +DELIMITER |; +CREATE PROCEDURE test.p1() BEGIN SELECT 1; END| +DELIMITER ;| + +--echo # +--echo # Same session: no bypass needed, privileges aren't re-read yet +--echo # +DENY SELECT ON test.t1 TO PUBLIC; +REVOKE DENY SELECT ON test.t1 FROM PUBLIC; + +--echo # +--echo # Table level +--echo # +CREATE USER u_table@localhost; +GRANT SELECT ON test.t1 TO u_table@localhost; +DENY SELECT ON test.t1 TO PUBLIC; + +connect (con_table1, localhost, u_table,,test); +--error ER_TABLEACCESS_DENIED_ERROR +SELECT * FROM t1; +--error ER_TABLEACCESS_DENIED_ERROR +REVOKE DENY SELECT ON test.t1 FROM PUBLIC; +disconnect con_table1; +connection default; + +GRANT UPDATE ON mysql.global_priv TO u_table@localhost; + +connect (con_table2, localhost, u_table,,test); +--error ER_TABLEACCESS_DENIED_ERROR +SELECT * FROM t1; +REVOKE DENY SELECT ON test.t1 FROM PUBLIC; +SELECT * FROM t1; +disconnect con_table2; +connection default; + +REVOKE UPDATE ON mysql.global_priv FROM u_table@localhost; +DROP USER u_table@localhost; + +--echo # +--echo # Column level +--echo # +CREATE USER u_col@localhost; +GRANT SELECT ON test.t1 TO u_col@localhost; +DENY SELECT (data) ON test.t1 TO PUBLIC; + +connect (con_col1, localhost, u_col,,test); +--error ER_COLUMNACCESS_DENIED_ERROR +SELECT data FROM t1; +--error ER_TABLEACCESS_DENIED_ERROR +REVOKE DENY SELECT (data) ON test.t1 FROM PUBLIC; +disconnect con_col1; +connection default; + +GRANT UPDATE ON mysql.global_priv TO u_col@localhost; + +connect (con_col2, localhost, u_col,,test); +--error ER_COLUMNACCESS_DENIED_ERROR +SELECT data FROM t1; +REVOKE DENY SELECT (data) ON test.t1 FROM PUBLIC; +SELECT data FROM t1; +disconnect con_col2; +connection default; + +REVOKE UPDATE ON mysql.global_priv FROM u_col@localhost; +DROP USER u_col@localhost; + +--echo # +--echo # DB level +--echo # +CREATE USER u_db@localhost; +GRANT SELECT ON test.* TO u_db@localhost; +DENY SELECT ON test.* TO PUBLIC; + +connect (con_db1, localhost, u_db,,); +--error ER_TABLEACCESS_DENIED_ERROR +SELECT * FROM test.t1; +--error ER_DBACCESS_DENIED_ERROR +REVOKE DENY SELECT ON test.* FROM PUBLIC; +disconnect con_db1; +connection default; + +GRANT UPDATE ON mysql.global_priv TO u_db@localhost; + +connect (con_db2, localhost, u_db,,); +--error ER_TABLEACCESS_DENIED_ERROR +SELECT * FROM test.t1; +REVOKE DENY SELECT ON test.* FROM PUBLIC; +SELECT * FROM test.t1; +disconnect con_db2; +connection default; + +REVOKE UPDATE ON mysql.global_priv FROM u_db@localhost; +DROP USER u_db@localhost; + +--echo # +--echo # Global level +--echo # +CREATE USER u_global@localhost; +GRANT SELECT ON *.* TO u_global@localhost; +DENY SELECT ON *.* TO PUBLIC; + +connect (con_global1, localhost, u_global,,); +--error ER_TABLEACCESS_DENIED_ERROR +SELECT * FROM test.t1; +--error ER_ACCESS_DENIED_ERROR +REVOKE DENY SELECT ON *.* FROM PUBLIC; +disconnect con_global1; +connection default; + +GRANT UPDATE ON mysql.global_priv TO u_global@localhost; + +connect (con_global2, localhost, u_global,,); +--error ER_TABLEACCESS_DENIED_ERROR +SELECT * FROM test.t1; +REVOKE DENY SELECT ON *.* FROM PUBLIC; +disconnect con_global2; +connection default; + +--echo # master_access is cached at connect; needs a reconnect to take effect +connect (con_global3, localhost, u_global,,); +SELECT * FROM test.t1; +disconnect con_global3; +connection default; + +REVOKE UPDATE ON mysql.global_priv FROM u_global@localhost; +DROP USER u_global@localhost; + +--echo # +--echo # Routine level +--echo # +CREATE USER u_proc@localhost; +GRANT EXECUTE ON PROCEDURE test.p1 TO u_proc@localhost; +DENY EXECUTE ON PROCEDURE test.p1 TO PUBLIC; + +connect (con_proc1, localhost, u_proc,,test); +--error ER_PROCACCESS_DENIED_ERROR +CALL p1(); +--error ER_PROCACCESS_DENIED_ERROR +REVOKE DENY EXECUTE ON PROCEDURE test.p1 FROM PUBLIC; +disconnect con_proc1; +connection default; + +GRANT UPDATE ON mysql.global_priv TO u_proc@localhost; + +connect (con_proc2, localhost, u_proc,,test); +--error ER_PROCACCESS_DENIED_ERROR +CALL p1(); +REVOKE DENY EXECUTE ON PROCEDURE test.p1 FROM PUBLIC; +CALL p1(); +disconnect con_proc2; +connection default; + +REVOKE UPDATE ON mysql.global_priv FROM u_proc@localhost; +DROP USER u_proc@localhost; + +--echo # +--echo # A role is not PUBLIC: no bypass, even with UPDATE on mysql.global_priv +--echo # +CREATE ROLE r1; +CREATE USER u_role@localhost; +GRANT r1 TO u_role@localhost; +GRANT SELECT ON test.t1 TO r1; +DENY SELECT ON test.t1 TO r1; +GRANT UPDATE ON mysql.global_priv TO u_role@localhost; + +connect (con_role, localhost, u_role,,); +SET ROLE r1; +--error ER_TABLEACCESS_DENIED_ERROR +SELECT * FROM test.t1; +--error ER_TABLEACCESS_DENIED_ERROR +REVOKE DENY SELECT ON test.t1 FROM r1; +disconnect con_role; +connection default; + +REVOKE UPDATE ON mysql.global_priv FROM u_role@localhost; +REVOKE DENY SELECT ON test.t1 FROM r1; +DROP USER u_role@localhost; +DROP ROLE r1; + +--echo # +--echo # Known limitation: denying the bypass's own privilege is unrecoverable +--echo # +DENY UPDATE ON *.* TO PUBLIC; + +disconnect default; +connect (default, localhost, root,,); + +--echo # Should FAIL - root can't UPDATE anywhere either, including this table +--error ER_TABLEACCESS_DENIED_ERROR +UPDATE mysql.global_priv SET Priv=Priv WHERE User='PUBLIC'; + +--echo # Should FAIL - no recovery via REVOKE DENY once UPDATE itself is denied +--error ER_ACCESS_DENIED_ERROR +REVOKE DENY UPDATE ON *.* FROM PUBLIC; + +--echo # Recovery: DELETE was never denied +DELETE FROM mysql.global_priv WHERE User='PUBLIC'; +FLUSH PRIVILEGES; + +disconnect default; +connect (default, localhost, root,,); + +--echo # UPDATE works again after reconnecting +UPDATE mysql.global_priv SET Priv=Priv WHERE User='root' AND Host='localhost'; + +--echo # +--echo # Cleanup +--echo # +DROP PROCEDURE test.p1; +DROP TABLE test.t1; +DELETE FROM mysql.global_priv WHERE User='PUBLIC'; +FLUSH PRIVILEGES; + +--echo # End of 13.1 tests diff --git a/sql/sql_acl.cc b/sql/sql_acl.cc index 1a2af68394868..ec260dcd3b0c9 100644 --- a/sql/sql_acl.cc +++ b/sql/sql_acl.cc @@ -14359,11 +14359,51 @@ bool Sql_cmd_grant_proxy::execute(THD *thd) } +/** + Allow REVOKE DENY ... FROM PUBLIC to bypass the privilege check when the + revoker holds UPDATE on mysql.global_priv (where DENYs are stored). + + DENY ... TO PUBLIC locks out everyone including root. But the revoker who + can modify the underlying table via UPDATE has sufficient power to undo it + directly, so the denied privilege itself is not required. + + @retval true revoker runs in REVOKE DENY FROM PUBLIC, + can UPDATE mysql.global_priv and shall skip other checks + @retval false normal check applies +*/ + +bool Sql_cmd_grant::should_bypass_revoke_deny(THD *thd) +{ + /* Is current command a REVOKE DENY ? */ + if (!is_revoke() || !m_deny) + return false; + + /* Is it for PUBLIC ? */ + List_iterator_fast it(thd->lex->users_list); + LEX_USER *user; + while ((user= it++)) + { + if (user->host.length || + !my_charset_utf8mb3_general1400_as_ci.streq(user->user, public_name)) + return false; + } + + /* Does current user have UPDATE grant for mysql.global_priv ? */ + TABLE_LIST tl; + tl.init_one_table(&MYSQL_SCHEMA_NAME, &MYSQL_TABLE_NAME[USER_TABLE], + NULL, TL_WRITE); + return !check_access(thd, UPDATE_ACL, tl.db.str, &tl.grant.privilege, + &tl.grant.m_internal, 0, 1) && + !check_grant(thd, UPDATE_ACL, &tl, FALSE, 1, TRUE); +} + + bool Sql_cmd_grant_object::grant_stage0_exact_object(THD *thd, TABLE_LIST *table) { privilege_t priv= m_object_privilege | m_column_privilege_total | GRANT_ACL; - if (check_access(thd, priv, table->db.str, + if (!should_bypass_revoke_deny(thd) && + check_access(thd, priv, table->db.str, &table->grant.privilege, &table->grant.m_internal, 0, 0)) return true; @@ -14376,8 +14416,9 @@ bool Sql_cmd_grant_table::execute_exact_table(THD *thd, TABLE_LIST *table) { LEX *lex= thd->lex; if (grant_stage0_exact_object(thd, table) || - check_grant(thd, m_object_privilege | m_column_privilege_total | GRANT_ACL, - lex->query_tables, FALSE, UINT_MAX, FALSE)) + (!should_bypass_revoke_deny(thd) && + check_grant(thd, m_object_privilege | m_column_privilege_total | GRANT_ACL, + lex->query_tables, FALSE, UINT_MAX, FALSE))) return true; /* Conditionally writes to binlog */ WSREP_TO_ISOLATION_BEGIN(WSREP_MYSQL_DB, NULL, NULL); @@ -14409,7 +14450,8 @@ bool Sql_cmd_grant_sp::execute(THD *thd) } if (grant_stage0_exact_object(thd, table) || - check_grant_routine(thd, grants|GRANT_ACL, lex->query_tables, &m_sph, 0)) + (!should_bypass_revoke_deny(thd) && + check_grant_routine(thd, grants|GRANT_ACL, lex->query_tables, &m_sph, 0))) return true; /* Conditionally writes to binlog */ @@ -14432,7 +14474,8 @@ bool Sql_cmd_grant_table::execute_table_mask(THD *thd) LEX *lex= thd->lex; DBUG_ASSERT(lex->first_select_lex()->table_list.first == NULL); - if (check_access(thd, m_object_privilege | m_column_privilege_total | GRANT_ACL, + if (!should_bypass_revoke_deny(thd) && + check_access(thd, m_object_privilege | m_column_privilege_total | GRANT_ACL, m_db.str, NULL, NULL, 1, 0)) return true; diff --git a/sql/sql_acl.h b/sql/sql_acl.h index b5d1aba2461fc..753c52bc49106 100644 --- a/sql/sql_acl.h +++ b/sql/sql_acl.h @@ -336,6 +336,7 @@ class Sql_cmd_grant: public Sql_cmd void warn_hostname_requires_resolving(THD *thd, List &list); bool user_list_reset_mqh(THD *thd, List &list); void grant_stage0(THD *thd); + bool should_bypass_revoke_deny(THD *thd); #endif public: Sql_cmd_grant(enum_sql_command command)