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 +376 -73 2.6.16 → 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,18 +359,24 @@
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 - // Get current settings to return API key
367 + // Get current settings to return connection status
362 368 $general_settings = Metasync::get_option('general') ?? [];
363 369
370 + // Never return the cleartext key to the browser; send a masked value only.
371 + $decrypted_key = Metasync::get_searchatlas_api_key();
372 + $masked_key = (is_string($decrypted_key) && $decrypted_key !== '')
373 + ? str_repeat('*', 8) . substr($decrypted_key, -4)
374 + : '';
375 +
364 376 wp_send_json_success(array(
365 377 'updated' => true,
366 - 'api_key' => $general_settings['searchatlas_api_key'], // Return full API key
378 + 'masked_key' => $masked_key, // Masked key for display only
367 379 'otto_pixel_uuid' => $general_settings['otto_pixel_uuid'] ?? '', // Return OTTO UUID for UI update
368 380 'status_code' => 200,
369 381 'whitelabel_enabled' => !empty($general_settings['white_label_plugin_name']),
370 382 'effective_domain' => Metasync_Admin::get_effective_dashboard_domain()
@@ -370,11 +382,172 @@
370 382 'effective_domain' => Metasync_Admin::get_effective_dashboard_domain()
371 383 ));
372 384 }
373 385
386 + // WP-558 (Option A): pull-based connect fallback.
387 + // The push callback (SA server -> this site) is silently dropped by
388 + // Cloudflare/WAFs/basic-auth/"coming soon" plugins on many hosts, which
389 + // is the #1 connect failure. Outbound requests from this site are NOT
390 + // firewalled, so poll the CA endpoint for the staged result using the
391 + // same nonce token we already sent to the SSO flow. The push callback
392 + // above is checked first and stays intact as a fallback — pull is purely
393 + // additive, and mark_searchatlas_nonce_used() is idempotent.
394 + if (!empty($nonce_token) && apply_filters('metasync_enable_pull_connect', true)) {
395 + $pull = $this->poll_pull_based_connect_result($nonce_token);
396 +
397 + if (is_array($pull)) {
398 + $connect = (isset($pull['connect']) && is_array($pull['connect'])) ? $pull['connect'] : array();
399 + $pull_status_code = isset($connect['status_code']) ? intval($connect['status_code']) : 0;
400 +
401 + if ($pull['status'] === 'ready'
402 + && $pull_status_code === 200
403 + && !empty($connect['api_key'])
404 + && !empty($connect['uuid'])) {
405 +
406 + // Persist through the EXACT same path the push callback uses.
407 + // Run the pulled payload through the SAME validation +
408 + // normalization the push callback applies
409 + // (extract_and_validate_searchatlas_params) BEFORE storing, so
410 + // pull and push share identical acceptance criteria: a stringy
411 + // is_whitelabel ("false"/"0") is coerced to a real bool, and a
412 + // malformed/oversized api_key or uuid is rejected instead of
413 + // stored verbatim. Only after this is the storage provably
414 + // identical to the push body.
415 + $rest_api = new Metasync_Rest_Api('metasync', defined('METASYNC_VERSION') ? METASYNC_VERSION : '1.0.0');
416 + $validated = $rest_api->extract_and_validate_searchatlas_params($connect);
417 +
418 + if (!is_wp_error($validated)) {
419 + $stored = $rest_api->mark_searchatlas_nonce_used(
420 + $nonce_token,
421 + $validated['api_key'],
422 + $validated['uuid'],
423 + $validated['status_code'],
424 + $validated['is_whitelabel'],
425 + $validated['whitelabel_domain'],
426 + $validated['whitelabel_logo'],
427 + $validated['whitelabel_company_name'],
428 + $validated['whitelabel_otto']
429 + );
430 +
431 + if ($stored) {
432 + // mark_searchatlas_nonce_used() sets the one-time success
433 + // transient keyed by md5($nonce_token); clear it since we
434 + // report success directly here (avoids a stale replay flag).
435 + delete_transient('metasync_sa_connect_success_' . md5($nonce_token));
436 +
437 + $general_settings = Metasync::get_option('general') ?? [];
438 +
439 + // Never return the cleartext key to the browser.
440 + $decrypted_key = Metasync::get_searchatlas_api_key();
441 + $masked_key = (is_string($decrypted_key) && $decrypted_key !== '')
442 + ? str_repeat('*', 8) . substr($decrypted_key, -4)
443 + : '';
444 +
445 + wp_send_json_success(array(
446 + 'updated' => true,
447 + 'masked_key' => $masked_key,
448 + 'otto_pixel_uuid' => $general_settings['otto_pixel_uuid'] ?? '',
449 + 'status_code' => 200,
450 + 'whitelabel_enabled' => !empty($general_settings['white_label_plugin_name']),
451 + 'effective_domain' => Metasync_Admin::get_effective_dashboard_domain(),
452 + 'source' => 'pull',
453 + ));
454 + }
455 + }
456 + // A WP_Error from validation (malformed staged payload) is not
457 + // persisted; we fall through to updated:false so polling
458 + // continues and the push callback / heartbeat self-heal remain.
459 + } elseif ($pull['status'] === 'error') {
460 + // Honest state: surface the real server error (e.g. "Domain not
461 + // found for customer") instead of the silent 60s timeout. The
462 + // existing JS branches on status_code (404/500/…) to render it.
463 + wp_send_json_success(array(
464 + 'updated' => true,
465 + 'status_code' => $pull_status_code ?: 400,
466 + 'message' => isset($connect['message']) ? sanitize_text_field($connect['message']) : '',
467 + 'effective_domain' => Metasync_Admin::get_effective_dashboard_domain(),
468 + 'source' => 'pull',
469 + ));
470 + }
471 + }
472 + }
473 +
374 474 wp_send_json_success(array('updated' => false));
375 475 }
376 476
477 + /**
478 + * WP-558 (Option A): poll the CA pull-based connect-result endpoint.
479 + *
480 + * Outbound GET to `<CA base>/api/wp-plugin-connect-result/?url=<site_url>` with
481 + * the same SSO nonce sent as the `X-Plugin-Token` header. `url` uses the exact
482 + * expression the SSO flow registered (str_replace('://www.','://', get_site_url()))
483 + * so it matches the server's `(hostname, sha256(nonce))` key. Outbound traffic
484 + * from the site bypasses the Cloudflare/WAF/basic-auth layers that silently drop
485 + * the inbound push callback.
486 + *
487 + * @param string $nonce_token The SSO connect nonce (same value sent to the dashboard).
488 + * @return array|null ['status' => 'ready'|'error', 'connect' => array] when the
489 + * server has a staged result; null to keep polling (pending,
490 + * throttled, bad request, or transport failure — the push
491 + * callback remains a fallback in every null case).
492 + */
493 + private function poll_pull_based_connect_result($nonce_token)
494 + {
495 + // Server rejects tokens over 512 chars with a 400; don't waste the request.
496 + if (empty($nonce_token) || strlen($nonce_token) > 512) {
497 + return null;
498 + }
499 +
500 + $base = class_exists('Metasync_Endpoint_Manager')
501 + ? Metasync_Endpoint_Manager::get_endpoint('CA_API_DOMAIN')
502 + : (class_exists('Metasync') ? Metasync::CA_API_DOMAIN : 'https://ca.searchatlas.com');
503 +
504 + // Same base + token convention as the activation announce ping
505 + // (Metasync_Activator::send_announce_ping). Send the EXACT same domain
506 + // expression the SSO flow registered with CA — generate_searchatlas_connect_url()
507 + // uses str_replace('://www.', '://', get_site_url()) — so the server's
508 + // (hostname, sha256(nonce)) lookup matches byte-for-byte instead of relying
509 + // on server-side www normalization or home_url == site_url.
510 + $domain = str_replace('://www.', '://', get_site_url());
511 + $url = rtrim($base, '/') . '/api/wp-plugin-connect-result/?url=' . rawurlencode($domain);
512 +
513 + // Timeout kept below the JS poll cadence (5s) so a slow/unreachable CA
514 + // can't cause overlapping admin-ajax requests to stack up and pin PHP
515 + // workers for the whole connect window.
516 + $response = wp_remote_get($url, array(
517 + 'timeout' => 4,
518 + 'headers' => array(
519 + 'X-Plugin-Token' => $nonce_token,
520 + 'Accept' => 'application/json',
521 + ),
522 + ));
523 +
524 + if (is_wp_error($response)) {
525 + return null; // transient network failure — keep polling
526 + }
527 +
528 + // 400 (bad request) / 429 (throttled) / anything non-200 → keep polling
529 + // this round; the push callback and heartbeat self-heal remain in play.
530 + if ((int) wp_remote_retrieve_response_code($response) !== 200) {
531 + return null;
532 + }
533 +
534 + $data = json_decode(wp_remote_retrieve_body($response), true);
535 + if (!is_array($data) || empty($data['status'])) {
536 + return null;
537 + }
538 +
539 + // 'ready' | 'error' are actionable; 'pending' (or anything else) keeps polling.
540 + if ($data['status'] === 'ready' || $data['status'] === 'error') {
541 + return array(
542 + 'status' => $data['status'],
543 + 'connect' => (isset($data['connect']) && is_array($data['connect'])) ? $data['connect'] : array(),
544 + );
545 + }
546 +
547 + return null;
548 + }
549 +
377 550 // ------------------------------------------------------------------
378 551 // Nonce / encrypted token helpers
379 552 // ------------------------------------------------------------------
380 553
@@ -390,9 +563,9 @@
390 563 $general_options = Metasync::get_option('general') ?? [];
391 564 $plugin_auth_token = $general_options['apikey'] ?? '';
392 565
393 566 if (empty($plugin_auth_token)) {
394 - 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
395 568 return false;
396 569 }
397 570
398 571 $random_bytes = wp_generate_password(32, false, false);
@@ -430,12 +603,12 @@
430 603 $timeout_active = '_transient_timeout_metasync_sa_connect_active_' . $sa_connect_token;
431 604 $timeout_metadata = '_transient_timeout_' . $transient_key;
432 605 $expires = time() + 900;
433 606
434 - $wpdb->replace($wpdb->options, array('option_name' => $active_option, 'option_value' => $transient_key, 'autoload' => 'no'));
435 - $wpdb->replace($wpdb->options, array('option_name' => $timeout_active, 'option_value' => $expires, 'autoload' => 'no'));
436 - $wpdb->replace($wpdb->options, array('option_name' => $metadata_option, 'option_value' => maybe_serialize($token_metadata), 'autoload' => 'no'));
437 - $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
438 611 }
439 612
440 613 return $sa_connect_token;
441 614 }
@@ -569,9 +742,9 @@
569 742 {
570 743 global $wpdb;
571 744
572 745 try {
573 - $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
574 747 "SELECT option_name FROM {$wpdb->options} WHERE option_name LIKE '_transient_sa_connect_rate_limit_%'",
575 748 ARRAY_A
576 749 );
577 750
@@ -596,9 +769,9 @@
596 769 public function clear_jwt_token_cache()
597 770 {
598 771 global $wpdb;
599 772
600 - $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
601 774 $wpdb->prepare(
602 775 "DELETE FROM {$wpdb->options} WHERE option_name LIKE %s",
603 776 '_transient_metasync_jwt_token_%'
604 777 )
@@ -603,9 +776,9 @@
603 776 '_transient_metasync_jwt_token_%'
604 777 )
605 778 );
606 779
607 - $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
608 781 $wpdb->prepare(
609 782 "DELETE FROM {$wpdb->options} WHERE option_name LIKE %s",
610 783 '_transient_timeout_metasync_jwt_token_%'
611 784 )
@@ -624,10 +797,12 @@
624 797 * @return string|false JWT token on success, false on failure
625 798 */
626 799 public static function get_active_jwt_token($force_refresh = false)
627 800 {
628 - $general_options = Metasync::get_option('general') ?? [];
629 - $api_key = $general_options['searchatlas_api_key'] ?? '';
801 + $api_key = Metasync::get_searchatlas_api_key();
802 + if ($api_key === false) {
803 + $api_key = '';
804 + }
630 805
631 806 if (empty($api_key)) {
632 807 return false;
633 808 }
@@ -647,8 +822,45 @@
647 822 return self::instance()->get_fresh_jwt_token();
648 823 }
649 824
650 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 + /**
651 863 * Get fresh JWT token from Search Atlas API with caching
652 864 *
653 865 * @return string|false JWT token on success, false on failure
654 866 */
@@ -653,10 +865,12 @@
653 865 * @return string|false JWT token on success, false on failure
654 866 */
655 867 public function get_fresh_jwt_token()
656 868 {
657 - $general_options = Metasync::get_option('general') ?? [];
658 - $api_key = $general_options['searchatlas_api_key'] ?? '';
869 + $api_key = Metasync::get_searchatlas_api_key();
870 + if ($api_key === false) {
871 + $api_key = '';
872 + }
659 873
660 874 if (empty($api_key)) {
661 875 return false;
662 876 }
@@ -688,9 +902,9 @@
688 902 try {
689 903 $response = wp_remote_post($url, $args);
690 904
691 905 if (is_wp_error($response)) {
692 - 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
693 907 return false;
694 908 }
695 909
696 910 $response_code = wp_remote_retrieve_response_code($response);
@@ -696,9 +910,9 @@
696 910 $response_code = wp_remote_retrieve_response_code($response);
697 911 $response_body = wp_remote_retrieve_body($response);
698 912
699 913 if ($response_code !== 200) {
700 - 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
701 915 return false;
702 916 }
703 917
704 918 $data = json_decode($response_body, true);
@@ -703,9 +917,9 @@
703 917
704 918 $data = json_decode($response_body, true);
705 919
706 920 if (!$data || !isset($data['token'], $data['expires'])) {
707 - 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
708 922 return false;
709 923 }
710 924
711 925 $token_data = array(
@@ -719,9 +933,9 @@
719 933
720 934 return $data['token'];
721 935
722 936 } catch (Exception $e) {
723 - 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
724 938 return false;
725 939 }
726 940 }
727 941
@@ -729,8 +943,56 @@
729 943 // Authentication reset
730 944 // ------------------------------------------------------------------
731 945
732 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 + /**
733 995 * Reset Search Atlas Authentication
734 996 * Clears all authentication data and tokens
735 997 */
736 998 public function reset_searchatlas_authentication()
@@ -764,9 +1026,14 @@
764 1026
765 1027 $cleared_data = array();
766 1028
767 1029 if (isset($options['general']['searchatlas_api_key'])) {
768 - $cleared_data['searchatlas_api_key'] = substr($options['general']['searchatlas_api_key'], 0, 8) . '...';
1030 + // Record only a masked marker (last 4 chars) — never the stored
1031 + // ciphertext or the cleartext key.
1032 + $disconnect_key = Metasync::get_searchatlas_api_key();
1033 + $cleared_data['searchatlas_api_key'] = (is_string($disconnect_key) && $disconnect_key !== '')
1034 + ? str_repeat('•', 4) . substr($disconnect_key, -4)
1035 + : '(removed)';
769 1036 unset($options['general']['searchatlas_api_key']);
770 1037 }
771 1038
772 1039 if (isset($options['general']['otto_pixel_uuid'])) {
@@ -783,9 +1050,9 @@
783 1050 $cleared_data['last_heart_beat'] = $options['general']['last_heart_beat'];
784 1051 unset($options['general']['last_heart_beat']);
785 1052 }
786 1053
787 - # WP-426: drop the manual "Sync Now" cooldown stamp too — a stale
1054 + # drop the manual "Sync Now" cooldown stamp too — a stale
788 1055 # cooldown surviving disconnect blocks the first sync after the
789 1056 # user reconnects or saves a new API key.
790 1057 if (isset($options['general']['last_manual_sync'])) {
791 1058 $cleared_data['last_manual_sync'] = $options['general']['last_manual_sync'];
@@ -791,14 +1058,15 @@
791 1058 $cleared_data['last_manual_sync'] = $options['general']['last_manual_sync'];
792 1059 unset($options['general']['last_manual_sync']);
793 1060 }
794 1061
795 - # Clear the dedicated heartbeat throttle option (WP-351 moved
1062 + # Clear the dedicated heartbeat throttle option (moved
796 1063 # throttle timestamps out of the main blob).
797 1064 delete_option(Metasync::heartbeat_throttle_option);
798 1065 $cleared_data['heartbeat_throttle'] = 'removed';
799 1066
800 - $save_result = Metasync::set_option($options);
1067 + $save_result = $this->write_options_without_stored_merge($options);
1068 + Metasync::invalidate_api_key_cache();
801 1069
802 1070 if (!$save_result) {
803 1071 throw new Exception('Failed to save updated plugin options');
804 1072 }
@@ -815,12 +1083,13 @@
815 1083 if (isset($options['whitelabel'])) {
816 1084 $cleared_data['whitelabel_settings'] = 'removed';
817 1085 unset($options['whitelabel']);
818 1086
819 - Metasync::set_option($options);
1087 + $this->write_options_without_stored_merge($options);
820 1088 }
821 1089
822 1090 $this->clear_jwt_token_cache();
1091 + $this->revoke_mcp_jwt_tokens();
823 1092 $cleared_data['jwt_token_cache'] = 'cleared';
824 1093
825 1094 $this->cleanup_searchatlas_rate_limits();
826 1095 $cleared_data['rate_limits'] = 'cleared';
@@ -831,9 +1100,9 @@
831 1100 }
832 1101 $cleared_data['public_hash_cache'] = 'cleared';
833 1102
834 1103 delete_transient('metasync_heartbeat_status_cache');
835 - # WP-426: also drop the last-known-state fallback. With it left at
1104 + # also drop the last-known-state fallback. With it left at
836 1105 # `true`, the header badge flips between "Not Connected" and
837 1106 # "Warning" across refreshes depending on whether the status cache
838 1107 # transient is alive once an API key exists again.
839 1108 delete_option('metasync_last_known_connection_state');
@@ -849,9 +1118,9 @@
849 1118 'timestamp' => current_time('mysql', true)
850 1119 ));
851 1120
852 1121 } catch (Exception $e) {
853 - 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
854 1123 wp_send_json_error(array(
855 1124 'message' => 'An error occurred while resetting authentication. Please try again or contact support.',
856 1125 'code' => 'reset_failed',
857 1126 'error' => $e->getMessage()
@@ -917,20 +1186,8 @@
917 1186 ));
918 1187 }
919 1188
920 1189 /**
921 - * Simple AJAX test without nonce (for debugging connectivity)
922 - */
923 - public function simple_ajax_test()
924 - {
925 - wp_send_json_success(array(
926 - 'message' => 'Basic AJAX connectivity works',
927 - 'timestamp' => time(),
928 - 'no_nonce_required' => true
929 - ));
930 - }
931 -
932 - /**
933 1190 * Test whitelabel domain configuration (development/debugging)
934 1191 */
935 1192 public function test_whitelabel_domain()
936 1193 {
@@ -978,10 +1235,10 @@
978 1235 if (!is_admin()) {
979 1236 return;
980 1237 }
981 1238
982 - $active_tab = isset($_GET['tab']) ? $_GET['tab'] : 'general';
983 - $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
984 1241
985 1242 $whitelabel_settings = Metasync::get_whitelabel_settings();
986 1243 $user_password = $whitelabel_settings['settings_password'] ?? '';
987 1244 $hide_settings_enabled = !empty($whitelabel_settings['hide_settings']);
@@ -1012,25 +1269,15 @@
1012 1269 public function handle_whitelabel_session_logic()
1013 1270 {
1014 1271 $auth = new Metasync_Auth_Manager('whitelabel', 1800);
1015 1272
1016 - $admin_password = 'abracadabra@2020';
1017 -
1018 - // Decrypted plaintext for verification; '' when unset or undecryptable.
1019 - $user_password = Metasync::get_whitelabel_password();
1020 -
1021 - $valid_passwords = array($admin_password);
1022 - if (!empty($user_password)) {
1023 - $valid_passwords[] = $user_password;
1024 - }
1025 -
1026 1273 if (isset($_POST['whitelabel_logout'])) {
1027 1274 if (wp_verify_nonce($_POST['whitelabel_logout_nonce'] ?? '', 'whitelabel_logout_nonce')) {
1028 1275 $auth->revoke_access();
1029 1276
1030 - $redirect_tab = $_GET['tab'] ?? 'whitelabel';
1277 + $redirect_tab = isset($_GET['tab']) ? sanitize_key(wp_unslash($_GET['tab'])) : 'whitelabel';
1031 1278 $redirect_url = admin_url('admin.php?page=' . Metasync_Admin::$page_slug . '&tab=' . $redirect_tab);
1032 - wp_redirect($redirect_url);
1279 + wp_safe_redirect($redirect_url);
1033 1280 exit;
1034 1281 }
1035 1282 }
1036 1283
@@ -1035,11 +1282,30 @@
1035 1282 }
1036 1283
1037 1284 if (isset($_POST['whitelabel_password_submit']) && isset($_POST['whitelabel_password'])) {
1038 1285 if (wp_verify_nonce($_POST['whitelabel_nonce'], 'whitelabel_password_nonce')) {
1039 - $submitted_password = sanitize_text_field($_POST['whitelabel_password']);
1286 + $submitted_password = (string) wp_unslash($_POST['whitelabel_password']);
1040 1287
1041 - $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 + }
1042 1308 }
1043 1309 }
1044 1310 }
1045 1311
@@ -1047,23 +1313,60 @@
1047 1313 * Handle whitelabel password early before WordPress filters it out
1048 1314 */
1049 1315 public function handle_whitelabel_password_early()
1050 1316 {
1051 - 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 + }
1052 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 +
1053 1330 if (isset($_POST[Metasync_Admin::option_key]['whitelabel']['settings_password'])) {
1054 - $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 + });
1055 1340
1056 - $current_options = Metasync::get_option();
1341 + try {
1342 + Metasync_Admin_Ajax::instance()->refresh_recovery_state_cache();
1343 + $current_options = Metasync::get_option();
1057 1344
1058 - if (!isset($current_options['whitelabel'])) {
1059 - $current_options['whitelabel'] = [];
1060 - }
1345 + if (!isset($current_options['whitelabel'])) {
1346 + $current_options['whitelabel'] = [];
1347 + }
1061 1348
1062 - $current_options['whitelabel']['settings_password'] = Metasync::encrypt_secret($submitted_password);
1063 - $current_options['whitelabel']['updated_at'] = time();
1349 + $current_options['whitelabel']['settings_password'] = Metasync::encrypt_secret($submitted_password);
1350 + $current_options['whitelabel']['updated_at'] = time();
1064 1351
1065 - 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 + }
1066 1357 }
1067 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);
1068 1371 }
1069 1372 }