PluginProbe
Search Atlas SEO – OTTO AI SEO Automation for WordPress / 2.7.2
Search Atlas SEO – OTTO AI SEO Automation for WordPress v2.7.2
2.7.1 2.7.2 2.7.0 2.6.26 2.6.25 2.6.24 2.6.23 2.6.22 2.6.21 2.6.20 2.6.19 2.6.18 2.6.17 2.6.16 2.6.15 2.6.14 2.6.13 2.6.12 2.6.11 2.6.10 2.6.9 2.6.8 2.6.7 2.6.6 2.6.5 All 141 releases
← All changes | includes/class-metasync-connect-manager.php +479 -69 2.6.5 → 2.7.2 View file →
@@ -88,9 +88,9 @@
88 88 $general_options = Metasync::get_option('general') ?? [];
89 89 $plugin_auth_token = $general_options['apikey'] ?? '';
90 90
91 91 if (empty($plugin_auth_token)) {
92 - error_log('MetaSync ERROR: Plugin Auth Token missing from options - should have been generated during activation');
92 + error_log('MetaSync ERROR: Plugin Auth Token missing from options - should have been generated during activation'); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- unreachable in production
93 93 return false;
94 94 }
95 95
96 96 return $plugin_auth_token;
@@ -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,28 @@
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 +
287 + // Consent-gated analytics: the denominator for searchatlas_connected.
288 + $existing_key = Metasync::get_searchatlas_api_key();
289 + Metasync_PostHog::feature('searchatlas_connect_started', array(
290 + 'is_reconnection' => is_string($existing_key) && $existing_key !== '',
291 + 'is_whitelabel' => (bool) Metasync::is_whitelabel_enabled(),
292 + ));
293 +
280 294 wp_send_json_success(array(
281 295 'connect_url' => $sa_connect_url,
282 296 'nonce_token' => $sa_connect_token,
283 297 'debug_info' => array(
@@ -315,19 +329,61 @@
315 329 // This prevents false positives from background sync/heartbeat activity
316 330 $success_key = 'metasync_sa_connect_success_' . md5($nonce_token);
317 331 $this_auth_completed = get_transient($success_key);
318 332
333 + // Fallback: on sites with external object cache, the success flag written
334 + // by the REST callback (SA server process) is invisible to this AJAX poll
335 + // (admin user process). Read directly from wp_options as fallback.
336 + if (!$this_auth_completed && wp_using_ext_object_cache()) {
337 + global $wpdb;
319 338
339 + // Check expiry first
340 + $timeout = $wpdb->get_var( // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- manual transient rows on the options table (autoload=no) — mirrors core transient storage; TTL reads need the paired timeout row
341 + $wpdb->prepare(
342 + "SELECT option_value FROM {$wpdb->options} WHERE option_name = %s LIMIT 1",
343 + '_transient_timeout_' . $success_key
344 + )
345 + );
346 +
347 + if ($timeout && (int) $timeout < time()) {
348 + // Expired — clean up
349 + $wpdb->delete($wpdb->options, array('option_name' => '_transient_' . $success_key)); // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- manual transient rows on the options table (autoload=no) — mirrors core transient storage; TTL reads need the paired timeout row
350 + $wpdb->delete($wpdb->options, array('option_name' => '_transient_timeout_' . $success_key)); // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- manual transient rows on the options table (autoload=no) — mirrors core transient storage; TTL reads need the paired timeout row
351 + } else {
352 + $db_value = $wpdb->get_var( // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- manual transient rows on the options table (autoload=no) — mirrors core transient storage; TTL reads need the paired timeout row
353 + $wpdb->prepare(
354 + "SELECT option_value FROM {$wpdb->options} WHERE option_name = %s LIMIT 1",
355 + '_transient_' . $success_key
356 + )
357 + );
358 + if ($db_value) {
359 + $this_auth_completed = true;
360 + }
361 + }
362 + }
363 +
320 364 if ($this_auth_completed) {
321 365 // Delete the transient (one-time use) to prevent replay
322 366 delete_transient($success_key);
367 + // Also clean up DB fallback rows
368 + if (wp_using_ext_object_cache()) {
369 + global $wpdb;
370 + $wpdb->delete($wpdb->options, array('option_name' => '_transient_' . $success_key)); // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- manual transient rows on the options table (autoload=no) — mirrors core transient storage; TTL reads need the paired timeout row
371 + $wpdb->delete($wpdb->options, array('option_name' => '_transient_timeout_' . $success_key)); // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- manual transient rows on the options table (autoload=no) — mirrors core transient storage; TTL reads need the paired timeout row
372 + }
323 373
324 - // Get current settings to return API key
374 + // Get current settings to return connection status
325 375 $general_settings = Metasync::get_option('general') ?? [];
326 376
377 + // Never return the cleartext key to the browser; send a masked value only.
378 + $decrypted_key = Metasync::get_searchatlas_api_key();
379 + $masked_key = (is_string($decrypted_key) && $decrypted_key !== '')
380 + ? str_repeat('*', 8) . substr($decrypted_key, -4)
381 + : '';
382 +
327 383 wp_send_json_success(array(
328 384 'updated' => true,
329 - 'api_key' => $general_settings['searchatlas_api_key'], // Return full API key
385 + 'masked_key' => $masked_key, // Masked key for display only
330 386 'otto_pixel_uuid' => $general_settings['otto_pixel_uuid'] ?? '', // Return OTTO UUID for UI update
331 387 'status_code' => 200,
332 388 'whitelabel_enabled' => !empty($general_settings['white_label_plugin_name']),
333 389 'effective_domain' => Metasync_Admin::get_effective_dashboard_domain()
@@ -333,11 +389,177 @@
333 389 'effective_domain' => Metasync_Admin::get_effective_dashboard_domain()
334 390 ));
335 391 }
336 392
393 + // WP-558 (Option A): pull-based connect fallback.
394 + // The push callback (SA server -> this site) is silently dropped by
395 + // Cloudflare/WAFs/basic-auth/"coming soon" plugins on many hosts, which
396 + // is the #1 connect failure. Outbound requests from this site are NOT
397 + // firewalled, so poll the CA endpoint for the staged result using the
398 + // same nonce token we already sent to the SSO flow. The push callback
399 + // above is checked first and stays intact as a fallback — pull is purely
400 + // additive, and mark_searchatlas_nonce_used() is idempotent.
401 + if (!empty($nonce_token) && apply_filters('metasync_enable_pull_connect', true)) {
402 + $pull = $this->poll_pull_based_connect_result($nonce_token);
403 +
404 + if (is_array($pull)) {
405 + $connect = (isset($pull['connect']) && is_array($pull['connect'])) ? $pull['connect'] : array();
406 + $pull_status_code = isset($connect['status_code']) ? intval($connect['status_code']) : 0;
407 +
408 + if ($pull['status'] === 'ready'
409 + && $pull_status_code === 200
410 + && !empty($connect['api_key'])
411 + && !empty($connect['uuid'])) {
412 +
413 + // Persist through the EXACT same path the push callback uses.
414 + // Run the pulled payload through the SAME validation +
415 + // normalization the push callback applies
416 + // (extract_and_validate_searchatlas_params) BEFORE storing, so
417 + // pull and push share identical acceptance criteria: a stringy
418 + // is_whitelabel ("false"/"0") is coerced to a real bool, and a
419 + // malformed/oversized api_key or uuid is rejected instead of
420 + // stored verbatim. Only after this is the storage provably
421 + // identical to the push body.
422 + $rest_api = new Metasync_Rest_Api('metasync', defined('METASYNC_VERSION') ? METASYNC_VERSION : '1.0.0');
423 + $validated = $rest_api->extract_and_validate_searchatlas_params($connect);
424 +
425 + if (!is_wp_error($validated)) {
426 + $stored = $rest_api->mark_searchatlas_nonce_used(
427 + $nonce_token,
428 + $validated['api_key'],
429 + $validated['uuid'],
430 + $validated['status_code'],
431 + $validated['is_whitelabel'],
432 + $validated['whitelabel_domain'],
433 + $validated['whitelabel_logo'],
434 + $validated['whitelabel_company_name'],
435 + $validated['whitelabel_otto']
436 + );
437 +
438 + if ($stored) {
439 + // mark_searchatlas_nonce_used() sets the one-time success
440 + // transient keyed by md5($nonce_token); clear it since we
441 + // report success directly here (avoids a stale replay flag).
442 + delete_transient('metasync_sa_connect_success_' . md5($nonce_token));
443 +
444 + $general_settings = Metasync::get_option('general') ?? [];
445 +
446 + // Never return the cleartext key to the browser.
447 + $decrypted_key = Metasync::get_searchatlas_api_key();
448 + $masked_key = (is_string($decrypted_key) && $decrypted_key !== '')
449 + ? str_repeat('*', 8) . substr($decrypted_key, -4)
450 + : '';
451 +
452 + wp_send_json_success(array(
453 + 'updated' => true,
454 + 'masked_key' => $masked_key,
455 + 'otto_pixel_uuid' => $general_settings['otto_pixel_uuid'] ?? '',
456 + 'status_code' => 200,
457 + 'whitelabel_enabled' => !empty($general_settings['white_label_plugin_name']),
458 + 'effective_domain' => Metasync_Admin::get_effective_dashboard_domain(),
459 + 'source' => 'pull',
460 + ));
461 + }
462 + }
463 + // A WP_Error from validation (malformed staged payload) is not
464 + // persisted; we fall through to updated:false so polling
465 + // continues and the push callback / heartbeat self-heal remain.
466 + } elseif ($pull['status'] === 'error') {
467 + // Honest state: surface the real server error (e.g. "Domain not
468 + // found for customer") instead of the silent 60s timeout. The
469 + // existing JS branches on status_code (404/500/…) to render it.
470 + // Once per connect attempt, even if the poll repeats.
471 + Metasync_PostHog::feature_once('searchatlas_connect_failed', array(
472 + 'status_code' => $pull_status_code ?: 400,
473 + 'source' => 'pull',
474 + ), md5($nonce_token), 15 * MINUTE_IN_SECONDS);
475 + wp_send_json_success(array(
476 + 'updated' => true,
477 + 'status_code' => $pull_status_code ?: 400,
478 + 'message' => isset($connect['message']) ? sanitize_text_field($connect['message']) : '',
479 + 'effective_domain' => Metasync_Admin::get_effective_dashboard_domain(),
480 + 'source' => 'pull',
481 + ));
482 + }
483 + }
484 + }
485 +
337 486 wp_send_json_success(array('updated' => false));
338 487 }
339 488
489 + /**
490 + * WP-558 (Option A): poll the CA pull-based connect-result endpoint.
491 + *
492 + * Outbound GET to `<CA base>/api/wp-plugin-connect-result/?url=<site_url>` with
493 + * the same SSO nonce sent as the `X-Plugin-Token` header. `url` uses the exact
494 + * expression the SSO flow registered (str_replace('://www.','://', get_site_url()))
495 + * so it matches the server's `(hostname, sha256(nonce))` key. Outbound traffic
496 + * from the site bypasses the Cloudflare/WAF/basic-auth layers that silently drop
497 + * the inbound push callback.
498 + *
499 + * @param string $nonce_token The SSO connect nonce (same value sent to the dashboard).
500 + * @return array|null ['status' => 'ready'|'error', 'connect' => array] when the
501 + * server has a staged result; null to keep polling (pending,
502 + * throttled, bad request, or transport failure — the push
503 + * callback remains a fallback in every null case).
504 + */
505 + private function poll_pull_based_connect_result($nonce_token)
506 + {
507 + // Server rejects tokens over 512 chars with a 400; don't waste the request.
508 + if (empty($nonce_token) || strlen($nonce_token) > 512) {
509 + return null;
510 + }
511 +
512 + $base = class_exists('Metasync_Endpoint_Manager')
513 + ? Metasync_Endpoint_Manager::get_endpoint('CA_API_DOMAIN')
514 + : (class_exists('Metasync') ? Metasync::CA_API_DOMAIN : 'https://ca.searchatlas.com');
515 +
516 + // Same base + token convention as the activation announce ping
517 + // (Metasync_Activator::send_announce_ping). Send the EXACT same domain
518 + // expression the SSO flow registered with CA — generate_searchatlas_connect_url()
519 + // uses str_replace('://www.', '://', get_site_url()) — so the server's
520 + // (hostname, sha256(nonce)) lookup matches byte-for-byte instead of relying
521 + // on server-side www normalization or home_url == site_url.
522 + $domain = str_replace('://www.', '://', get_site_url());
523 + $url = rtrim($base, '/') . '/api/wp-plugin-connect-result/?url=' . rawurlencode($domain);
524 +
525 + // Timeout kept below the JS poll cadence (5s) so a slow/unreachable CA
526 + // can't cause overlapping admin-ajax requests to stack up and pin PHP
527 + // workers for the whole connect window.
528 + $response = wp_remote_get($url, array(
529 + 'timeout' => 4,
530 + 'headers' => array(
531 + 'X-Plugin-Token' => $nonce_token,
532 + 'Accept' => 'application/json',
533 + ),
534 + ));
535 +
536 + if (is_wp_error($response)) {
537 + return null; // transient network failure — keep polling
538 + }
539 +
540 + // 400 (bad request) / 429 (throttled) / anything non-200 → keep polling
541 + // this round; the push callback and heartbeat self-heal remain in play.
542 + if ((int) wp_remote_retrieve_response_code($response) !== 200) {
543 + return null;
544 + }
545 +
546 + $data = json_decode(wp_remote_retrieve_body($response), true);
547 + if (!is_array($data) || empty($data['status'])) {
548 + return null;
549 + }
550 +
551 + // 'ready' | 'error' are actionable; 'pending' (or anything else) keeps polling.
552 + if ($data['status'] === 'ready' || $data['status'] === 'error') {
553 + return array(
554 + 'status' => $data['status'],
555 + 'connect' => (isset($data['connect']) && is_array($data['connect'])) ? $data['connect'] : array(),
556 + );
557 + }
558 +
559 + return null;
560 + }
561 +
340 562 // ------------------------------------------------------------------
341 563 // Nonce / encrypted token helpers
342 564 // ------------------------------------------------------------------
343 565
@@ -353,9 +575,9 @@
353 575 $general_options = Metasync::get_option('general') ?? [];
354 576 $plugin_auth_token = $general_options['apikey'] ?? '';
355 577
356 578 if (empty($plugin_auth_token)) {
357 - error_log('MetaSync ERROR: Plugin Auth Token missing from options');
579 + error_log('MetaSync ERROR: Plugin Auth Token missing from options'); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- genuine failure path, bounded, no secrets
358 580 return false;
359 581 }
360 582
361 583 $random_bytes = wp_generate_password(32, false, false);
@@ -381,8 +603,26 @@
381 603 set_transient($transient_key, $token_metadata, 900);
382 604
383 605 set_transient('metasync_sa_connect_active_' . $sa_connect_token, $transient_key, 900);
384 606
607 + // When external object cache (Redis/Memcached/LiteSpeed) is active,
608 + // set_transient() writes to cache ONLY — never to wp_options. The REST
609 + // callback from SA servers runs in a different process/cache context and
610 + // cannot see the cached value. Write directly to DB as a fallback.
611 + if (wp_using_ext_object_cache()) {
612 + global $wpdb;
613 + $active_option = '_transient_metasync_sa_connect_active_' . $sa_connect_token;
614 + $metadata_option = '_transient_' . $transient_key;
615 + $timeout_active = '_transient_timeout_metasync_sa_connect_active_' . $sa_connect_token;
616 + $timeout_metadata = '_transient_timeout_' . $transient_key;
617 + $expires = time() + 900;
618 +
619 + $wpdb->replace($wpdb->options, array('option_name' => $active_option, 'option_value' => $transient_key, 'autoload' => 'no')); // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- manual transient rows on the options table (autoload=no) — mirrors core transient storage; TTL reads need the paired timeout row
620 + $wpdb->replace($wpdb->options, array('option_name' => $timeout_active, 'option_value' => $expires, 'autoload' => 'no')); // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- manual transient rows on the options table (autoload=no) — mirrors core transient storage; TTL reads need the paired timeout row
621 + $wpdb->replace($wpdb->options, array('option_name' => $metadata_option, 'option_value' => maybe_serialize($token_metadata), 'autoload' => 'no')); // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- manual transient rows on the options table (autoload=no) — mirrors core transient storage; TTL reads need the paired timeout row
622 + $wpdb->replace($wpdb->options, array('option_name' => $timeout_metadata, 'option_value' => $expires, 'autoload' => 'no')); // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- manual transient rows on the options table (autoload=no) — mirrors core transient storage; TTL reads need the paired timeout row
623 + }
624 +
385 625 return $sa_connect_token;
386 626 }
387 627
388 628 /**
@@ -514,9 +754,9 @@
514 754 {
515 755 global $wpdb;
516 756
517 757 try {
518 - $rate_limit_transients = $wpdb->get_results(
758 + $rate_limit_transients = $wpdb->get_results( // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- transient enumeration/cleanup on the options table — no API to scan or bulk-delete transient rows
519 759 "SELECT option_name FROM {$wpdb->options} WHERE option_name LIKE '_transient_sa_connect_rate_limit_%'",
520 760 ARRAY_A
521 761 );
522 762
@@ -541,9 +781,9 @@
541 781 public function clear_jwt_token_cache()
542 782 {
543 783 global $wpdb;
544 784
545 - $deleted = $wpdb->query(
785 + $deleted = $wpdb->query( // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- manual transient rows on the options table (autoload=no) — mirrors core transient storage; TTL reads need the paired timeout row
546 786 $wpdb->prepare(
547 787 "DELETE FROM {$wpdb->options} WHERE option_name LIKE %s",
548 788 '_transient_metasync_jwt_token_%'
549 789 )
@@ -548,9 +788,9 @@
548 788 '_transient_metasync_jwt_token_%'
549 789 )
550 790 );
551 791
552 - $wpdb->query(
792 + $wpdb->query( // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- manual transient rows on the options table (autoload=no) — mirrors core transient storage; TTL reads need the paired timeout row
553 793 $wpdb->prepare(
554 794 "DELETE FROM {$wpdb->options} WHERE option_name LIKE %s",
555 795 '_transient_timeout_metasync_jwt_token_%'
556 796 )
@@ -569,10 +809,12 @@
569 809 * @return string|false JWT token on success, false on failure
570 810 */
571 811 public static function get_active_jwt_token($force_refresh = false)
572 812 {
573 - $general_options = Metasync::get_option('general') ?? [];
574 - $api_key = $general_options['searchatlas_api_key'] ?? '';
813 + $api_key = Metasync::get_searchatlas_api_key();
814 + if ($api_key === false) {
815 + $api_key = '';
816 + }
575 817
576 818 if (empty($api_key)) {
577 819 return false;
578 820 }
@@ -592,8 +834,45 @@
592 834 return self::instance()->get_fresh_jwt_token();
593 835 }
594 836
595 837 /**
838 + * Rotate the MCP signing secret so all previously issued tokens are invalid.
839 + */
840 + public function revoke_mcp_jwt_tokens()
841 + {
842 + delete_option('metasync_jwt_secret');
843 + wp_cache_delete('metasync_jwt_secret', 'options');
844 + }
845 +
846 + /**
847 + * Return a valid cached JWT without making a network request.
848 + *
849 + * Non-blocking telemetry must never refresh credentials as a side effect.
850 + *
851 + * @return string|false Cached JWT token, or false when none is available.
852 + */
853 + public static function get_cached_jwt_token()
854 + {
855 + $api_key = Metasync::get_searchatlas_api_key();
856 + if ($api_key === false || $api_key === '') {
857 + return false;
858 + }
859 +
860 + $cache_key = 'metasync_jwt_token_' . md5($api_key);
861 + $cached_token_data = get_transient($cache_key);
862 + if (!is_array($cached_token_data) || empty($cached_token_data['token'])) {
863 + return false;
864 + }
865 +
866 + $expires = isset($cached_token_data['expires']) ? (int) $cached_token_data['expires'] : 0;
867 + if ($expires <= time() + 300) {
868 + return false;
869 + }
870 +
871 + return (string) $cached_token_data['token'];
872 + }
873 +
874 + /**
596 875 * Get fresh JWT token from Search Atlas API with caching
597 876 *
598 877 * @return string|false JWT token on success, false on failure
599 878 */
@@ -598,10 +877,12 @@
598 877 * @return string|false JWT token on success, false on failure
599 878 */
600 879 public function get_fresh_jwt_token()
601 880 {
602 - $general_options = Metasync::get_option('general') ?? [];
603 - $api_key = $general_options['searchatlas_api_key'] ?? '';
881 + $api_key = Metasync::get_searchatlas_api_key();
882 + if ($api_key === false) {
883 + $api_key = '';
884 + }
604 885
605 886 if (empty($api_key)) {
606 887 return false;
607 888 }
@@ -633,9 +914,9 @@
633 914 try {
634 915 $response = wp_remote_post($url, $args);
635 916
636 917 if (is_wp_error($response)) {
637 - error_log('MetaSync: JWT token API request failed - ' . $response->get_error_message());
918 + error_log('MetaSync: JWT token API request failed - ' . $response->get_error_message()); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- genuine failure path, bounded, no secrets
638 919 return false;
639 920 }
640 921
641 922 $response_code = wp_remote_retrieve_response_code($response);
@@ -641,9 +922,9 @@
641 922 $response_code = wp_remote_retrieve_response_code($response);
642 923 $response_body = wp_remote_retrieve_body($response);
643 924
644 925 if ($response_code !== 200) {
645 - error_log('MetaSync: JWT token API returned error code ' . $response_code);
926 + error_log('MetaSync: JWT token API returned error code ' . $response_code); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- genuine failure path, bounded, no secrets
646 927 return false;
647 928 }
648 929
649 930 $data = json_decode($response_body, true);
@@ -648,9 +929,9 @@
648 929
649 930 $data = json_decode($response_body, true);
650 931
651 932 if (!$data || !isset($data['token'], $data['expires'])) {
652 - error_log('MetaSync: Invalid JWT token API response format');
933 + error_log('MetaSync: Invalid JWT token API response format'); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- genuine failure path, bounded, no secrets
653 934 return false;
654 935 }
655 936
656 937 $token_data = array(
@@ -664,9 +945,9 @@
664 945
665 946 return $data['token'];
666 947
667 948 } catch (Exception $e) {
668 - error_log('MetaSync: Exception during JWT generation - ' . $e->getMessage());
949 + error_log('MetaSync: Exception during JWT generation - ' . $e->getMessage()); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- genuine failure path, bounded, no secrets
669 950 return false;
670 951 }
671 952 }
672 953
@@ -674,8 +955,56 @@
674 955 // Authentication reset
675 956 // ------------------------------------------------------------------
676 957
677 958 /**
959 + * Save the options blob so that removing a key actually removes it.
960 + *
961 + * Metasync::set_option() goes through update_option(), and WordPress pipes
962 + * every update_option() call for a registered setting through
963 + * sanitize_option() — which runs Metasync_Settings_Registration::sanitize(),
964 + * the callback register_setting() attached. That sanitizer is built for a
965 + * Settings-API form POST, where the submission carries only the fields of
966 + * one tab: it takes the *stored* option as its base layer and merges the
967 + * incoming array over it, deliberately so that keys a partial submission
968 + * omits survive rather than being wiped.
969 + *
970 + * Correct for a form. Fatal here. Disconnecting works by unsetting keys and
971 + * saving the result, and the sanitizer re-reads the pre-write option as its
972 + * base, so every key this handler removed came straight back — the API key
973 + * byte-identical to the one just cleared. The account looked connected again
974 + * on the next page load.
975 + *
976 + * So this write detaches that callback for its own duration and restores it
977 + * afterwards. Scoped to the disconnect handler rather than changed inside
978 + * set_option(): the merge is right for the form path, and the sanitizer also
979 + * carries the whitelabel password encryption and recovery-password guards
980 + * that other programmatic writes still want.
981 + *
982 + * @param array $options The complete options array to store verbatim.
983 + * @return bool Whether the write succeeded.
984 + */
985 + private function write_options_without_stored_merge($options)
986 + {
987 + $hook = 'sanitize_option_' . Metasync::option_name;
988 +
989 + // register_setting() attaches it at the default priority against the
990 + // registration singleton, so the same callable identity removes it.
991 + $callback = class_exists('Metasync_Settings_Registration')
992 + ? array(Metasync_Settings_Registration::instance(), 'sanitize')
993 + : null;
994 +
995 + $was_attached = ($callback !== null) && remove_filter($hook, $callback);
996 +
997 + $save_result = Metasync::set_option($options);
998 +
999 + if ($was_attached) {
1000 + add_filter($hook, $callback);
1001 + }
1002 +
1003 + return $save_result;
1004 + }
1005 +
1006 + /**
678 1007 * Reset Search Atlas Authentication
679 1008 * Clears all authentication data and tokens
680 1009 */
681 1010 public function reset_searchatlas_authentication()
@@ -709,9 +1038,14 @@
709 1038
710 1039 $cleared_data = array();
711 1040
712 1041 if (isset($options['general']['searchatlas_api_key'])) {
713 - $cleared_data['searchatlas_api_key'] = substr($options['general']['searchatlas_api_key'], 0, 8) . '...';
1042 + // Record only a masked marker (last 4 chars) — never the stored
1043 + // ciphertext or the cleartext key.
1044 + $disconnect_key = Metasync::get_searchatlas_api_key();
1045 + $cleared_data['searchatlas_api_key'] = (is_string($disconnect_key) && $disconnect_key !== '')
1046 + ? str_repeat('•', 4) . substr($disconnect_key, -4)
1047 + : '(removed)';
714 1048 unset($options['general']['searchatlas_api_key']);
715 1049 }
716 1050
717 1051 if (isset($options['general']['otto_pixel_uuid'])) {
@@ -728,10 +1062,24 @@
728 1062 $cleared_data['last_heart_beat'] = $options['general']['last_heart_beat'];
729 1063 unset($options['general']['last_heart_beat']);
730 1064 }
731 1065
732 - $save_result = Metasync::set_option($options);
1066 + # drop the manual "Sync Now" cooldown stamp too — a stale
1067 + # cooldown surviving disconnect blocks the first sync after the
1068 + # user reconnects or saves a new API key.
1069 + if (isset($options['general']['last_manual_sync'])) {
1070 + $cleared_data['last_manual_sync'] = $options['general']['last_manual_sync'];
1071 + unset($options['general']['last_manual_sync']);
1072 + }
733 1073
1074 + # Clear the dedicated heartbeat throttle option (moved
1075 + # throttle timestamps out of the main blob).
1076 + delete_option(Metasync::heartbeat_throttle_option);
1077 + $cleared_data['heartbeat_throttle'] = 'removed';
1078 +
1079 + $save_result = $this->write_options_without_stored_merge($options);
1080 + Metasync::invalidate_api_key_cache();
1081 +
734 1082 if (!$save_result) {
735 1083 throw new Exception('Failed to save updated plugin options');
736 1084 }
737 1085
@@ -740,19 +1088,18 @@
740 1088
741 1089 $cleaned_tokens = $this->cleanup_searchatlas_nonce_tokens();
742 1090 $cleared_data['sa_connect_nonce_tokens'] = 'none (simplified token system)';
743 1091
744 - delete_option(Metasync::option_name . '_whitelabel_user');
745 - $cleared_data['whitelabel_user'] = 'removed';
1092 + // White-label identity is preserved across the authentication
1093 + // reset: the reseller's branding, logos, domains, custom OTTO
1094 + // name, visibility/access controls, password and recovery
1095 + // settings, and the dedicated whitelabel_user option must survive
1096 + // the disconnect. Only authentication tokens and credentials are
1097 + // cleared above. Do not delete the whitelabel blob or the
1098 + // whitelabel_user option here.
746 1099
747 - if (isset($options['whitelabel'])) {
748 - $cleared_data['whitelabel_settings'] = 'removed';
749 - unset($options['whitelabel']);
750 -
751 - Metasync::set_option($options);
752 - }
753 -
754 1100 $this->clear_jwt_token_cache();
1101 + $this->revoke_mcp_jwt_tokens();
755 1102 $cleared_data['jwt_token_cache'] = 'cleared';
756 1103
757 1104 $this->cleanup_searchatlas_rate_limits();
758 1105 $cleared_data['rate_limits'] = 'cleared';
@@ -758,17 +1105,46 @@
758 1105 $cleared_data['rate_limits'] = 'cleared';
759 1106
760 1107 $otto_uuid = $cleared_data['otto_pixel_uuid'] ?? '';
761 1108 if (!empty($otto_uuid)) {
762 - delete_transient('metasync_public_hash_' . md5($otto_uuid));
1109 + delete_transient(Metasync_Heartbeat_Manager::public_hash_cache_key($otto_uuid));
763 1110 }
764 1111 $cleared_data['public_hash_cache'] = 'cleared';
765 1112
766 1113 delete_transient('metasync_heartbeat_status_cache');
1114 + # also drop the last-known-state fallback. With it left at
1115 + # `true`, the header badge flips between "Not Connected" and
1116 + # "Warning" across refreshes depending on whether the status cache
1117 + # transient is alive once an API key exists again.
1118 + delete_option('metasync_last_known_connection_state');
1119 + Metasync_Admin_Navigation::invalidate_admin_bar_status_cache();
767 1120 $cleared_data['heartbeat_cache'] = 'cleared';
1121 + $cleared_data['last_known_connection_state'] = 'cleared';
768 1122
769 1123 Metasync_Heartbeat_Manager::instance()->unschedule_heartbeat_cron();
770 1124
1125 + // Consent-gated analytics: churn signal, the opposite of connected.
1126 + Metasync_PostHog::feature('searchatlas_disconnected', array(
1127 + 'method' => 'reset_auth',
1128 + 'was_connected' => isset($cleared_data['searchatlas_api_key']),
1129 + ));
1130 +
1131 + // Re-synchronise the branded plugin file headers so the WordPress
1132 + // plugin list and the plugin UI stay branded after the
1133 + // authentication reset. Only attempted when white-label branding
1134 + // exists, and a failed sync is logged as a warning, not reported
1135 + // as a reset failure: the credentials have already been cleared,
1136 + // and hosts with read-only plugin files would otherwise never see
1137 + // a successful reset.
1138 + if (Metasync::is_whitelabel_enabled()) {
1139 + if (!class_exists('Metasync_Activator')) {
1140 + require_once plugin_dir_path(dirname(__FILE__)) . 'includes/class-metasync-activator.php';
1141 + }
1142 + if (!Metasync_Activator::sync_plugin_file_headers()) {
1143 + error_log('MetaSync: failed to re-synchronise branded plugin file headers after the authentication reset.');
1144 + }
1145 + }
1146 +
771 1147 wp_send_json_success(array(
772 1148 'message' => 'Authentication has been reset successfully. You can now connect a new account.',
773 1149 'cleared_data' => $cleared_data,
774 1150 'timestamp' => current_time('mysql', true)
@@ -774,9 +1150,9 @@
774 1150 'timestamp' => current_time('mysql', true)
775 1151 ));
776 1152
777 1153 } catch (Exception $e) {
778 - error_log('Authentication Reset Error: ' . $e->getMessage());
1154 + error_log('Authentication Reset Error: ' . $e->getMessage()); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- genuine failure path, bounded, no secrets
779 1155 wp_send_json_error(array(
780 1156 'message' => 'An error occurred while resetting authentication. Please try again or contact support.',
781 1157 'code' => 'reset_failed',
782 1158 'error' => $e->getMessage()
@@ -842,20 +1218,8 @@
842 1218 ));
843 1219 }
844 1220
845 1221 /**
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 1222 * Test whitelabel domain configuration (development/debugging)
859 1223 */
860 1224 public function test_whitelabel_domain()
861 1225 {
@@ -903,10 +1267,10 @@
903 1267 if (!is_admin()) {
904 1268 return;
905 1269 }
906 1270
907 - $active_tab = isset($_GET['tab']) ? $_GET['tab'] : 'general';
908 - $current_page = isset($_GET['page']) ? $_GET['page'] : '';
1271 + $active_tab = isset($_GET['tab']) ? $_GET['tab'] : 'general'; // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only routing, each action verifies its own nonce
1272 + $current_page = isset($_GET['page']) ? $_GET['page'] : ''; // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only routing, each action verifies its own nonce
909 1273
910 1274 $whitelabel_settings = Metasync::get_whitelabel_settings();
911 1275 $user_password = $whitelabel_settings['settings_password'] ?? '';
912 1276 $hide_settings_enabled = !empty($whitelabel_settings['hide_settings']);
@@ -937,25 +1301,15 @@
937 1301 public function handle_whitelabel_session_logic()
938 1302 {
939 1303 $auth = new Metasync_Auth_Manager('whitelabel', 1800);
940 1304
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 1305 if (isset($_POST['whitelabel_logout'])) {
952 1306 if (wp_verify_nonce($_POST['whitelabel_logout_nonce'] ?? '', 'whitelabel_logout_nonce')) {
953 1307 $auth->revoke_access();
954 1308
955 - $redirect_tab = $_GET['tab'] ?? 'whitelabel';
1309 + $redirect_tab = isset($_GET['tab']) ? sanitize_key(wp_unslash($_GET['tab'])) : 'whitelabel';
956 1310 $redirect_url = admin_url('admin.php?page=' . Metasync_Admin::$page_slug . '&tab=' . $redirect_tab);
957 - wp_redirect($redirect_url);
1311 + wp_safe_redirect($redirect_url);
958 1312 exit;
959 1313 }
960 1314 }
961 1315
@@ -960,11 +1314,30 @@
960 1314 }
961 1315
962 1316 if (isset($_POST['whitelabel_password_submit']) && isset($_POST['whitelabel_password'])) {
963 1317 if (wp_verify_nonce($_POST['whitelabel_nonce'], 'whitelabel_password_nonce')) {
964 - $submitted_password = sanitize_text_field($_POST['whitelabel_password']);
1318 + $submitted_password = (string) wp_unslash($_POST['whitelabel_password']);
965 1319
966 - $auth->verify_and_grant($submitted_password, $valid_passwords, false);
1320 + $lock_owner = '';
1321 + if (!Metasync_Admin_Ajax::instance()->acquire_recovery_lock($lock_owner)) {
1322 + return;
1323 + }
1324 + $shutdown_lock_owner = $lock_owner;
1325 + register_shutdown_function(function () use ($shutdown_lock_owner) {
1326 + Metasync_Admin_Ajax::instance()->release_recovery_lock($shutdown_lock_owner);
1327 + });
1328 +
1329 + try {
1330 + // This request may have cached settings before a reset
1331 + // completed. Refresh after taking the shared lock, then
1332 + // verify and grant against one consistent password state.
1333 + Metasync_Admin_Ajax::instance()->refresh_recovery_state_cache();
1334 + if (function_exists('wp_cache_delete')) wp_cache_delete('metasync_auth_revoke_epoch_whitelabel', 'options');
1335 + $auth->verify_and_grant($submitted_password, $this->get_whitelabel_valid_passwords(), false);
1336 + } finally {
1337 + Metasync_Admin_Ajax::instance()->release_recovery_lock($lock_owner);
1338 + $lock_owner = '';
1339 + }
967 1340 }
968 1341 }
969 1342 }
970 1343
@@ -972,23 +1345,60 @@
972 1345 * Handle whitelabel password early before WordPress filters it out
973 1346 */
974 1347 public function handle_whitelabel_password_early()
975 1348 {
976 - if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['option_page']) && $_POST['option_page'] === Metasync_Admin::option_group) {
1349 + if (!current_user_can('manage_options')) {
1350 + return;
1351 + }
977 1352
1353 + if (
1354 + !isset($_POST['meta_sync_nonce'])
1355 + || !wp_verify_nonce(wp_unslash($_POST['meta_sync_nonce']), 'meta_sync_general_setting_nonce')
1356 + ) {
1357 + return;
1358 + }
1359 +
1360 + if (($_SERVER['REQUEST_METHOD'] ?? '') === 'POST' && isset($_POST['option_page']) && $_POST['option_page'] === Metasync_Admin::option_group) {
1361 +
978 1362 if (isset($_POST[Metasync_Admin::option_key]['whitelabel']['settings_password'])) {
979 - $submitted_password = sanitize_text_field($_POST[Metasync_Admin::option_key]['whitelabel']['settings_password']);
1363 + $submitted_password = (string) wp_unslash($_POST[Metasync_Admin::option_key]['whitelabel']['settings_password']);
1364 + $lock_owner = '';
1365 + if (!Metasync_Admin_Ajax::instance()->acquire_recovery_lock($lock_owner)) {
1366 + return;
1367 + }
1368 + $shutdown_lock_owner = $lock_owner;
1369 + register_shutdown_function(function () use ($shutdown_lock_owner) {
1370 + Metasync_Admin_Ajax::instance()->release_recovery_lock($shutdown_lock_owner);
1371 + });
980 1372
981 - $current_options = Metasync::get_option();
1373 + try {
1374 + Metasync_Admin_Ajax::instance()->refresh_recovery_state_cache();
1375 + $current_options = Metasync::get_option();
982 1376
983 - if (!isset($current_options['whitelabel'])) {
984 - $current_options['whitelabel'] = [];
985 - }
1377 + if (!isset($current_options['whitelabel'])) {
1378 + $current_options['whitelabel'] = [];
1379 + }
986 1380
987 - $current_options['whitelabel']['settings_password'] = $submitted_password;
988 - $current_options['whitelabel']['updated_at'] = time();
1381 + $current_options['whitelabel']['settings_password'] = Metasync::encrypt_secret($submitted_password);
1382 + $current_options['whitelabel']['updated_at'] = time();
989 1383
990 - update_option(Metasync_Admin::option_key, $current_options);
1384 + update_option(Metasync_Admin::option_key, $current_options);
1385 + } finally {
1386 + Metasync_Admin_Ajax::instance()->release_recovery_lock($lock_owner);
1387 + $lock_owner = '';
1388 + }
991 1389 }
992 1390 }
1391 + }
1392 +
1393 + /**
1394 + * Return the site-specific passwords accepted by the whitelabel gate.
1395 + *
1396 + * @return array<int, string>
1397 + */
1398 + private function get_whitelabel_valid_passwords()
1399 + {
1400 + $user_password = Metasync::get_whitelabel_password();
1401 +
1402 + return $user_password === '' ? array() : array($user_password);
993 1403 }
994 1404 }