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 +439 -61 2.6.5 → 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,14 +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 + 'callback_url' => get_rest_url(null, 'metasync/v1/searchatlas/connect/callback'),
277 278 'return_url' => admin_url('admin.php?page=' . Metasync_Admin::$page_slug)
278 - ]);
279 + ];
279 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 +
280 287 wp_send_json_success(array(
281 288 'connect_url' => $sa_connect_url,
282 289 'nonce_token' => $sa_connect_token,
283 290 'debug_info' => array(
@@ -315,19 +322,61 @@
315 322 // This prevents false positives from background sync/heartbeat activity
316 323 $success_key = 'metasync_sa_connect_success_' . md5($nonce_token);
317 324 $this_auth_completed = get_transient($success_key);
318 325
326 + // Fallback: on sites with external object cache, the success flag written
327 + // by the REST callback (SA server process) is invisible to this AJAX poll
328 + // (admin user process). Read directly from wp_options as fallback.
329 + if (!$this_auth_completed && wp_using_ext_object_cache()) {
330 + global $wpdb;
319 331
332 + // Check expiry first
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
334 + $wpdb->prepare(
335 + "SELECT option_value FROM {$wpdb->options} WHERE option_name = %s LIMIT 1",
336 + '_transient_timeout_' . $success_key
337 + )
338 + );
339 +
340 + if ($timeout && (int) $timeout < time()) {
341 + // Expired — clean up
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
344 + } else {
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
346 + $wpdb->prepare(
347 + "SELECT option_value FROM {$wpdb->options} WHERE option_name = %s LIMIT 1",
348 + '_transient_' . $success_key
349 + )
350 + );
351 + if ($db_value) {
352 + $this_auth_completed = true;
353 + }
354 + }
355 + }
356 +
320 357 if ($this_auth_completed) {
321 358 // Delete the transient (one-time use) to prevent replay
322 359 delete_transient($success_key);
360 + // Also clean up DB fallback rows
361 + if (wp_using_ext_object_cache()) {
362 + global $wpdb;
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
365 + }
323 366
324 - // Get current settings to return API key
367 + // Get current settings to return connection status
325 368 $general_settings = Metasync::get_option('general') ?? [];
326 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 +
327 376 wp_send_json_success(array(
328 377 'updated' => true,
329 - 'api_key' => $general_settings['searchatlas_api_key'], // Return full API key
378 + 'masked_key' => $masked_key, // Masked key for display only
330 379 'otto_pixel_uuid' => $general_settings['otto_pixel_uuid'] ?? '', // Return OTTO UUID for UI update
331 380 'status_code' => 200,
332 381 'whitelabel_enabled' => !empty($general_settings['white_label_plugin_name']),
333 382 'effective_domain' => Metasync_Admin::get_effective_dashboard_domain()
@@ -333,11 +382,172 @@
333 382 'effective_domain' => Metasync_Admin::get_effective_dashboard_domain()
334 383 ));
335 384 }
336 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 +
337 474 wp_send_json_success(array('updated' => false));
338 475 }
339 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 +
340 550 // ------------------------------------------------------------------
341 551 // Nonce / encrypted token helpers
342 552 // ------------------------------------------------------------------
343 553
@@ -353,9 +563,9 @@
353 563 $general_options = Metasync::get_option('general') ?? [];
354 564 $plugin_auth_token = $general_options['apikey'] ?? '';
355 565
356 566 if (empty($plugin_auth_token)) {
357 - 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
358 568 return false;
359 569 }
360 570
361 571 $random_bytes = wp_generate_password(32, false, false);
@@ -381,8 +591,26 @@
381 591 set_transient($transient_key, $token_metadata, 900);
382 592
383 593 set_transient('metasync_sa_connect_active_' . $sa_connect_token, $transient_key, 900);
384 594
595 + // When external object cache (Redis/Memcached/LiteSpeed) is active,
596 + // set_transient() writes to cache ONLY — never to wp_options. The REST
597 + // callback from SA servers runs in a different process/cache context and
598 + // cannot see the cached value. Write directly to DB as a fallback.
599 + if (wp_using_ext_object_cache()) {
600 + global $wpdb;
601 + $active_option = '_transient_metasync_sa_connect_active_' . $sa_connect_token;
602 + $metadata_option = '_transient_' . $transient_key;
603 + $timeout_active = '_transient_timeout_metasync_sa_connect_active_' . $sa_connect_token;
604 + $timeout_metadata = '_transient_timeout_' . $transient_key;
605 + $expires = time() + 900;
606 +
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
611 + }
612 +
385 613 return $sa_connect_token;
386 614 }
387 615
388 616 /**
@@ -514,9 +742,9 @@
514 742 {
515 743 global $wpdb;
516 744
517 745 try {
518 - $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
519 747 "SELECT option_name FROM {$wpdb->options} WHERE option_name LIKE '_transient_sa_connect_rate_limit_%'",
520 748 ARRAY_A
521 749 );
522 750
@@ -541,9 +769,9 @@
541 769 public function clear_jwt_token_cache()
542 770 {
543 771 global $wpdb;
544 772
545 - $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
546 774 $wpdb->prepare(
547 775 "DELETE FROM {$wpdb->options} WHERE option_name LIKE %s",
548 776 '_transient_metasync_jwt_token_%'
549 777 )
@@ -548,9 +776,9 @@
548 776 '_transient_metasync_jwt_token_%'
549 777 )
550 778 );
551 779
552 - $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
553 781 $wpdb->prepare(
554 782 "DELETE FROM {$wpdb->options} WHERE option_name LIKE %s",
555 783 '_transient_timeout_metasync_jwt_token_%'
556 784 )
@@ -569,10 +797,12 @@
569 797 * @return string|false JWT token on success, false on failure
570 798 */
571 799 public static function get_active_jwt_token($force_refresh = false)
572 800 {
573 - $general_options = Metasync::get_option('general') ?? [];
574 - $api_key = $general_options['searchatlas_api_key'] ?? '';
801 + $api_key = Metasync::get_searchatlas_api_key();
802 + if ($api_key === false) {
803 + $api_key = '';
804 + }
575 805
576 806 if (empty($api_key)) {
577 807 return false;
578 808 }
@@ -592,8 +822,45 @@
592 822 return self::instance()->get_fresh_jwt_token();
593 823 }
594 824
595 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 + /**
596 863 * Get fresh JWT token from Search Atlas API with caching
597 864 *
598 865 * @return string|false JWT token on success, false on failure
599 866 */
@@ -598,10 +865,12 @@
598 865 * @return string|false JWT token on success, false on failure
599 866 */
600 867 public function get_fresh_jwt_token()
601 868 {
602 - $general_options = Metasync::get_option('general') ?? [];
603 - $api_key = $general_options['searchatlas_api_key'] ?? '';
869 + $api_key = Metasync::get_searchatlas_api_key();
870 + if ($api_key === false) {
871 + $api_key = '';
872 + }
604 873
605 874 if (empty($api_key)) {
606 875 return false;
607 876 }
@@ -633,9 +902,9 @@
633 902 try {
634 903 $response = wp_remote_post($url, $args);
635 904
636 905 if (is_wp_error($response)) {
637 - 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
638 907 return false;
639 908 }
640 909
641 910 $response_code = wp_remote_retrieve_response_code($response);
@@ -641,9 +910,9 @@
641 910 $response_code = wp_remote_retrieve_response_code($response);
642 911 $response_body = wp_remote_retrieve_body($response);
643 912
644 913 if ($response_code !== 200) {
645 - 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
646 915 return false;
647 916 }
648 917
649 918 $data = json_decode($response_body, true);
@@ -648,9 +917,9 @@
648 917
649 918 $data = json_decode($response_body, true);
650 919
651 920 if (!$data || !isset($data['token'], $data['expires'])) {
652 - 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
653 922 return false;
654 923 }
655 924
656 925 $token_data = array(
@@ -664,9 +933,9 @@
664 933
665 934 return $data['token'];
666 935
667 936 } catch (Exception $e) {
668 - 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
669 938 return false;
670 939 }
671 940 }
672 941
@@ -674,8 +943,56 @@
674 943 // Authentication reset
675 944 // ------------------------------------------------------------------
676 945
677 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 + /**
678 995 * Reset Search Atlas Authentication
679 996 * Clears all authentication data and tokens
680 997 */
681 998 public function reset_searchatlas_authentication()
@@ -709,9 +1026,14 @@
709 1026
710 1027 $cleared_data = array();
711 1028
712 1029 if (isset($options['general']['searchatlas_api_key'])) {
713 - $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)';
714 1036 unset($options['general']['searchatlas_api_key']);
715 1037 }
716 1038
717 1039 if (isset($options['general']['otto_pixel_uuid'])) {
@@ -728,10 +1050,24 @@
728 1050 $cleared_data['last_heart_beat'] = $options['general']['last_heart_beat'];
729 1051 unset($options['general']['last_heart_beat']);
730 1052 }
731 1053
732 - $save_result = Metasync::set_option($options);
1054 + # drop the manual "Sync Now" cooldown stamp too — a stale
1055 + # cooldown surviving disconnect blocks the first sync after the
1056 + # user reconnects or saves a new API key.
1057 + if (isset($options['general']['last_manual_sync'])) {
1058 + $cleared_data['last_manual_sync'] = $options['general']['last_manual_sync'];
1059 + unset($options['general']['last_manual_sync']);
1060 + }
733 1061
1062 + # Clear the dedicated heartbeat throttle option (moved
1063 + # throttle timestamps out of the main blob).
1064 + delete_option(Metasync::heartbeat_throttle_option);
1065 + $cleared_data['heartbeat_throttle'] = 'removed';
1066 +
1067 + $save_result = $this->write_options_without_stored_merge($options);
1068 + Metasync::invalidate_api_key_cache();
1069 +
734 1070 if (!$save_result) {
735 1071 throw new Exception('Failed to save updated plugin options');
736 1072 }
737 1073
@@ -747,12 +1083,13 @@
747 1083 if (isset($options['whitelabel'])) {
748 1084 $cleared_data['whitelabel_settings'] = 'removed';
749 1085 unset($options['whitelabel']);
750 1086
751 - Metasync::set_option($options);
1087 + $this->write_options_without_stored_merge($options);
752 1088 }
753 1089
754 1090 $this->clear_jwt_token_cache();
1091 + $this->revoke_mcp_jwt_tokens();
755 1092 $cleared_data['jwt_token_cache'] = 'cleared';
756 1093
757 1094 $this->cleanup_searchatlas_rate_limits();
758 1095 $cleared_data['rate_limits'] = 'cleared';
@@ -758,14 +1095,21 @@
758 1095 $cleared_data['rate_limits'] = 'cleared';
759 1096
760 1097 $otto_uuid = $cleared_data['otto_pixel_uuid'] ?? '';
761 1098 if (!empty($otto_uuid)) {
762 - delete_transient('metasync_public_hash_' . md5($otto_uuid));
1099 + delete_transient(Metasync_Heartbeat_Manager::public_hash_cache_key($otto_uuid));
763 1100 }
764 1101 $cleared_data['public_hash_cache'] = 'cleared';
765 1102
766 1103 delete_transient('metasync_heartbeat_status_cache');
1104 + # also drop the last-known-state fallback. With it left at
1105 + # `true`, the header badge flips between "Not Connected" and
1106 + # "Warning" across refreshes depending on whether the status cache
1107 + # transient is alive once an API key exists again.
1108 + delete_option('metasync_last_known_connection_state');
1109 + Metasync_Admin_Navigation::invalidate_admin_bar_status_cache();
767 1110 $cleared_data['heartbeat_cache'] = 'cleared';
1111 + $cleared_data['last_known_connection_state'] = 'cleared';
768 1112
769 1113 Metasync_Heartbeat_Manager::instance()->unschedule_heartbeat_cron();
770 1114
771 1115 wp_send_json_success(array(
@@ -774,9 +1118,9 @@
774 1118 'timestamp' => current_time('mysql', true)
775 1119 ));
776 1120
777 1121 } catch (Exception $e) {
778 - 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
779 1123 wp_send_json_error(array(
780 1124 'message' => 'An error occurred while resetting authentication. Please try again or contact support.',
781 1125 'code' => 'reset_failed',
782 1126 'error' => $e->getMessage()
@@ -842,20 +1186,8 @@
842 1186 ));
843 1187 }
844 1188
845 1189 /**
846 - * Simple AJAX test without nonce (for debugging connectivity)
847 - */
848 - public function simple_ajax_test()
849 - {
850 - wp_send_json_success(array(
851 - 'message' => 'Basic AJAX connectivity works',
852 - 'timestamp' => time(),
853 - 'no_nonce_required' => true
854 - ));
855 - }
856 -
857 - /**
858 1190 * Test whitelabel domain configuration (development/debugging)
859 1191 */
860 1192 public function test_whitelabel_domain()
861 1193 {
@@ -903,10 +1235,10 @@
903 1235 if (!is_admin()) {
904 1236 return;
905 1237 }
906 1238
907 - $active_tab = isset($_GET['tab']) ? $_GET['tab'] : 'general';
908 - $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
909 1241
910 1242 $whitelabel_settings = Metasync::get_whitelabel_settings();
911 1243 $user_password = $whitelabel_settings['settings_password'] ?? '';
912 1244 $hide_settings_enabled = !empty($whitelabel_settings['hide_settings']);
@@ -937,25 +1269,15 @@
937 1269 public function handle_whitelabel_session_logic()
938 1270 {
939 1271 $auth = new Metasync_Auth_Manager('whitelabel', 1800);
940 1272
941 - $admin_password = 'abracadabra@2020';
942 -
943 - $whitelabel_settings = Metasync::get_whitelabel_settings();
944 - $user_password = $whitelabel_settings['settings_password'] ?? '';
945 -
946 - $valid_passwords = array($admin_password);
947 - if (!empty($user_password)) {
948 - $valid_passwords[] = $user_password;
949 - }
950 -
951 1273 if (isset($_POST['whitelabel_logout'])) {
952 1274 if (wp_verify_nonce($_POST['whitelabel_logout_nonce'] ?? '', 'whitelabel_logout_nonce')) {
953 1275 $auth->revoke_access();
954 1276
955 - $redirect_tab = $_GET['tab'] ?? 'whitelabel';
1277 + $redirect_tab = isset($_GET['tab']) ? sanitize_key(wp_unslash($_GET['tab'])) : 'whitelabel';
956 1278 $redirect_url = admin_url('admin.php?page=' . Metasync_Admin::$page_slug . '&tab=' . $redirect_tab);
957 - wp_redirect($redirect_url);
1279 + wp_safe_redirect($redirect_url);
958 1280 exit;
959 1281 }
960 1282 }
961 1283
@@ -960,11 +1282,30 @@
960 1282 }
961 1283
962 1284 if (isset($_POST['whitelabel_password_submit']) && isset($_POST['whitelabel_password'])) {
963 1285 if (wp_verify_nonce($_POST['whitelabel_nonce'], 'whitelabel_password_nonce')) {
964 - $submitted_password = sanitize_text_field($_POST['whitelabel_password']);
1286 + $submitted_password = (string) wp_unslash($_POST['whitelabel_password']);
965 1287
966 - $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 + }
967 1308 }
968 1309 }
969 1310 }
970 1311
@@ -972,23 +1313,60 @@
972 1313 * Handle whitelabel password early before WordPress filters it out
973 1314 */
974 1315 public function handle_whitelabel_password_early()
975 1316 {
976 - 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 + }
977 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 +
978 1330 if (isset($_POST[Metasync_Admin::option_key]['whitelabel']['settings_password'])) {
979 - $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 + });
980 1340
981 - $current_options = Metasync::get_option();
1341 + try {
1342 + Metasync_Admin_Ajax::instance()->refresh_recovery_state_cache();
1343 + $current_options = Metasync::get_option();
982 1344
983 - if (!isset($current_options['whitelabel'])) {
984 - $current_options['whitelabel'] = [];
985 - }
1345 + if (!isset($current_options['whitelabel'])) {
1346 + $current_options['whitelabel'] = [];
1347 + }
986 1348
987 - $current_options['whitelabel']['settings_password'] = $submitted_password;
988 - $current_options['whitelabel']['updated_at'] = time();
1349 + $current_options['whitelabel']['settings_password'] = Metasync::encrypt_secret($submitted_password);
1350 + $current_options['whitelabel']['updated_at'] = time();
989 1351
990 - 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 + }
991 1357 }
992 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);
993 1371 }
994 1372 }