PluginProbe
Search Atlas SEO – OTTO AI SEO Automation for WordPress / 2.7.0
Search Atlas SEO – OTTO AI SEO Automation for WordPress v2.7.0
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 2.6.4 2.6.3 All 139 releases
← All changes | includes/class-metasync-connect-manager.php +163 -25 2.6.212.7.0 View file →
@@ -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(
@@ -816,8 +822,45 @@
816 822 return self::instance()->get_fresh_jwt_token();
817 823 }
818 824
819 825 /**
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.
836 + *
837 + * Non-blocking telemetry must never refresh credentials as a side effect.
838 + *
839 + * @return string|false Cached JWT token, or false when none is available.
840 + */
841 + public static function get_cached_jwt_token()
842 + {
843 + $api_key = Metasync::get_searchatlas_api_key();
844 + if ($api_key === false || $api_key === '') {
845 + return false;
846 + }
847 +
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'])) {
851 + return false;
852 + }
853 +
854 + $expires = isset($cached_token_data['expires']) ? (int) $cached_token_data['expires'] : 0;
855 + if ($expires <= time() + 300) {
856 + return false;
857 + }
858 +
859 + return (string) $cached_token_data['token'];
860 + }
861 +
862 + /**
820 863 * Get fresh JWT token from Search Atlas API with caching
821 864 *
822 865 * @return string|false JWT token on success, false on failure
823 866 */
@@ -900,8 +943,56 @@
900 943 // Authentication reset
901 944 // ------------------------------------------------------------------
902 945
903 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 + /**
904 995 * Reset Search Atlas Authentication
905 996 * Clears all authentication data and tokens
906 997 */
907 998 public function reset_searchatlas_authentication()
@@ -972,9 +1063,9 @@
972 1063 # throttle timestamps out of the main blob).
973 1064 delete_option(Metasync::heartbeat_throttle_option);
974 1065 $cleared_data['heartbeat_throttle'] = 'removed';
975 1066
976 - $save_result = Metasync::set_option($options);
1067 + $save_result = $this->write_options_without_stored_merge($options);
977 1068 Metasync::invalidate_api_key_cache();
978 1069
979 1070 if (!$save_result) {
980 1071 throw new Exception('Failed to save updated plugin options');
@@ -992,12 +1083,13 @@
992 1083 if (isset($options['whitelabel'])) {
993 1084 $cleared_data['whitelabel_settings'] = 'removed';
994 1085 unset($options['whitelabel']);
995 1086
996 - Metasync::set_option($options);
1087 + $this->write_options_without_stored_merge($options);
997 1088 }
998 1089
999 1090 $this->clear_jwt_token_cache();
1091 + $this->revoke_mcp_jwt_tokens();
1000 1092 $cleared_data['jwt_token_cache'] = 'cleared';
1001 1093
1002 1094 $this->cleanup_searchatlas_rate_limits();
1003 1095 $cleared_data['rate_limits'] = 'cleared';
@@ -1189,18 +1281,8 @@
1189 1281 public function handle_whitelabel_session_logic()
1190 1282 {
1191 1283 $auth = new Metasync_Auth_Manager('whitelabel', 1800);
1192 1284
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 1285 if (isset($_POST['whitelabel_logout'])) {
1204 1286 if (wp_verify_nonce($_POST['whitelabel_logout_nonce'] ?? '', 'whitelabel_logout_nonce')) {
1205 1287 $auth->revoke_access();
1206 1288
@@ -1212,11 +1294,30 @@
1212 1294 }
1213 1295
1214 1296 if (isset($_POST['whitelabel_password_submit']) && isset($_POST['whitelabel_password'])) {
1215 1297 if (wp_verify_nonce($_POST['whitelabel_nonce'], 'whitelabel_password_nonce')) {
1216 - $submitted_password = sanitize_text_field($_POST['whitelabel_password']);
1298 + $submitted_password = (string) wp_unslash($_POST['whitelabel_password']);
1217 1299
1218 - $auth->verify_and_grant($submitted_password, $valid_passwords, false);
1300 + $lock_owner = '';
1301 + if (!Metasync_Admin_Ajax::instance()->acquire_recovery_lock($lock_owner)) {
1302 + return;
1303 + }
1304 + $shutdown_lock_owner = $lock_owner;
1305 + register_shutdown_function(function () use ($shutdown_lock_owner) {
1306 + Metasync_Admin_Ajax::instance()->release_recovery_lock($shutdown_lock_owner);
1307 + });
1308 +
1309 + try {
1310 + // This request may have cached settings before a reset
1311 + // completed. Refresh after taking the shared lock, then
1312 + // verify and grant against one consistent password state.
1313 + Metasync_Admin_Ajax::instance()->refresh_recovery_state_cache();
1314 + if (function_exists('wp_cache_delete')) wp_cache_delete('metasync_auth_revoke_epoch_whitelabel', 'options');
1315 + $auth->verify_and_grant($submitted_password, $this->get_whitelabel_valid_passwords(), false);
1316 + } finally {
1317 + Metasync_Admin_Ajax::instance()->release_recovery_lock($lock_owner);
1318 + $lock_owner = '';
1319 + }
1219 1320 }
1220 1321 }
1221 1322 }
1222 1323
@@ -1224,23 +1325,60 @@
1224 1325 * Handle whitelabel password early before WordPress filters it out
1225 1326 */
1226 1327 public function handle_whitelabel_password_early()
1227 1328 {
1228 - if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['option_page']) && $_POST['option_page'] === Metasync_Admin::option_group) {
1329 + if (!current_user_can('manage_options')) {
1330 + return;
1331 + }
1229 1332
1333 + if (
1334 + !isset($_POST['meta_sync_nonce'])
1335 + || !wp_verify_nonce(wp_unslash($_POST['meta_sync_nonce']), 'meta_sync_general_setting_nonce')
1336 + ) {
1337 + return;
1338 + }
1339 +
1340 + if (($_SERVER['REQUEST_METHOD'] ?? '') === 'POST' && isset($_POST['option_page']) && $_POST['option_page'] === Metasync_Admin::option_group) {
1341 +
1230 1342 if (isset($_POST[Metasync_Admin::option_key]['whitelabel']['settings_password'])) {
1231 - $submitted_password = sanitize_text_field($_POST[Metasync_Admin::option_key]['whitelabel']['settings_password']);
1343 + $submitted_password = (string) wp_unslash($_POST[Metasync_Admin::option_key]['whitelabel']['settings_password']);
1344 + $lock_owner = '';
1345 + if (!Metasync_Admin_Ajax::instance()->acquire_recovery_lock($lock_owner)) {
1346 + return;
1347 + }
1348 + $shutdown_lock_owner = $lock_owner;
1349 + register_shutdown_function(function () use ($shutdown_lock_owner) {
1350 + Metasync_Admin_Ajax::instance()->release_recovery_lock($shutdown_lock_owner);
1351 + });
1232 1352
1233 - $current_options = Metasync::get_option();
1353 + try {
1354 + Metasync_Admin_Ajax::instance()->refresh_recovery_state_cache();
1355 + $current_options = Metasync::get_option();
1234 1356
1235 - if (!isset($current_options['whitelabel'])) {
1236 - $current_options['whitelabel'] = [];
1237 - }
1357 + if (!isset($current_options['whitelabel'])) {
1358 + $current_options['whitelabel'] = [];
1359 + }
1238 1360
1239 - $current_options['whitelabel']['settings_password'] = Metasync::encrypt_secret($submitted_password);
1240 - $current_options['whitelabel']['updated_at'] = time();
1361 + $current_options['whitelabel']['settings_password'] = Metasync::encrypt_secret($submitted_password);
1362 + $current_options['whitelabel']['updated_at'] = time();
1241 1363
1242 - update_option(Metasync_Admin::option_key, $current_options);
1364 + update_option(Metasync_Admin::option_key, $current_options);
1365 + } finally {
1366 + Metasync_Admin_Ajax::instance()->release_recovery_lock($lock_owner);
1367 + $lock_owner = '';
1368 + }
1243 1369 }
1244 1370 }
1371 + }
1372 +
1373 + /**
1374 + * Return the site-specific passwords accepted by the whitelabel gate.
1375 + *
1376 + * @return array<int, string>
1377 + */
1378 + private function get_whitelabel_valid_passwords()
1379 + {
1380 + $user_password = Metasync::get_whitelabel_password();
1381 +
1382 + return $user_password === '' ? array() : array($user_password);
1245 1383 }
1246 1384 }