From 4d32621e1e0ae21559ab680758af742cba66fdeb Mon Sep 17 00:00:00 2001 From: Simon Costea Date: Sat, 10 Oct 2026 00:01:41 +0300 Subject: [PATCH 1/2] Clean up data left behind by the old OpenID Connect Generic plugin "Delete subscribers" only removed the capabilities row of each subscriber, so their other user meta stayed in the database, including the openid-connect-generic-* tokens and claims of the old plugin. - The button now also removes the user meta of deleted users, the openid-connect-generic-* user meta of the remaining users, the old plugin's options and transients, and expired transients. - A daily WP-Cron task deletes any openid-connect-generic-* user meta, so sites are cleaned up automatically once they update, and again if the data comes back (e.g. from a restored backup). Co-Authored-By: Claude Opus 5.5 (1M context) --- plugins/bcc-login/bcc-login.php | 6 +- .../includes/class-bcc-login-cleanup.php | 69 +++++++++++++++++++ .../includes/class-bcc-login-settings.php | 64 ++++++++++++++++- 3 files changed, 136 insertions(+), 3 deletions(-) create mode 100644 plugins/bcc-login/includes/class-bcc-login-cleanup.php diff --git a/plugins/bcc-login/bcc-login.php b/plugins/bcc-login/bcc-login.php index 5be994c..528f135 100644 --- a/plugins/bcc-login/bcc-login.php +++ b/plugins/bcc-login/bcc-login.php @@ -28,6 +28,7 @@ require_once( 'includes/class-bcc-storage.php' ); require_once( 'includes/class-bcc-notifications.php' ); require_once( 'includes/class-exclusive-lock.php' ); +require_once( 'includes/class-bcc-login-cleanup.php' ); class BCC_Login { /** @@ -51,7 +52,8 @@ class BCC_Login { private BCC_Coreapi_Client $_coreapi; private BCC_Notifications $_notifications; private BCC_Storage $_storage; - + private BCC_Login_Cleanup $_cleanup; + /** * Initialize the plugin. */ @@ -73,6 +75,7 @@ private function __construct(){ $this->_feed = new BCC_Login_Feed( $this->_settings, $this->_client ); $this->_updater = new BCC_Login_Updater( $this->plugin, $this->plugin_slug, $this->plugin_version, $this->plugin_name ); $this->_notifications = new BCC_Notifications( $this->_settings, $this->_coreapi ); + $this->_cleanup = new BCC_Login_Cleanup(); if (!empty($this->_settings->site_groups) || !empty($this->_settings->full_content_access_groups)) { $this->_coreapi->ensure_subscription_to_person_updates(); @@ -265,6 +268,7 @@ static function activate_plugin() { * Called when plugin is deactivated */ static function deactivate_plugin() { + BCC_Login_Cleanup::unschedule(); flush_rewrite_rules(); } diff --git a/plugins/bcc-login/includes/class-bcc-login-cleanup.php b/plugins/bcc-login/includes/class-bcc-login-cleanup.php new file mode 100644 index 0000000..54dfe18 --- /dev/null +++ b/plugins/bcc-login/includes/class-bcc-login-cleanup.php @@ -0,0 +1,69 @@ +get_results( + "SELECT meta_key, COUNT(*) AS count + FROM {$wpdb->usermeta} + WHERE meta_key LIKE 'openid-connect-generic%' + GROUP BY meta_key" + ); + + $deleted = 0; + foreach ( (array) $legacy_meta_keys as $legacy_meta_key ) { + // Unlike a plain DELETE query, this also clears the cached meta of the users. + delete_metadata( 'user', 0, $legacy_meta_key->meta_key, '', true ); + $deleted += (int) $legacy_meta_key->count; + } + + $remaining = $wpdb->get_var( + "SELECT COUNT(*) FROM {$wpdb->usermeta} WHERE meta_key LIKE 'openid-connect-generic%'" + ); + + // Null means the query failed. + if ( null === $remaining || (int) $remaining > 0 ) { + return false; + } + + return $deleted; + } +} diff --git a/plugins/bcc-login/includes/class-bcc-login-settings.php b/plugins/bcc-login/includes/class-bcc-login-settings.php index f4149a8..e7328b0 100644 --- a/plugins/bcc-login/includes/class-bcc-login-settings.php +++ b/plugins/bcc-login/includes/class-bcc-login-settings.php @@ -653,6 +653,7 @@ function render_delete_subscribers_button () {

+

