PluginProbe
Search Atlas SEO – OTTO AI SEO Automation for WordPress / 2.7.1
Search Atlas SEO – OTTO AI SEO Automation for WordPress v2.7.1
2.7.1 2.7.2 2.7.0 2.6.26 2.6.25 2.6.24 2.6.23 2.6.22 2.6.21 2.6.20 2.6.19 2.6.18 2.6.17 2.6.16 2.6.15 2.6.14 2.6.13 2.6.12 2.6.11 2.6.10 2.6.9 2.6.8 2.6.7 2.6.6 2.6.5 All 141 releases
← All changes | includes/class-metasync-connect-manager.php +171 -91 2.6.22 → 2.7.1 View file →
@@ -88,9 +88,9 @@
88 88 $general_options = Metasync::get_option('general') ?? [];
89 89 $plugin_auth_token = $general_options['apikey'] ?? '';
90 90
91 91 if (empty($plugin_auth_token)) {
92 - error_log('MetaSync ERROR: Plugin Auth Token missing from options - should have been generated during activation');
92 + error_log('MetaSync ERROR: Plugin Auth Token missing from options - should have been generated during activation'); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- unreachable in production
93 93 return false;
94 94 }
95 95
96 96 return $plugin_auth_token;
@@ -155,13 +155,8 @@
155 155 wp_send_json_error(array('message' => 'Invalid nonce'));
156 156 return;
157 157 }
158 158
159 - if (!current_user_can('manage_options')) {
160 - wp_send_json_error(array('message' => 'Insufficient permissions'));
161 - return;
162 - }
163 -
164 159 if (!Metasync::current_user_has_plugin_access()) {
165 160 wp_send_json_error(array('message' => 'Insufficient permissions'));
166 161 return;
167 162 }
@@ -193,9 +188,9 @@
193 188 wp_send_json_error(array('message' => 'Failed to save new token'));
194 189 }
195 190
196 191 } catch (Exception $e) {
197 - error_log('Plugin Auth Token Refresh Error: ' . $e->getMessage());
192 + error_log('Plugin Auth Token Refresh Error: ' . $e->getMessage()); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- genuine failure path, bounded, no secrets
198 193 wp_send_json_error(array('message' => 'Error generating new token'));
199 194 }
200 195 }
201 196
@@ -227,9 +222,9 @@
227 222 wp_send_json_error(array('message' => 'Plugin Auth Token not found'));
228 223 }
229 224
230 225 } catch (Exception $e) {
231 - error_log('Get Plugin Auth Token Error: ' . $e->getMessage());
226 + error_log('Get Plugin Auth Token Error: ' . $e->getMessage()); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- genuine failure path, bounded, no secrets
232 227 wp_send_json_error(array('message' => 'Error retrieving Plugin Auth Token'));
233 228 }
234 229 }
235 230
@@ -275,15 +270,21 @@
275 270 $domain = str_replace('://www.', '://', get_site_url());
276 271
277 272 $dashboard_domain = Metasync_Admin::get_effective_dashboard_domain();
278 273
279 - $sa_connect_url = $dashboard_domain . '/sso/wordpress?' . http_build_query([
274 + $connect_args = [
280 275 'nonce_token' => $sa_connect_token,
281 276 'domain' => $domain,
282 277 'callback_url' => get_rest_url(null, 'metasync/v1/searchatlas/connect/callback'),
283 278 'return_url' => admin_url('admin.php?page=' . Metasync_Admin::$page_slug)
284 - ]);
279 + ];
285 280
281 + if (Metasync_Headless_Config::is_active()) {
282 + $connect_args['frontend_domain'] = Metasync_Headless_Config::get_frontend_domain();
283 + }
284 +
285 + $sa_connect_url = $dashboard_domain . '/sso/wordpress?' . http_build_query($connect_args);
286 +
286 287 wp_send_json_success(array(
287 288 'connect_url' => $sa_connect_url,
288 289 'nonce_token' => $sa_connect_token,
289 290 'debug_info' => array(
@@ -328,9 +329,9 @@
328 329 if (!$this_auth_completed && wp_using_ext_object_cache()) {
329 330 global $wpdb;
330 331
331 332 // Check expiry first
332 - $timeout = $wpdb->get_var(
333 + $timeout = $wpdb->get_var( // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- manual transient rows on the options table (autoload=no) — mirrors core transient storage; TTL reads need the paired timeout row
333 334 $wpdb->prepare(
334 335 "SELECT option_value FROM {$wpdb->options} WHERE option_name = %s LIMIT 1",
335 336 '_transient_timeout_' . $success_key
336 337 )
@@ -337,12 +338,12 @@
337 338 );
338 339
339 340 if ($timeout && (int) $timeout < time()) {
340 341 // Expired — clean up
341 - $wpdb->delete($wpdb->options, array('option_name' => '_transient_' . $success_key));
342 - $wpdb->delete($wpdb->options, array('option_name' => '_transient_timeout_' . $success_key));
342 + $wpdb->delete($wpdb->options, array('option_name' => '_transient_' . $success_key)); // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- manual transient rows on the options table (autoload=no) — mirrors core transient storage; TTL reads need the paired timeout row
343 + $wpdb->delete($wpdb->options, array('option_name' => '_transient_timeout_' . $success_key)); // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- manual transient rows on the options table (autoload=no) — mirrors core transient storage; TTL reads need the paired timeout row
343 344 } else {
344 - $db_value = $wpdb->get_var(
345 + $db_value = $wpdb->get_var( // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- manual transient rows on the options table (autoload=no) — mirrors core transient storage; TTL reads need the paired timeout row
345 346 $wpdb->prepare(
346 347 "SELECT option_value FROM {$wpdb->options} WHERE option_name = %s LIMIT 1",
347 348 '_transient_' . $success_key
348 349 )
@@ -358,10 +359,10 @@
358 359 delete_transient($success_key);
359 360 // Also clean up DB fallback rows
360 361 if (wp_using_ext_object_cache()) {
361 362 global $wpdb;
362 - $wpdb->delete($wpdb->options, array('option_name' => '_transient_' . $success_key));
363 - $wpdb->delete($wpdb->options, array('option_name' => '_transient_timeout_' . $success_key));
363 + $wpdb->delete($wpdb->options, array('option_name' => '_transient_' . $success_key)); // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- manual transient rows on the options table (autoload=no) — mirrors core transient storage; TTL reads need the paired timeout row
364 + $wpdb->delete($wpdb->options, array('option_name' => '_transient_timeout_' . $success_key)); // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- manual transient rows on the options table (autoload=no) — mirrors core transient storage; TTL reads need the paired timeout row
364 365 }
365 366
366 367 // Get current settings to return connection status
367 368 $general_settings = Metasync::get_option('general') ?? [];
@@ -562,9 +563,9 @@
562 563 $general_options = Metasync::get_option('general') ?? [];
563 564 $plugin_auth_token = $general_options['apikey'] ?? '';
564 565
565 566 if (empty($plugin_auth_token)) {
566 - error_log('MetaSync ERROR: Plugin Auth Token missing from options');
567 + error_log('MetaSync ERROR: Plugin Auth Token missing from options'); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- genuine failure path, bounded, no secrets
567 568 return false;
568 569 }
569 570
570 571 $random_bytes = wp_generate_password(32, false, false);
@@ -602,12 +603,12 @@
602 603 $timeout_active = '_transient_timeout_metasync_sa_connect_active_' . $sa_connect_token;
603 604 $timeout_metadata = '_transient_timeout_' . $transient_key;
604 605 $expires = time() + 900;
605 606
606 - $wpdb->replace($wpdb->options, array('option_name' => $active_option, 'option_value' => $transient_key, 'autoload' => 'no'));
607 - $wpdb->replace($wpdb->options, array('option_name' => $timeout_active, 'option_value' => $expires, 'autoload' => 'no'));
608 - $wpdb->replace($wpdb->options, array('option_name' => $metadata_option, 'option_value' => maybe_serialize($token_metadata), 'autoload' => 'no'));
609 - $wpdb->replace($wpdb->options, array('option_name' => $timeout_metadata, 'option_value' => $expires, 'autoload' => 'no'));
607 + $wpdb->replace($wpdb->options, array('option_name' => $active_option, 'option_value' => $transient_key, 'autoload' => 'no')); // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- manual transient rows on the options table (autoload=no) — mirrors core transient storage; TTL reads need the paired timeout row
608 + $wpdb->replace($wpdb->options, array('option_name' => $timeout_active, 'option_value' => $expires, 'autoload' => 'no')); // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- manual transient rows on the options table (autoload=no) — mirrors core transient storage; TTL reads need the paired timeout row
609 + $wpdb->replace($wpdb->options, array('option_name' => $metadata_option, 'option_value' => maybe_serialize($token_metadata), 'autoload' => 'no')); // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- manual transient rows on the options table (autoload=no) — mirrors core transient storage; TTL reads need the paired timeout row
610 + $wpdb->replace($wpdb->options, array('option_name' => $timeout_metadata, 'option_value' => $expires, 'autoload' => 'no')); // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- manual transient rows on the options table (autoload=no) — mirrors core transient storage; TTL reads need the paired timeout row
610 611 }
611 612
612 613 return $sa_connect_token;
613 614 }
@@ -741,9 +742,9 @@
741 742 {
742 743 global $wpdb;
743 744
744 745 try {
745 - $rate_limit_transients = $wpdb->get_results(
746 + $rate_limit_transients = $wpdb->get_results( // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- transient enumeration/cleanup on the options table — no API to scan or bulk-delete transient rows
746 747 "SELECT option_name FROM {$wpdb->options} WHERE option_name LIKE '_transient_sa_connect_rate_limit_%'",
747 748 ARRAY_A
748 749 );
749 750
@@ -768,9 +769,9 @@
768 769 public function clear_jwt_token_cache()
769 770 {
770 771 global $wpdb;
771 772
772 - $deleted = $wpdb->query(
773 + $deleted = $wpdb->query( // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- manual transient rows on the options table (autoload=no) — mirrors core transient storage; TTL reads need the paired timeout row
773 774 $wpdb->prepare(
774 775 "DELETE FROM {$wpdb->options} WHERE option_name LIKE %s",
775 776 '_transient_metasync_jwt_token_%'
776 777 )
@@ -775,9 +776,9 @@
775 776 '_transient_metasync_jwt_token_%'
776 777 )
777 778 );
778 779
779 - $wpdb->query(
780 + $wpdb->query( // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- manual transient rows on the options table (autoload=no) — mirrors core transient storage; TTL reads need the paired timeout row
780 781 $wpdb->prepare(
781 782 "DELETE FROM {$wpdb->options} WHERE option_name LIKE %s",
782 783 '_transient_timeout_metasync_jwt_token_%'
783 784 )
@@ -821,46 +822,42 @@
821 822 return self::instance()->get_fresh_jwt_token();
822 823 }
823 824
824 825 /**
825 - * Return the cached JWT token, or false — never fetches a fresh one.
826 + * Rotate the MCP signing secret so all previously issued tokens are invalid.
827 + */
828 + public function revoke_mcp_jwt_tokens()
829 + {
830 + delete_option('metasync_jwt_secret');
831 + wp_cache_delete('metasync_jwt_secret', 'options');
832 + }
833 +
834 + /**
835 + * Return a valid cached JWT without making a network request.
826 836 *
827 - * get_active_jwt_token() falls through to get_fresh_jwt_token() on a cache
828 - * miss, which performs a blocking POST with a 15 second timeout. That is fine
829 - * for admin and cron work but not for anything on a visitor's page render,
830 - * where it would add up to 15 seconds to the response.
837 + * Non-blocking telemetry must never refresh credentials as a side effect.
831 838 *
832 - * Callers on a request-path should use this and simply skip whatever they
833 - * wanted the token for when it returns false. The next admin or cron request
834 - * will repopulate the cache.
835 - *
836 - * @return string|false Cached token, or false when none is cached or it is
837 - * within the expiry buffer.
839 + * @return string|false Cached JWT token, or false when none is available.
838 840 */
839 841 public static function get_cached_jwt_token()
840 842 {
841 843 $api_key = Metasync::get_searchatlas_api_key();
842 - if ($api_key === false) {
843 - $api_key = '';
844 - }
845 -
846 - if (empty($api_key)) {
844 + if ($api_key === false || $api_key === '') {
847 845 return false;
848 846 }
849 847
850 - $cached_token_data = get_transient('metasync_jwt_token_' . md5($api_key));
851 -
852 - if (!$cached_token_data || !is_array($cached_token_data)) {
848 + $cache_key = 'metasync_jwt_token_' . md5($api_key);
849 + $cached_token_data = get_transient($cache_key);
850 + if (!is_array($cached_token_data) || empty($cached_token_data['token'])) {
853 851 return false;
854 852 }
855 853
856 - # Same 5-minute expiry buffer get_active_jwt_token() applies.
857 - $expires_with_buffer = $cached_token_data['expires'] - 300;
858 - if (time() >= $expires_with_buffer || empty($cached_token_data['token'])) {
854 + $expires = isset($cached_token_data['expires']) ? (int) $cached_token_data['expires'] : 0;
855 + if ($expires <= time() + 300) {
859 856 return false;
860 857 }
861 858
862 - return $cached_token_data['token'];
859 + return (string) $cached_token_data['token'];
863 860 }
864 861
865 862 /**
866 863 * Get fresh JWT token from Search Atlas API with caching
@@ -905,9 +902,9 @@
905 902 try {
906 903 $response = wp_remote_post($url, $args);
907 904
908 905 if (is_wp_error($response)) {
909 - error_log('MetaSync: JWT token API request failed - ' . $response->get_error_message());
906 + error_log('MetaSync: JWT token API request failed - ' . $response->get_error_message()); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- genuine failure path, bounded, no secrets
910 907 return false;
911 908 }
912 909
913 910 $response_code = wp_remote_retrieve_response_code($response);
@@ -913,9 +910,9 @@
913 910 $response_code = wp_remote_retrieve_response_code($response);
914 911 $response_body = wp_remote_retrieve_body($response);
915 912
916 913 if ($response_code !== 200) {
917 - error_log('MetaSync: JWT token API returned error code ' . $response_code);
914 + error_log('MetaSync: JWT token API returned error code ' . $response_code); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- genuine failure path, bounded, no secrets
918 915 return false;
919 916 }
920 917
921 918 $data = json_decode($response_body, true);
@@ -920,9 +917,9 @@
920 917
921 918 $data = json_decode($response_body, true);
922 919
923 920 if (!$data || !isset($data['token'], $data['expires'])) {
924 - error_log('MetaSync: Invalid JWT token API response format');
921 + error_log('MetaSync: Invalid JWT token API response format'); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- genuine failure path, bounded, no secrets
925 922 return false;
926 923 }
927 924
928 925 $token_data = array(
@@ -936,9 +933,9 @@
936 933
937 934 return $data['token'];
938 935
939 936 } catch (Exception $e) {
940 - error_log('MetaSync: Exception during JWT generation - ' . $e->getMessage());
937 + error_log('MetaSync: Exception during JWT generation - ' . $e->getMessage()); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- genuine failure path, bounded, no secrets
941 938 return false;
942 939 }
943 940 }
944 941
@@ -946,8 +943,56 @@
946 943 // Authentication reset
947 944 // ------------------------------------------------------------------
948 945
949 946 /**
947 + * Save the options blob so that removing a key actually removes it.
948 + *
949 + * Metasync::set_option() goes through update_option(), and WordPress pipes
950 + * every update_option() call for a registered setting through
951 + * sanitize_option() — which runs Metasync_Settings_Registration::sanitize(),
952 + * the callback register_setting() attached. That sanitizer is built for a
953 + * Settings-API form POST, where the submission carries only the fields of
954 + * one tab: it takes the *stored* option as its base layer and merges the
955 + * incoming array over it, deliberately so that keys a partial submission
956 + * omits survive rather than being wiped.
957 + *
958 + * Correct for a form. Fatal here. Disconnecting works by unsetting keys and
959 + * saving the result, and the sanitizer re-reads the pre-write option as its
960 + * base, so every key this handler removed came straight back — the API key
961 + * byte-identical to the one just cleared. The account looked connected again
962 + * on the next page load.
963 + *
964 + * So this write detaches that callback for its own duration and restores it
965 + * afterwards. Scoped to the disconnect handler rather than changed inside
966 + * set_option(): the merge is right for the form path, and the sanitizer also
967 + * carries the whitelabel password encryption and recovery-password guards
968 + * that other programmatic writes still want.
969 + *
970 + * @param array $options The complete options array to store verbatim.
971 + * @return bool Whether the write succeeded.
972 + */
973 + private function write_options_without_stored_merge($options)
974 + {
975 + $hook = 'sanitize_option_' . Metasync::option_name;
976 +
977 + // register_setting() attaches it at the default priority against the
978 + // registration singleton, so the same callable identity removes it.
979 + $callback = class_exists('Metasync_Settings_Registration')
980 + ? array(Metasync_Settings_Registration::instance(), 'sanitize')
981 + : null;
982 +
983 + $was_attached = ($callback !== null) && remove_filter($hook, $callback);
984 +
985 + $save_result = Metasync::set_option($options);
986 +
987 + if ($was_attached) {
988 + add_filter($hook, $callback);
989 + }
990 +
991 + return $save_result;
992 + }
993 +
994 + /**
950 995 * Reset Search Atlas Authentication
951 996 * Clears all authentication data and tokens
952 997 */
953 998 public function reset_searchatlas_authentication()
@@ -1018,9 +1063,9 @@
1018 1063 # throttle timestamps out of the main blob).
1019 1064 delete_option(Metasync::heartbeat_throttle_option);
1020 1065 $cleared_data['heartbeat_throttle'] = 'removed';
1021 1066
1022 - $save_result = Metasync::set_option($options);
1067 + $save_result = $this->write_options_without_stored_merge($options);
1023 1068 Metasync::invalidate_api_key_cache();
1024 1069
1025 1070 if (!$save_result) {
1026 1071 throw new Exception('Failed to save updated plugin options');
@@ -1038,12 +1083,13 @@
1038 1083 if (isset($options['whitelabel'])) {
1039 1084 $cleared_data['whitelabel_settings'] = 'removed';
1040 1085 unset($options['whitelabel']);
1041 1086
1042 - Metasync::set_option($options);
1087 + $this->write_options_without_stored_merge($options);
1043 1088 }
1044 1089
1045 1090 $this->clear_jwt_token_cache();
1091 + $this->revoke_mcp_jwt_tokens();
1046 1092 $cleared_data['jwt_token_cache'] = 'cleared';
1047 1093
1048 1094 $this->cleanup_searchatlas_rate_limits();
1049 1095 $cleared_data['rate_limits'] = 'cleared';
@@ -1072,9 +1118,9 @@
1072 1118 'timestamp' => current_time('mysql', true)
1073 1119 ));
1074 1120
1075 1121 } catch (Exception $e) {
1076 - error_log('Authentication Reset Error: ' . $e->getMessage());
1122 + error_log('Authentication Reset Error: ' . $e->getMessage()); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- genuine failure path, bounded, no secrets
1077 1123 wp_send_json_error(array(
1078 1124 'message' => 'An error occurred while resetting authentication. Please try again or contact support.',
1079 1125 'code' => 'reset_failed',
1080 1126 'error' => $e->getMessage()
@@ -1140,20 +1186,8 @@
1140 1186 ));
1141 1187 }
1142 1188
1143 1189 /**
1144 - * Simple AJAX test without nonce (for debugging connectivity)
1145 - */
1146 - public function simple_ajax_test()
1147 - {
1148 - wp_send_json_success(array(
1149 - 'message' => 'Basic AJAX connectivity works',
1150 - 'timestamp' => time(),
1151 - 'no_nonce_required' => true
1152 - ));
1153 - }
1154 -
1155 - /**
1156 1190 * Test whitelabel domain configuration (development/debugging)
1157 1191 */
1158 1192 public function test_whitelabel_domain()
1159 1193 {
@@ -1201,10 +1235,10 @@
1201 1235 if (!is_admin()) {
1202 1236 return;
1203 1237 }
1204 1238
1205 - $active_tab = isset($_GET['tab']) ? $_GET['tab'] : 'general';
1206 - $current_page = isset($_GET['page']) ? $_GET['page'] : '';
1239 + $active_tab = isset($_GET['tab']) ? $_GET['tab'] : 'general'; // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only routing, each action verifies its own nonce
1240 + $current_page = isset($_GET['page']) ? $_GET['page'] : ''; // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only routing, each action verifies its own nonce
1207 1241
1208 1242 $whitelabel_settings = Metasync::get_whitelabel_settings();
1209 1243 $user_password = $whitelabel_settings['settings_password'] ?? '';
1210 1244 $hide_settings_enabled = !empty($whitelabel_settings['hide_settings']);
@@ -1235,25 +1269,15 @@
1235 1269 public function handle_whitelabel_session_logic()
1236 1270 {
1237 1271 $auth = new Metasync_Auth_Manager('whitelabel', 1800);
1238 1272
1239 - $admin_password = 'abracadabra@2020';
1240 -
1241 - // Decrypted plaintext for verification; '' when unset or undecryptable.
1242 - $user_password = Metasync::get_whitelabel_password();
1243 -
1244 - $valid_passwords = array($admin_password);
1245 - if (!empty($user_password)) {
1246 - $valid_passwords[] = $user_password;
1247 - }
1248 -
1249 1273 if (isset($_POST['whitelabel_logout'])) {
1250 1274 if (wp_verify_nonce($_POST['whitelabel_logout_nonce'] ?? '', 'whitelabel_logout_nonce')) {
1251 1275 $auth->revoke_access();
1252 1276
1253 - $redirect_tab = $_GET['tab'] ?? 'whitelabel';
1277 + $redirect_tab = isset($_GET['tab']) ? sanitize_key(wp_unslash($_GET['tab'])) : 'whitelabel';
1254 1278 $redirect_url = admin_url('admin.php?page=' . Metasync_Admin::$page_slug . '&tab=' . $redirect_tab);
1255 - wp_redirect($redirect_url);
1279 + wp_safe_redirect($redirect_url);
1256 1280 exit;
1257 1281 }
1258 1282 }
1259 1283
@@ -1258,11 +1282,30 @@
1258 1282 }
1259 1283
1260 1284 if (isset($_POST['whitelabel_password_submit']) && isset($_POST['whitelabel_password'])) {
1261 1285 if (wp_verify_nonce($_POST['whitelabel_nonce'], 'whitelabel_password_nonce')) {
1262 - $submitted_password = sanitize_text_field($_POST['whitelabel_password']);
1286 + $submitted_password = (string) wp_unslash($_POST['whitelabel_password']);
1263 1287
1264 - $auth->verify_and_grant($submitted_password, $valid_passwords, false);
1288 + $lock_owner = '';
1289 + if (!Metasync_Admin_Ajax::instance()->acquire_recovery_lock($lock_owner)) {
1290 + return;
1291 + }
1292 + $shutdown_lock_owner = $lock_owner;
1293 + register_shutdown_function(function () use ($shutdown_lock_owner) {
1294 + Metasync_Admin_Ajax::instance()->release_recovery_lock($shutdown_lock_owner);
1295 + });
1296 +
1297 + try {
1298 + // This request may have cached settings before a reset
1299 + // completed. Refresh after taking the shared lock, then
1300 + // verify and grant against one consistent password state.
1301 + Metasync_Admin_Ajax::instance()->refresh_recovery_state_cache();
1302 + if (function_exists('wp_cache_delete')) wp_cache_delete('metasync_auth_revoke_epoch_whitelabel', 'options');
1303 + $auth->verify_and_grant($submitted_password, $this->get_whitelabel_valid_passwords(), false);
1304 + } finally {
1305 + Metasync_Admin_Ajax::instance()->release_recovery_lock($lock_owner);
1306 + $lock_owner = '';
1307 + }
1265 1308 }
1266 1309 }
1267 1310 }
1268 1311
@@ -1270,23 +1313,60 @@
1270 1313 * Handle whitelabel password early before WordPress filters it out
1271 1314 */
1272 1315 public function handle_whitelabel_password_early()
1273 1316 {
1274 - if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['option_page']) && $_POST['option_page'] === Metasync_Admin::option_group) {
1317 + if (!current_user_can('manage_options')) {
1318 + return;
1319 + }
1275 1320
1321 + if (
1322 + !isset($_POST['meta_sync_nonce'])
1323 + || !wp_verify_nonce(wp_unslash($_POST['meta_sync_nonce']), 'meta_sync_general_setting_nonce')
1324 + ) {
1325 + return;
1326 + }
1327 +
1328 + if (($_SERVER['REQUEST_METHOD'] ?? '') === 'POST' && isset($_POST['option_page']) && $_POST['option_page'] === Metasync_Admin::option_group) {
1329 +
1276 1330 if (isset($_POST[Metasync_Admin::option_key]['whitelabel']['settings_password'])) {
1277 - $submitted_password = sanitize_text_field($_POST[Metasync_Admin::option_key]['whitelabel']['settings_password']);
1331 + $submitted_password = (string) wp_unslash($_POST[Metasync_Admin::option_key]['whitelabel']['settings_password']);
1332 + $lock_owner = '';
1333 + if (!Metasync_Admin_Ajax::instance()->acquire_recovery_lock($lock_owner)) {
1334 + return;
1335 + }
1336 + $shutdown_lock_owner = $lock_owner;
1337 + register_shutdown_function(function () use ($shutdown_lock_owner) {
1338 + Metasync_Admin_Ajax::instance()->release_recovery_lock($shutdown_lock_owner);
1339 + });
1278 1340
1279 - $current_options = Metasync::get_option();
1341 + try {
1342 + Metasync_Admin_Ajax::instance()->refresh_recovery_state_cache();
1343 + $current_options = Metasync::get_option();
1280 1344
1281 - if (!isset($current_options['whitelabel'])) {
1282 - $current_options['whitelabel'] = [];
1283 - }
1345 + if (!isset($current_options['whitelabel'])) {
1346 + $current_options['whitelabel'] = [];
1347 + }
1284 1348
1285 - $current_options['whitelabel']['settings_password'] = Metasync::encrypt_secret($submitted_password);
1286 - $current_options['whitelabel']['updated_at'] = time();
1349 + $current_options['whitelabel']['settings_password'] = Metasync::encrypt_secret($submitted_password);
1350 + $current_options['whitelabel']['updated_at'] = time();
1287 1351
1288 - update_option(Metasync_Admin::option_key, $current_options);
1352 + update_option(Metasync_Admin::option_key, $current_options);
1353 + } finally {
1354 + Metasync_Admin_Ajax::instance()->release_recovery_lock($lock_owner);
1355 + $lock_owner = '';
1356 + }
1289 1357 }
1290 1358 }
1359 + }
1360 +
1361 + /**
1362 + * Return the site-specific passwords accepted by the whitelabel gate.
1363 + *
1364 + * @return array<int, string>
1365 + */
1366 + private function get_whitelabel_valid_passwords()
1367 + {
1368 + $user_password = Metasync::get_whitelabel_password();
1369 +
1370 + return $user_password === '' ? array() : array($user_password);
1291 1371 }
1292 1372 }