PluginProbe
Search Atlas SEO – OTTO AI SEO Automation for WordPress / 2.7.2
Search Atlas SEO – OTTO AI SEO Automation for WordPress v2.7.2
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 +229 -71 2.6.20 → 2.7.2 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;
@@ -188,9 +188,9 @@
188 188 wp_send_json_error(array('message' => 'Failed to save new token'));
189 189 }
190 190
191 191 } catch (Exception $e) {
192 - 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
193 193 wp_send_json_error(array('message' => 'Error generating new token'));
194 194 }
195 195 }
196 196
@@ -222,9 +222,9 @@
222 222 wp_send_json_error(array('message' => 'Plugin Auth Token not found'));
223 223 }
224 224
225 225 } catch (Exception $e) {
226 - 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
227 227 wp_send_json_error(array('message' => 'Error retrieving Plugin Auth Token'));
228 228 }
229 229 }
230 230
@@ -270,15 +270,28 @@
270 270 $domain = str_replace('://www.', '://', get_site_url());
271 271
272 272 $dashboard_domain = Metasync_Admin::get_effective_dashboard_domain();
273 273
274 - $sa_connect_url = $dashboard_domain . '/sso/wordpress?' . http_build_query([
274 + $connect_args = [
275 275 'nonce_token' => $sa_connect_token,
276 276 'domain' => $domain,
277 277 'callback_url' => get_rest_url(null, 'metasync/v1/searchatlas/connect/callback'),
278 278 'return_url' => admin_url('admin.php?page=' . Metasync_Admin::$page_slug)
279 - ]);
279 + ];
280 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 +
287 + // Consent-gated analytics: the denominator for searchatlas_connected.
288 + $existing_key = Metasync::get_searchatlas_api_key();
289 + Metasync_PostHog::feature('searchatlas_connect_started', array(
290 + 'is_reconnection' => is_string($existing_key) && $existing_key !== '',
291 + 'is_whitelabel' => (bool) Metasync::is_whitelabel_enabled(),
292 + ));
293 +
281 294 wp_send_json_success(array(
282 295 'connect_url' => $sa_connect_url,
283 296 'nonce_token' => $sa_connect_token,
284 297 'debug_info' => array(
@@ -323,9 +336,9 @@
323 336 if (!$this_auth_completed && wp_using_ext_object_cache()) {
324 337 global $wpdb;
325 338
326 339 // Check expiry first
327 - $timeout = $wpdb->get_var(
340 + $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
328 341 $wpdb->prepare(
329 342 "SELECT option_value FROM {$wpdb->options} WHERE option_name = %s LIMIT 1",
330 343 '_transient_timeout_' . $success_key
331 344 )
@@ -332,12 +345,12 @@
332 345 );
333 346
334 347 if ($timeout && (int) $timeout < time()) {
335 348 // Expired — clean up
336 - $wpdb->delete($wpdb->options, array('option_name' => '_transient_' . $success_key));
337 - $wpdb->delete($wpdb->options, array('option_name' => '_transient_timeout_' . $success_key));
349 + $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
350 + $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
338 351 } else {
339 - $db_value = $wpdb->get_var(
352 + $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
340 353 $wpdb->prepare(
341 354 "SELECT option_value FROM {$wpdb->options} WHERE option_name = %s LIMIT 1",
342 355 '_transient_' . $success_key
343 356 )
@@ -353,10 +366,10 @@
353 366 delete_transient($success_key);
354 367 // Also clean up DB fallback rows
355 368 if (wp_using_ext_object_cache()) {
356 369 global $wpdb;
357 - $wpdb->delete($wpdb->options, array('option_name' => '_transient_' . $success_key));
358 - $wpdb->delete($wpdb->options, array('option_name' => '_transient_timeout_' . $success_key));
370 + $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
371 + $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
359 372 }
360 373
361 374 // Get current settings to return connection status
362 375 $general_settings = Metasync::get_option('general') ?? [];
@@ -453,8 +466,13 @@
453 466 } elseif ($pull['status'] === 'error') {
454 467 // Honest state: surface the real server error (e.g. "Domain not
455 468 // found for customer") instead of the silent 60s timeout. The
456 469 // existing JS branches on status_code (404/500/…) to render it.
470 + // Once per connect attempt, even if the poll repeats.
471 + Metasync_PostHog::feature_once('searchatlas_connect_failed', array(
472 + 'status_code' => $pull_status_code ?: 400,
473 + 'source' => 'pull',
474 + ), md5($nonce_token), 15 * MINUTE_IN_SECONDS);
457 475 wp_send_json_success(array(
458 476 'updated' => true,
459 477 'status_code' => $pull_status_code ?: 400,
460 478 'message' => isset($connect['message']) ? sanitize_text_field($connect['message']) : '',
@@ -557,9 +575,9 @@
557 575 $general_options = Metasync::get_option('general') ?? [];
558 576 $plugin_auth_token = $general_options['apikey'] ?? '';
559 577
560 578 if (empty($plugin_auth_token)) {
561 - error_log('MetaSync ERROR: Plugin Auth Token missing from options');
579 + 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
562 580 return false;
563 581 }
564 582
565 583 $random_bytes = wp_generate_password(32, false, false);
@@ -597,12 +615,12 @@
597 615 $timeout_active = '_transient_timeout_metasync_sa_connect_active_' . $sa_connect_token;
598 616 $timeout_metadata = '_transient_timeout_' . $transient_key;
599 617 $expires = time() + 900;
600 618
601 - $wpdb->replace($wpdb->options, array('option_name' => $active_option, 'option_value' => $transient_key, 'autoload' => 'no'));
602 - $wpdb->replace($wpdb->options, array('option_name' => $timeout_active, 'option_value' => $expires, 'autoload' => 'no'));
603 - $wpdb->replace($wpdb->options, array('option_name' => $metadata_option, 'option_value' => maybe_serialize($token_metadata), 'autoload' => 'no'));
604 - $wpdb->replace($wpdb->options, array('option_name' => $timeout_metadata, 'option_value' => $expires, 'autoload' => 'no'));
619 + $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
620 + $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
621 + $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
622 + $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
605 623 }
606 624
607 625 return $sa_connect_token;
608 626 }
@@ -736,9 +754,9 @@
736 754 {
737 755 global $wpdb;
738 756
739 757 try {
740 - $rate_limit_transients = $wpdb->get_results(
758 + $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
741 759 "SELECT option_name FROM {$wpdb->options} WHERE option_name LIKE '_transient_sa_connect_rate_limit_%'",
742 760 ARRAY_A
743 761 );
744 762
@@ -763,9 +781,9 @@
763 781 public function clear_jwt_token_cache()
764 782 {
765 783 global $wpdb;
766 784
767 - $deleted = $wpdb->query(
785 + $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
768 786 $wpdb->prepare(
769 787 "DELETE FROM {$wpdb->options} WHERE option_name LIKE %s",
770 788 '_transient_metasync_jwt_token_%'
771 789 )
@@ -770,9 +788,9 @@
770 788 '_transient_metasync_jwt_token_%'
771 789 )
772 790 );
773 791
774 - $wpdb->query(
792 + $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
775 793 $wpdb->prepare(
776 794 "DELETE FROM {$wpdb->options} WHERE option_name LIKE %s",
777 795 '_transient_timeout_metasync_jwt_token_%'
778 796 )
@@ -816,8 +834,45 @@
816 834 return self::instance()->get_fresh_jwt_token();
817 835 }
818 836
819 837 /**
838 + * Rotate the MCP signing secret so all previously issued tokens are invalid.
839 + */
840 + public function revoke_mcp_jwt_tokens()
841 + {
842 + delete_option('metasync_jwt_secret');
843 + wp_cache_delete('metasync_jwt_secret', 'options');
844 + }
845 +
846 + /**
847 + * Return a valid cached JWT without making a network request.
848 + *
849 + * Non-blocking telemetry must never refresh credentials as a side effect.
850 + *
851 + * @return string|false Cached JWT token, or false when none is available.
852 + */
853 + public static function get_cached_jwt_token()
854 + {
855 + $api_key = Metasync::get_searchatlas_api_key();
856 + if ($api_key === false || $api_key === '') {
857 + return false;
858 + }
859 +
860 + $cache_key = 'metasync_jwt_token_' . md5($api_key);
861 + $cached_token_data = get_transient($cache_key);
862 + if (!is_array($cached_token_data) || empty($cached_token_data['token'])) {
863 + return false;
864 + }
865 +
866 + $expires = isset($cached_token_data['expires']) ? (int) $cached_token_data['expires'] : 0;
867 + if ($expires <= time() + 300) {
868 + return false;
869 + }
870 +
871 + return (string) $cached_token_data['token'];
872 + }
873 +
874 + /**
820 875 * Get fresh JWT token from Search Atlas API with caching
821 876 *
822 877 * @return string|false JWT token on success, false on failure
823 878 */
@@ -859,9 +914,9 @@
859 914 try {
860 915 $response = wp_remote_post($url, $args);
861 916
862 917 if (is_wp_error($response)) {
863 - error_log('MetaSync: JWT token API request failed - ' . $response->get_error_message());
918 + 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
864 919 return false;
865 920 }
866 921
867 922 $response_code = wp_remote_retrieve_response_code($response);
@@ -867,9 +922,9 @@
867 922 $response_code = wp_remote_retrieve_response_code($response);
868 923 $response_body = wp_remote_retrieve_body($response);
869 924
870 925 if ($response_code !== 200) {
871 - error_log('MetaSync: JWT token API returned error code ' . $response_code);
926 + 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
872 927 return false;
873 928 }
874 929
875 930 $data = json_decode($response_body, true);
@@ -874,9 +929,9 @@
874 929
875 930 $data = json_decode($response_body, true);
876 931
877 932 if (!$data || !isset($data['token'], $data['expires'])) {
878 - error_log('MetaSync: Invalid JWT token API response format');
933 + error_log('MetaSync: Invalid JWT token API response format'); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- genuine failure path, bounded, no secrets
879 934 return false;
880 935 }
881 936
882 937 $token_data = array(
@@ -890,9 +945,9 @@
890 945
891 946 return $data['token'];
892 947
893 948 } catch (Exception $e) {
894 - error_log('MetaSync: Exception during JWT generation - ' . $e->getMessage());
949 + error_log('MetaSync: Exception during JWT generation - ' . $e->getMessage()); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- genuine failure path, bounded, no secrets
895 950 return false;
896 951 }
897 952 }
898 953
@@ -900,8 +955,56 @@
900 955 // Authentication reset
901 956 // ------------------------------------------------------------------
902 957
903 958 /**
959 + * Save the options blob so that removing a key actually removes it.
960 + *
961 + * Metasync::set_option() goes through update_option(), and WordPress pipes
962 + * every update_option() call for a registered setting through
963 + * sanitize_option() — which runs Metasync_Settings_Registration::sanitize(),
964 + * the callback register_setting() attached. That sanitizer is built for a
965 + * Settings-API form POST, where the submission carries only the fields of
966 + * one tab: it takes the *stored* option as its base layer and merges the
967 + * incoming array over it, deliberately so that keys a partial submission
968 + * omits survive rather than being wiped.
969 + *
970 + * Correct for a form. Fatal here. Disconnecting works by unsetting keys and
971 + * saving the result, and the sanitizer re-reads the pre-write option as its
972 + * base, so every key this handler removed came straight back — the API key
973 + * byte-identical to the one just cleared. The account looked connected again
974 + * on the next page load.
975 + *
976 + * So this write detaches that callback for its own duration and restores it
977 + * afterwards. Scoped to the disconnect handler rather than changed inside
978 + * set_option(): the merge is right for the form path, and the sanitizer also
979 + * carries the whitelabel password encryption and recovery-password guards
980 + * that other programmatic writes still want.
981 + *
982 + * @param array $options The complete options array to store verbatim.
983 + * @return bool Whether the write succeeded.
984 + */
985 + private function write_options_without_stored_merge($options)
986 + {
987 + $hook = 'sanitize_option_' . Metasync::option_name;
988 +
989 + // register_setting() attaches it at the default priority against the
990 + // registration singleton, so the same callable identity removes it.
991 + $callback = class_exists('Metasync_Settings_Registration')
992 + ? array(Metasync_Settings_Registration::instance(), 'sanitize')
993 + : null;
994 +
995 + $was_attached = ($callback !== null) && remove_filter($hook, $callback);
996 +
997 + $save_result = Metasync::set_option($options);
998 +
999 + if ($was_attached) {
1000 + add_filter($hook, $callback);
1001 + }
1002 +
1003 + return $save_result;
1004 + }
1005 +
1006 + /**
904 1007 * Reset Search Atlas Authentication
905 1008 * Clears all authentication data and tokens
906 1009 */
907 1010 public function reset_searchatlas_authentication()
@@ -972,9 +1075,9 @@
972 1075 # throttle timestamps out of the main blob).
973 1076 delete_option(Metasync::heartbeat_throttle_option);
974 1077 $cleared_data['heartbeat_throttle'] = 'removed';
975 1078
976 - $save_result = Metasync::set_option($options);
1079 + $save_result = $this->write_options_without_stored_merge($options);
977 1080 Metasync::invalidate_api_key_cache();
978 1081
979 1082 if (!$save_result) {
980 1083 throw new Exception('Failed to save updated plugin options');
@@ -985,19 +1088,18 @@
985 1088
986 1089 $cleaned_tokens = $this->cleanup_searchatlas_nonce_tokens();
987 1090 $cleared_data['sa_connect_nonce_tokens'] = 'none (simplified token system)';
988 1091
989 - delete_option(Metasync::option_name . '_whitelabel_user');
990 - $cleared_data['whitelabel_user'] = 'removed';
1092 + // White-label identity is preserved across the authentication
1093 + // reset: the reseller's branding, logos, domains, custom OTTO
1094 + // name, visibility/access controls, password and recovery
1095 + // settings, and the dedicated whitelabel_user option must survive
1096 + // the disconnect. Only authentication tokens and credentials are
1097 + // cleared above. Do not delete the whitelabel blob or the
1098 + // whitelabel_user option here.
991 1099
992 - if (isset($options['whitelabel'])) {
993 - $cleared_data['whitelabel_settings'] = 'removed';
994 - unset($options['whitelabel']);
995 -
996 - Metasync::set_option($options);
997 - }
998 -
999 1100 $this->clear_jwt_token_cache();
1101 + $this->revoke_mcp_jwt_tokens();
1000 1102 $cleared_data['jwt_token_cache'] = 'cleared';
1001 1103
1002 1104 $this->cleanup_searchatlas_rate_limits();
1003 1105 $cleared_data['rate_limits'] = 'cleared';
@@ -1019,8 +1121,30 @@
1019 1121 $cleared_data['last_known_connection_state'] = 'cleared';
1020 1122
1021 1123 Metasync_Heartbeat_Manager::instance()->unschedule_heartbeat_cron();
1022 1124
1125 + // Consent-gated analytics: churn signal, the opposite of connected.
1126 + Metasync_PostHog::feature('searchatlas_disconnected', array(
1127 + 'method' => 'reset_auth',
1128 + 'was_connected' => isset($cleared_data['searchatlas_api_key']),
1129 + ));
1130 +
1131 + // Re-synchronise the branded plugin file headers so the WordPress
1132 + // plugin list and the plugin UI stay branded after the
1133 + // authentication reset. Only attempted when white-label branding
1134 + // exists, and a failed sync is logged as a warning, not reported
1135 + // as a reset failure: the credentials have already been cleared,
1136 + // and hosts with read-only plugin files would otherwise never see
1137 + // a successful reset.
1138 + if (Metasync::is_whitelabel_enabled()) {
1139 + if (!class_exists('Metasync_Activator')) {
1140 + require_once plugin_dir_path(dirname(__FILE__)) . 'includes/class-metasync-activator.php';
1141 + }
1142 + if (!Metasync_Activator::sync_plugin_file_headers()) {
1143 + error_log('MetaSync: failed to re-synchronise branded plugin file headers after the authentication reset.');
1144 + }
1145 + }
1146 +
1023 1147 wp_send_json_success(array(
1024 1148 'message' => 'Authentication has been reset successfully. You can now connect a new account.',
1025 1149 'cleared_data' => $cleared_data,
1026 1150 'timestamp' => current_time('mysql', true)
@@ -1026,9 +1150,9 @@
1026 1150 'timestamp' => current_time('mysql', true)
1027 1151 ));
1028 1152
1029 1153 } catch (Exception $e) {
1030 - error_log('Authentication Reset Error: ' . $e->getMessage());
1154 + error_log('Authentication Reset Error: ' . $e->getMessage()); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- genuine failure path, bounded, no secrets
1031 1155 wp_send_json_error(array(
1032 1156 'message' => 'An error occurred while resetting authentication. Please try again or contact support.',
1033 1157 'code' => 'reset_failed',
1034 1158 'error' => $e->getMessage()
@@ -1094,20 +1218,8 @@
1094 1218 ));
1095 1219 }
1096 1220
1097 1221 /**
1098 - * Simple AJAX test without nonce (for debugging connectivity)
1099 - */
1100 - public function simple_ajax_test()
1101 - {
1102 - wp_send_json_success(array(
1103 - 'message' => 'Basic AJAX connectivity works',
1104 - 'timestamp' => time(),
1105 - 'no_nonce_required' => true
1106 - ));
1107 - }
1108 -
1109 - /**
1110 1222 * Test whitelabel domain configuration (development/debugging)
1111 1223 */
1112 1224 public function test_whitelabel_domain()
1113 1225 {
@@ -1155,10 +1267,10 @@
1155 1267 if (!is_admin()) {
1156 1268 return;
1157 1269 }
1158 1270
1159 - $active_tab = isset($_GET['tab']) ? $_GET['tab'] : 'general';
1160 - $current_page = isset($_GET['page']) ? $_GET['page'] : '';
1271 + $active_tab = isset($_GET['tab']) ? $_GET['tab'] : 'general'; // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only routing, each action verifies its own nonce
1272 + $current_page = isset($_GET['page']) ? $_GET['page'] : ''; // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only routing, each action verifies its own nonce
1161 1273
1162 1274 $whitelabel_settings = Metasync::get_whitelabel_settings();
1163 1275 $user_password = $whitelabel_settings['settings_password'] ?? '';
1164 1276 $hide_settings_enabled = !empty($whitelabel_settings['hide_settings']);
@@ -1189,25 +1301,15 @@
1189 1301 public function handle_whitelabel_session_logic()
1190 1302 {
1191 1303 $auth = new Metasync_Auth_Manager('whitelabel', 1800);
1192 1304
1193 - $admin_password = 'abracadabra@2020';
1194 -
1195 - // Decrypted plaintext for verification; '' when unset or undecryptable.
1196 - $user_password = Metasync::get_whitelabel_password();
1197 -
1198 - $valid_passwords = array($admin_password);
1199 - if (!empty($user_password)) {
1200 - $valid_passwords[] = $user_password;
1201 - }
1202 -
1203 1305 if (isset($_POST['whitelabel_logout'])) {
1204 1306 if (wp_verify_nonce($_POST['whitelabel_logout_nonce'] ?? '', 'whitelabel_logout_nonce')) {
1205 1307 $auth->revoke_access();
1206 1308
1207 - $redirect_tab = $_GET['tab'] ?? 'whitelabel';
1309 + $redirect_tab = isset($_GET['tab']) ? sanitize_key(wp_unslash($_GET['tab'])) : 'whitelabel';
1208 1310 $redirect_url = admin_url('admin.php?page=' . Metasync_Admin::$page_slug . '&tab=' . $redirect_tab);
1209 - wp_redirect($redirect_url);
1311 + wp_safe_redirect($redirect_url);
1210 1312 exit;
1211 1313 }
1212 1314 }
1213 1315
@@ -1212,11 +1314,30 @@
1212 1314 }
1213 1315
1214 1316 if (isset($_POST['whitelabel_password_submit']) && isset($_POST['whitelabel_password'])) {
1215 1317 if (wp_verify_nonce($_POST['whitelabel_nonce'], 'whitelabel_password_nonce')) {
1216 - $submitted_password = sanitize_text_field($_POST['whitelabel_password']);
1318 + $submitted_password = (string) wp_unslash($_POST['whitelabel_password']);
1217 1319
1218 - $auth->verify_and_grant($submitted_password, $valid_passwords, false);
1320 + $lock_owner = '';
1321 + if (!Metasync_Admin_Ajax::instance()->acquire_recovery_lock($lock_owner)) {
1322 + return;
1323 + }
1324 + $shutdown_lock_owner = $lock_owner;
1325 + register_shutdown_function(function () use ($shutdown_lock_owner) {
1326 + Metasync_Admin_Ajax::instance()->release_recovery_lock($shutdown_lock_owner);
1327 + });
1328 +
1329 + try {
1330 + // This request may have cached settings before a reset
1331 + // completed. Refresh after taking the shared lock, then
1332 + // verify and grant against one consistent password state.
1333 + Metasync_Admin_Ajax::instance()->refresh_recovery_state_cache();
1334 + if (function_exists('wp_cache_delete')) wp_cache_delete('metasync_auth_revoke_epoch_whitelabel', 'options');
1335 + $auth->verify_and_grant($submitted_password, $this->get_whitelabel_valid_passwords(), false);
1336 + } finally {
1337 + Metasync_Admin_Ajax::instance()->release_recovery_lock($lock_owner);
1338 + $lock_owner = '';
1339 + }
1219 1340 }
1220 1341 }
1221 1342 }
1222 1343
@@ -1224,23 +1345,60 @@
1224 1345 * Handle whitelabel password early before WordPress filters it out
1225 1346 */
1226 1347 public function handle_whitelabel_password_early()
1227 1348 {
1228 - if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['option_page']) && $_POST['option_page'] === Metasync_Admin::option_group) {
1349 + if (!current_user_can('manage_options')) {
1350 + return;
1351 + }
1229 1352
1353 + if (
1354 + !isset($_POST['meta_sync_nonce'])
1355 + || !wp_verify_nonce(wp_unslash($_POST['meta_sync_nonce']), 'meta_sync_general_setting_nonce')
1356 + ) {
1357 + return;
1358 + }
1359 +
1360 + if (($_SERVER['REQUEST_METHOD'] ?? '') === 'POST' && isset($_POST['option_page']) && $_POST['option_page'] === Metasync_Admin::option_group) {
1361 +
1230 1362 if (isset($_POST[Metasync_Admin::option_key]['whitelabel']['settings_password'])) {
1231 - $submitted_password = sanitize_text_field($_POST[Metasync_Admin::option_key]['whitelabel']['settings_password']);
1363 + $submitted_password = (string) wp_unslash($_POST[Metasync_Admin::option_key]['whitelabel']['settings_password']);
1364 + $lock_owner = '';
1365 + if (!Metasync_Admin_Ajax::instance()->acquire_recovery_lock($lock_owner)) {
1366 + return;
1367 + }
1368 + $shutdown_lock_owner = $lock_owner;
1369 + register_shutdown_function(function () use ($shutdown_lock_owner) {
1370 + Metasync_Admin_Ajax::instance()->release_recovery_lock($shutdown_lock_owner);
1371 + });
1232 1372
1233 - $current_options = Metasync::get_option();
1373 + try {
1374 + Metasync_Admin_Ajax::instance()->refresh_recovery_state_cache();
1375 + $current_options = Metasync::get_option();
1234 1376
1235 - if (!isset($current_options['whitelabel'])) {
1236 - $current_options['whitelabel'] = [];
1237 - }
1377 + if (!isset($current_options['whitelabel'])) {
1378 + $current_options['whitelabel'] = [];
1379 + }
1238 1380
1239 - $current_options['whitelabel']['settings_password'] = Metasync::encrypt_secret($submitted_password);
1240 - $current_options['whitelabel']['updated_at'] = time();
1381 + $current_options['whitelabel']['settings_password'] = Metasync::encrypt_secret($submitted_password);
1382 + $current_options['whitelabel']['updated_at'] = time();
1241 1383
1242 - update_option(Metasync_Admin::option_key, $current_options);
1384 + update_option(Metasync_Admin::option_key, $current_options);
1385 + } finally {
1386 + Metasync_Admin_Ajax::instance()->release_recovery_lock($lock_owner);
1387 + $lock_owner = '';
1388 + }
1243 1389 }
1244 1390 }
1391 + }
1392 +
1393 + /**
1394 + * Return the site-specific passwords accepted by the whitelabel gate.
1395 + *
1396 + * @return array<int, string>
1397 + */
1398 + private function get_whitelabel_valid_passwords()
1399 + {
1400 + $user_password = Metasync::get_whitelabel_password();
1401 +
1402 + return $user_password === '' ? array() : array($user_password);
1245 1403 }
1246 1404 }