prefix}capabilities' AND meta_value LIKE 'a:1:{s:10:\"subscriber\";b:1;}'"; - $result = $wpdb->get_results($sql); + $wpdb->query($sql); + + // The join above only removes the capabilities meta row of each subscriber, + // so remove all remaining meta (e.g. openid-connect-generic-*) of users that no longer exist. + // The users tables are shared by all sites of a multisite network, so these aren't prefixed per site. + $sql = "DELETE {$wpdb->usermeta} + FROM {$wpdb->usermeta} + LEFT JOIN {$wpdb->users} ON {$wpdb->usermeta}.user_id = {$wpdb->users}.ID + WHERE {$wpdb->users}.ID IS NULL"; + + $orphaned_meta_deleted = $wpdb->query($sql); + + if ( false === $orphaned_meta_deleted ) { + add_settings_error( + 'subscribers_deleted', + 'orphaned_user_meta_not_deleted', + __( 'The user meta of deleted users could not be deleted.', 'bcc-login' ), + 'error' + ); + } + + // Remove tokens and claims stored by the old OpenID Connect Generic plugin for the remaining users (e.g. admins and editors). + $legacy_meta_deleted = BCC_Login_Cleanup::delete_legacy_user_meta(); + + if ( false === $legacy_meta_deleted ) { + add_settings_error( + 'subscribers_deleted', + 'legacy_user_meta_not_deleted', + __( 'Not all OpenID Connect Generic user meta could be deleted.', 'bcc-login' ), + 'error' + ); + } + + // Remove options and transients left behind by the old OpenID Connect Generic plugin (states, logs, version). + // 'openid_connect_generic_settings' is kept, as it's still used as fallback for the client id and secret. + $legacy_options = $wpdb->get_col( + "SELECT option_name FROM {$wpdb->prefix}options WHERE option_name LIKE '%openid-connect-generic%'" + ); + + foreach ( $legacy_options as $legacy_option ) { + delete_option( $legacy_option ); + } + + // Remove expired transients, e.g. the oidc_* tokens of ended sessions. WP does this daily via WP-Cron, + // but skips the database when an object cache is in use, so force it here. + delete_expired_transients( true ); + + // Only report the counts of the steps that succeeded, the others have an error notice. + $messages = array( __( 'All subscribers were successfully deleted.' ) ); + + if ( false !== $orphaned_meta_deleted ) { + $messages[] = sprintf( __( 'Removed %d user meta rows of deleted users.', 'bcc-login' ), $orphaned_meta_deleted ); + } + + if ( false !== $legacy_meta_deleted ) { + $messages[] = sprintf( __( 'Removed %d OpenID Connect Generic user meta rows.', 'bcc-login' ), $legacy_meta_deleted ); + } + + $messages[] = sprintf( __( 'Removed %d OpenID Connect Generic options.', 'bcc-login' ), count( $legacy_options ) ); + $messages[] = __( 'Removed expired transients.', 'bcc-login' ); add_settings_error( 'subscribers_deleted', 'subscribers_deleted', - __( 'All subscribers were successfully deleted.' ) . ' – ' . implode(', ', $result), + implode( ' ', $messages ), 'success' ); } From 0a27ed5317ad839753d768bf83c1e2b73b8c57b9 Mon Sep 17 00:00:00 2001 From: Simon Costea Date: Mon, 12 Oct 2026 11:08:34 +0300 Subject: [PATCH 2/2] Protect "Delete subscribers" against cross-site requests The handler ran whenever the referer contained delete_subscribers=true, which a link or form on another site can set through _wp_http_referer. An administrator opening such a link deleted all subscribers without clicking anything. It now requires the form's nonce and an administrator. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../bcc-login/includes/class-bcc-login-settings.php | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/plugins/bcc-login/includes/class-bcc-login-settings.php b/plugins/bcc-login/includes/class-bcc-login-settings.php index e7328b0..7baf398 100644 --- a/plugins/bcc-login/includes/class-bcc-login-settings.php +++ b/plugins/bcc-login/includes/class-bcc-login-settings.php @@ -655,7 +655,7 @@ function render_delete_subscribers_button () {

- + 'return confirm("Are you sure you want to delete all the subscribers?");' )); ?> @@ -668,7 +668,13 @@ function render_delete_subscribers_button () { * Handles deletion of subscribers */ function delete_subscribers_handler() { - if ( strpos(wp_get_referer(), 'delete_subscribers=true') !== false) { + // Only run for the form above, submitted by an administrator. The nonce stops other sites + // from submitting it on behalf of a logged-in administrator. + if ( + isset( $_POST['delete_subscribers'], $_POST['delete_subscribers_nonce'] ) && + wp_verify_nonce( $_POST['delete_subscribers_nonce'], 'delete_subscribers' ) && + current_user_can( 'administrator' ) + ) { global $wpdb; $sql = "DELETE {$wpdb->prefix}users, {$wpdb->prefix}usermeta