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 +127 -53 2.6.24 → 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;
@@ -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,21 @@
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 +
281 287 wp_send_json_success(array(
282 288 'connect_url' => $sa_connect_url,
283 289 'nonce_token' => $sa_connect_token,
284 290 'debug_info' => array(
@@ -323,9 +329,9 @@
323 329 if (!$this_auth_completed && wp_using_ext_object_cache()) {
324 330 global $wpdb;
325 331
326 332 // Check expiry first
327 - $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
328 334 $wpdb->prepare(
329 335 "SELECT option_value FROM {$wpdb->options} WHERE option_name = %s LIMIT 1",
330 336 '_transient_timeout_' . $success_key
331 337 )
@@ -332,12 +338,12 @@
332 338 );
333 339
334 340 if ($timeout && (int) $timeout < time()) {
335 341 // 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));
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
338 344 } else {
339 - $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
340 346 $wpdb->prepare(
341 347 "SELECT option_value FROM {$wpdb->options} WHERE option_name = %s LIMIT 1",
342 348 '_transient_' . $success_key
343 349 )
@@ -353,10 +359,10 @@
353 359 delete_transient($success_key);
354 360 // Also clean up DB fallback rows
355 361 if (wp_using_ext_object_cache()) {
356 362 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));
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
359 365 }
360 366
361 367 // Get current settings to return connection status
362 368 $general_settings = Metasync::get_option('general') ?? [];
@@ -557,9 +563,9 @@
557 563 $general_options = Metasync::get_option('general') ?? [];
558 564 $plugin_auth_token = $general_options['apikey'] ?? '';
559 565
560 566 if (empty($plugin_auth_token)) {
561 - 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
562 568 return false;
563 569 }
564 570
565 571 $random_bytes = wp_generate_password(32, false, false);
@@ -597,12 +603,12 @@
597 603 $timeout_active = '_transient_timeout_metasync_sa_connect_active_' . $sa_connect_token;
598 604 $timeout_metadata = '_transient_timeout_' . $transient_key;
599 605 $expires = time() + 900;
600 606
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'));
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
605 611 }
606 612
607 613 return $sa_connect_token;
608 614 }
@@ -736,9 +742,9 @@
736 742 {
737 743 global $wpdb;
738 744
739 745 try {
740 - $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
741 747 "SELECT option_name FROM {$wpdb->options} WHERE option_name LIKE '_transient_sa_connect_rate_limit_%'",
742 748 ARRAY_A
743 749 );
744 750
@@ -763,9 +769,9 @@
763 769 public function clear_jwt_token_cache()
764 770 {
765 771 global $wpdb;
766 772
767 - $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
768 774 $wpdb->prepare(
769 775 "DELETE FROM {$wpdb->options} WHERE option_name LIKE %s",
770 776 '_transient_metasync_jwt_token_%'
771 777 )
@@ -770,9 +776,9 @@
770 776 '_transient_metasync_jwt_token_%'
771 777 )
772 778 );
773 779
774 - $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
775 781 $wpdb->prepare(
776 782 "DELETE FROM {$wpdb->options} WHERE option_name LIKE %s",
777 783 '_transient_timeout_metasync_jwt_token_%'
778 784 )
@@ -896,9 +902,9 @@
896 902 try {
897 903 $response = wp_remote_post($url, $args);
898 904
899 905 if (is_wp_error($response)) {
900 - 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
901 907 return false;
902 908 }
903 909
904 910 $response_code = wp_remote_retrieve_response_code($response);
@@ -904,9 +910,9 @@
904 910 $response_code = wp_remote_retrieve_response_code($response);
905 911 $response_body = wp_remote_retrieve_body($response);
906 912
907 913 if ($response_code !== 200) {
908 - 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
909 915 return false;
910 916 }
911 917
912 918 $data = json_decode($response_body, true);
@@ -911,9 +917,9 @@
911 917
912 918 $data = json_decode($response_body, true);
913 919
914 920 if (!$data || !isset($data['token'], $data['expires'])) {
915 - 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
916 922 return false;
917 923 }
918 924
919 925 $token_data = array(
@@ -927,9 +933,9 @@
927 933
928 934 return $data['token'];
929 935
930 936 } catch (Exception $e) {
931 - 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
932 938 return false;
933 939 }
934 940 }
935 941
@@ -937,8 +943,56 @@
937 943 // Authentication reset
938 944 // ------------------------------------------------------------------
939 945
940 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 + /**
941 995 * Reset Search Atlas Authentication
942 996 * Clears all authentication data and tokens
943 997 */
944 998 public function reset_searchatlas_authentication()
@@ -1009,9 +1063,9 @@
1009 1063 # throttle timestamps out of the main blob).
1010 1064 delete_option(Metasync::heartbeat_throttle_option);
1011 1065 $cleared_data['heartbeat_throttle'] = 'removed';
1012 1066
1013 - $save_result = Metasync::set_option($options);
1067 + $save_result = $this->write_options_without_stored_merge($options);
1014 1068 Metasync::invalidate_api_key_cache();
1015 1069
1016 1070 if (!$save_result) {
1017 1071 throw new Exception('Failed to save updated plugin options');
@@ -1029,9 +1083,9 @@
1029 1083 if (isset($options['whitelabel'])) {
1030 1084 $cleared_data['whitelabel_settings'] = 'removed';
1031 1085 unset($options['whitelabel']);
1032 1086
1033 - Metasync::set_option($options);
1087 + $this->write_options_without_stored_merge($options);
1034 1088 }
1035 1089
1036 1090 $this->clear_jwt_token_cache();
1037 1091 $this->revoke_mcp_jwt_tokens();
@@ -1064,9 +1118,9 @@
1064 1118 'timestamp' => current_time('mysql', true)
1065 1119 ));
1066 1120
1067 1121 } catch (Exception $e) {
1068 - 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
1069 1123 wp_send_json_error(array(
1070 1124 'message' => 'An error occurred while resetting authentication. Please try again or contact support.',
1071 1125 'code' => 'reset_failed',
1072 1126 'error' => $e->getMessage()
@@ -1132,20 +1186,8 @@
1132 1186 ));
1133 1187 }
1134 1188
1135 1189 /**
1136 - * Simple AJAX test without nonce (for debugging connectivity)
1137 - */
1138 - public function simple_ajax_test()
1139 - {
1140 - wp_send_json_success(array(
1141 - 'message' => 'Basic AJAX connectivity works',
1142 - 'timestamp' => time(),
1143 - 'no_nonce_required' => true
1144 - ));
1145 - }
1146 -
1147 - /**
1148 1190 * Test whitelabel domain configuration (development/debugging)
1149 1191 */
1150 1192 public function test_whitelabel_domain()
1151 1193 {
@@ -1193,10 +1235,10 @@
1193 1235 if (!is_admin()) {
1194 1236 return;
1195 1237 }
1196 1238
1197 - $active_tab = isset($_GET['tab']) ? $_GET['tab'] : 'general';
1198 - $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
1199 1241
1200 1242 $whitelabel_settings = Metasync::get_whitelabel_settings();
1201 1243 $user_password = $whitelabel_settings['settings_password'] ?? '';
1202 1244 $hide_settings_enabled = !empty($whitelabel_settings['hide_settings']);
@@ -1226,17 +1268,16 @@
1226 1268 */
1227 1269 public function handle_whitelabel_session_logic()
1228 1270 {
1229 1271 $auth = new Metasync_Auth_Manager('whitelabel', 1800);
1230 - $valid_passwords = $this->get_whitelabel_valid_passwords();
1231 1272
1232 1273 if (isset($_POST['whitelabel_logout'])) {
1233 1274 if (wp_verify_nonce($_POST['whitelabel_logout_nonce'] ?? '', 'whitelabel_logout_nonce')) {
1234 1275 $auth->revoke_access();
1235 1276
1236 - $redirect_tab = $_GET['tab'] ?? 'whitelabel';
1277 + $redirect_tab = isset($_GET['tab']) ? sanitize_key(wp_unslash($_GET['tab'])) : 'whitelabel';
1237 1278 $redirect_url = admin_url('admin.php?page=' . Metasync_Admin::$page_slug . '&tab=' . $redirect_tab);
1238 - wp_redirect($redirect_url);
1279 + wp_safe_redirect($redirect_url);
1239 1280 exit;
1240 1281 }
1241 1282 }
1242 1283
@@ -1241,11 +1282,30 @@
1241 1282 }
1242 1283
1243 1284 if (isset($_POST['whitelabel_password_submit']) && isset($_POST['whitelabel_password'])) {
1244 1285 if (wp_verify_nonce($_POST['whitelabel_nonce'], 'whitelabel_password_nonce')) {
1245 - $submitted_password = sanitize_text_field($_POST['whitelabel_password']);
1286 + $submitted_password = (string) wp_unslash($_POST['whitelabel_password']);
1246 1287
1247 - $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 + }
1248 1308 }
1249 1309 }
1250 1310 }
1251 1311
@@ -1267,20 +1327,34 @@
1267 1327
1268 1328 if (($_SERVER['REQUEST_METHOD'] ?? '') === 'POST' && isset($_POST['option_page']) && $_POST['option_page'] === Metasync_Admin::option_group) {
1269 1329
1270 1330 if (isset($_POST[Metasync_Admin::option_key]['whitelabel']['settings_password'])) {
1271 - $submitted_password = sanitize_text_field(wp_unslash($_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 + });
1272 1340
1273 - $current_options = Metasync::get_option();
1341 + try {
1342 + Metasync_Admin_Ajax::instance()->refresh_recovery_state_cache();
1343 + $current_options = Metasync::get_option();
1274 1344
1275 - if (!isset($current_options['whitelabel'])) {
1276 - $current_options['whitelabel'] = [];
1277 - }
1345 + if (!isset($current_options['whitelabel'])) {
1346 + $current_options['whitelabel'] = [];
1347 + }
1278 1348
1279 - $current_options['whitelabel']['settings_password'] = Metasync::encrypt_secret($submitted_password);
1280 - $current_options['whitelabel']['updated_at'] = time();
1349 + $current_options['whitelabel']['settings_password'] = Metasync::encrypt_secret($submitted_password);
1350 + $current_options['whitelabel']['updated_at'] = time();
1281 1351
1282 - 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 + }
1283 1357 }
1284 1358 }
1285 1359 }
1286 1360