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 +211 -99 2.6.23 → 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;
@@ -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,28 @@
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 +
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 +
286 294 wp_send_json_success(array(
287 295 'connect_url' => $sa_connect_url,
288 296 'nonce_token' => $sa_connect_token,
289 297 'debug_info' => array(
@@ -328,9 +336,9 @@
328 336 if (!$this_auth_completed && wp_using_ext_object_cache()) {
329 337 global $wpdb;
330 338
331 339 // Check expiry first
332 - $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
333 341 $wpdb->prepare(
334 342 "SELECT option_value FROM {$wpdb->options} WHERE option_name = %s LIMIT 1",
335 343 '_transient_timeout_' . $success_key
336 344 )
@@ -337,12 +345,12 @@
337 345 );
338 346
339 347 if ($timeout && (int) $timeout < time()) {
340 348 // 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));
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
343 351 } else {
344 - $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
345 353 $wpdb->prepare(
346 354 "SELECT option_value FROM {$wpdb->options} WHERE option_name = %s LIMIT 1",
347 355 '_transient_' . $success_key
348 356 )
@@ -358,10 +366,10 @@
358 366 delete_transient($success_key);
359 367 // Also clean up DB fallback rows
360 368 if (wp_using_ext_object_cache()) {
361 369 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));
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
364 372 }
365 373
366 374 // Get current settings to return connection status
367 375 $general_settings = Metasync::get_option('general') ?? [];
@@ -458,8 +466,13 @@
458 466 } elseif ($pull['status'] === 'error') {
459 467 // Honest state: surface the real server error (e.g. "Domain not
460 468 // found for customer") instead of the silent 60s timeout. The
461 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);
462 475 wp_send_json_success(array(
463 476 'updated' => true,
464 477 'status_code' => $pull_status_code ?: 400,
465 478 'message' => isset($connect['message']) ? sanitize_text_field($connect['message']) : '',
@@ -562,9 +575,9 @@
562 575 $general_options = Metasync::get_option('general') ?? [];
563 576 $plugin_auth_token = $general_options['apikey'] ?? '';
564 577
565 578 if (empty($plugin_auth_token)) {
566 - 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
567 580 return false;
568 581 }
569 582
570 583 $random_bytes = wp_generate_password(32, false, false);
@@ -602,12 +615,12 @@
602 615 $timeout_active = '_transient_timeout_metasync_sa_connect_active_' . $sa_connect_token;
603 616 $timeout_metadata = '_transient_timeout_' . $transient_key;
604 617 $expires = time() + 900;
605 618
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'));
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
610 623 }
611 624
612 625 return $sa_connect_token;
613 626 }
@@ -741,9 +754,9 @@
741 754 {
742 755 global $wpdb;
743 756
744 757 try {
745 - $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
746 759 "SELECT option_name FROM {$wpdb->options} WHERE option_name LIKE '_transient_sa_connect_rate_limit_%'",
747 760 ARRAY_A
748 761 );
749 762
@@ -768,9 +781,9 @@
768 781 public function clear_jwt_token_cache()
769 782 {
770 783 global $wpdb;
771 784
772 - $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
773 786 $wpdb->prepare(
774 787 "DELETE FROM {$wpdb->options} WHERE option_name LIKE %s",
775 788 '_transient_metasync_jwt_token_%'
776 789 )
@@ -775,9 +788,9 @@
775 788 '_transient_metasync_jwt_token_%'
776 789 )
777 790 );
778 791
779 - $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
780 793 $wpdb->prepare(
781 794 "DELETE FROM {$wpdb->options} WHERE option_name LIKE %s",
782 795 '_transient_timeout_metasync_jwt_token_%'
783 796 )
@@ -821,46 +834,42 @@
821 834 return self::instance()->get_fresh_jwt_token();
822 835 }
823 836
824 837 /**
825 - * Return the cached JWT token, or false — never fetches a fresh one.
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.
826 848 *
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.
849 + * Non-blocking telemetry must never refresh credentials as a side effect.
831 850 *
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.
851 + * @return string|false Cached JWT token, or false when none is available.
838 852 */
839 853 public static function get_cached_jwt_token()
840 854 {
841 855 $api_key = Metasync::get_searchatlas_api_key();
842 - if ($api_key === false) {
843 - $api_key = '';
844 - }
845 -
846 - if (empty($api_key)) {
856 + if ($api_key === false || $api_key === '') {
847 857 return false;
848 858 }
849 859
850 - $cached_token_data = get_transient('metasync_jwt_token_' . md5($api_key));
851 -
852 - if (!$cached_token_data || !is_array($cached_token_data)) {
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'])) {
853 863 return false;
854 864 }
855 865
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'])) {
866 + $expires = isset($cached_token_data['expires']) ? (int) $cached_token_data['expires'] : 0;
867 + if ($expires <= time() + 300) {
859 868 return false;
860 869 }
861 870
862 - return $cached_token_data['token'];
871 + return (string) $cached_token_data['token'];
863 872 }
864 873
865 874 /**
866 875 * Get fresh JWT token from Search Atlas API with caching
@@ -905,9 +914,9 @@
905 914 try {
906 915 $response = wp_remote_post($url, $args);
907 916
908 917 if (is_wp_error($response)) {
909 - 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
910 919 return false;
911 920 }
912 921
913 922 $response_code = wp_remote_retrieve_response_code($response);
@@ -913,9 +922,9 @@
913 922 $response_code = wp_remote_retrieve_response_code($response);
914 923 $response_body = wp_remote_retrieve_body($response);
915 924
916 925 if ($response_code !== 200) {
917 - 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
918 927 return false;
919 928 }
920 929
921 930 $data = json_decode($response_body, true);
@@ -920,9 +929,9 @@
920 929
921 930 $data = json_decode($response_body, true);
922 931
923 932 if (!$data || !isset($data['token'], $data['expires'])) {
924 - 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
925 934 return false;
926 935 }
927 936
928 937 $token_data = array(
@@ -936,9 +945,9 @@
936 945
937 946 return $data['token'];
938 947
939 948 } catch (Exception $e) {
940 - 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
941 950 return false;
942 951 }
943 952 }
944 953
@@ -946,8 +955,56 @@
946 955 // Authentication reset
947 956 // ------------------------------------------------------------------
948 957
949 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 + /**
950 1007 * Reset Search Atlas Authentication
951 1008 * Clears all authentication data and tokens
952 1009 */
953 1010 public function reset_searchatlas_authentication()
@@ -1018,9 +1075,9 @@
1018 1075 # throttle timestamps out of the main blob).
1019 1076 delete_option(Metasync::heartbeat_throttle_option);
1020 1077 $cleared_data['heartbeat_throttle'] = 'removed';
1021 1078
1022 - $save_result = Metasync::set_option($options);
1079 + $save_result = $this->write_options_without_stored_merge($options);
1023 1080 Metasync::invalidate_api_key_cache();
1024 1081
1025 1082 if (!$save_result) {
1026 1083 throw new Exception('Failed to save updated plugin options');
@@ -1031,19 +1088,18 @@
1031 1088
1032 1089 $cleaned_tokens = $this->cleanup_searchatlas_nonce_tokens();
1033 1090 $cleared_data['sa_connect_nonce_tokens'] = 'none (simplified token system)';
1034 1091
1035 - delete_option(Metasync::option_name . '_whitelabel_user');
1036 - $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.
1037 1099
1038 - if (isset($options['whitelabel'])) {
1039 - $cleared_data['whitelabel_settings'] = 'removed';
1040 - unset($options['whitelabel']);
1041 -
1042 - Metasync::set_option($options);
1043 - }
1044 -
1045 1100 $this->clear_jwt_token_cache();
1101 + $this->revoke_mcp_jwt_tokens();
1046 1102 $cleared_data['jwt_token_cache'] = 'cleared';
1047 1103
1048 1104 $this->cleanup_searchatlas_rate_limits();
1049 1105 $cleared_data['rate_limits'] = 'cleared';
@@ -1065,8 +1121,30 @@
1065 1121 $cleared_data['last_known_connection_state'] = 'cleared';
1066 1122
1067 1123 Metasync_Heartbeat_Manager::instance()->unschedule_heartbeat_cron();
1068 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 +
1069 1147 wp_send_json_success(array(
1070 1148 'message' => 'Authentication has been reset successfully. You can now connect a new account.',
1071 1149 'cleared_data' => $cleared_data,
1072 1150 'timestamp' => current_time('mysql', true)
@@ -1072,9 +1150,9 @@
1072 1150 'timestamp' => current_time('mysql', true)
1073 1151 ));
1074 1152
1075 1153 } catch (Exception $e) {
1076 - 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
1077 1155 wp_send_json_error(array(
1078 1156 'message' => 'An error occurred while resetting authentication. Please try again or contact support.',
1079 1157 'code' => 'reset_failed',
1080 1158 'error' => $e->getMessage()
@@ -1140,20 +1218,8 @@
1140 1218 ));
1141 1219 }
1142 1220
1143 1221 /**
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 1222 * Test whitelabel domain configuration (development/debugging)
1157 1223 */
1158 1224 public function test_whitelabel_domain()
1159 1225 {
@@ -1201,10 +1267,10 @@
1201 1267 if (!is_admin()) {
1202 1268 return;
1203 1269 }
1204 1270
1205 - $active_tab = isset($_GET['tab']) ? $_GET['tab'] : 'general';
1206 - $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
1207 1273
1208 1274 $whitelabel_settings = Metasync::get_whitelabel_settings();
1209 1275 $user_password = $whitelabel_settings['settings_password'] ?? '';
1210 1276 $hide_settings_enabled = !empty($whitelabel_settings['hide_settings']);
@@ -1235,25 +1301,15 @@
1235 1301 public function handle_whitelabel_session_logic()
1236 1302 {
1237 1303 $auth = new Metasync_Auth_Manager('whitelabel', 1800);
1238 1304
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 1305 if (isset($_POST['whitelabel_logout'])) {
1250 1306 if (wp_verify_nonce($_POST['whitelabel_logout_nonce'] ?? '', 'whitelabel_logout_nonce')) {
1251 1307 $auth->revoke_access();
1252 1308
1253 - $redirect_tab = $_GET['tab'] ?? 'whitelabel';
1309 + $redirect_tab = isset($_GET['tab']) ? sanitize_key(wp_unslash($_GET['tab'])) : 'whitelabel';
1254 1310 $redirect_url = admin_url('admin.php?page=' . Metasync_Admin::$page_slug . '&tab=' . $redirect_tab);
1255 - wp_redirect($redirect_url);
1311 + wp_safe_redirect($redirect_url);
1256 1312 exit;
1257 1313 }
1258 1314 }
1259 1315
@@ -1258,11 +1314,30 @@
1258 1314 }
1259 1315
1260 1316 if (isset($_POST['whitelabel_password_submit']) && isset($_POST['whitelabel_password'])) {
1261 1317 if (wp_verify_nonce($_POST['whitelabel_nonce'], 'whitelabel_password_nonce')) {
1262 - $submitted_password = sanitize_text_field($_POST['whitelabel_password']);
1318 + $submitted_password = (string) wp_unslash($_POST['whitelabel_password']);
1263 1319
1264 - $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 + }
1265 1340 }
1266 1341 }
1267 1342 }
1268 1343
@@ -1270,23 +1345,60 @@
1270 1345 * Handle whitelabel password early before WordPress filters it out
1271 1346 */
1272 1347 public function handle_whitelabel_password_early()
1273 1348 {
1274 - 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 + }
1275 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 +
1276 1362 if (isset($_POST[Metasync_Admin::option_key]['whitelabel']['settings_password'])) {
1277 - $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 + });
1278 1372
1279 - $current_options = Metasync::get_option();
1373 + try {
1374 + Metasync_Admin_Ajax::instance()->refresh_recovery_state_cache();
1375 + $current_options = Metasync::get_option();
1280 1376
1281 - if (!isset($current_options['whitelabel'])) {
1282 - $current_options['whitelabel'] = [];
1283 - }
1377 + if (!isset($current_options['whitelabel'])) {
1378 + $current_options['whitelabel'] = [];
1379 + }
1284 1380
1285 - $current_options['whitelabel']['settings_password'] = Metasync::encrypt_secret($submitted_password);
1286 - $current_options['whitelabel']['updated_at'] = time();
1381 + $current_options['whitelabel']['settings_password'] = Metasync::encrypt_secret($submitted_password);
1382 + $current_options['whitelabel']['updated_at'] = time();
1287 1383
1288 - 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 + }
1289 1389 }
1290 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);
1291 1403 }
1292 1404 }