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-redirections-admin.php +199 -34 2.6.13 → 2.7.2 View file →
@@ -15,8 +15,22 @@
15 15 class Metasync_Redirections_Admin
16 16 {
17 17 private static $instance = null;
18 18
19 + /**
20 + * Which tab of the combined Redirections screen is being viewed.
21 + *
22 + * Set ONLY by render_tab_content(), i.e. only when both the redirections
23 + * and 404-monitor tables are rendered onto one page. Stays null on the
24 + * standalone 404 Monitor page (page=...-404-monitor), where that table is
25 + * the only one present. The list tables read it to decide whether to offer
26 + * bulk actions - see get_bulk_actions() in either table for why.
27 + *
28 + * @var string|null 'redirections', '404-monitor', or null when not on the
29 + * combined screen.
30 + */
31 + public static $combined_screen_active_tab = null;
32 +
19 33 /** @var object Redirection database helper */
20 34 private $db_redirection;
21 35
22 36 /** @var Metasync_Admin Back-reference used for shared UI helpers */
@@ -51,9 +65,9 @@
51 65 $this->check_database_structure();
52 66
53 67 $this->ensure_404_monitor_table();
54 68
55 - $current_tab = isset($_GET['tab']) ? sanitize_text_field($_GET['tab']) : 'redirections';
69 + $current_tab = isset($_GET['tab']) ? sanitize_text_field(wp_unslash($_GET['tab'])) : 'redirections'; // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only tab selection
56 70
57 71 $this->add_tabbed_interface_assets();
58 72 $this->admin->render_layout_open('Redirections', 'redirections', 'Manage URL redirects and monitor 404 errors on your site.');
59 73 $this->render_tab_navigation($current_tab);
@@ -87,9 +101,9 @@
87 101
88 102 private function safe_redirect($url)
89 103 {
90 104 if (!headers_sent()) {
91 - wp_redirect($url);
105 + wp_safe_redirect($url);
92 106 exit;
93 107 } else {
94 108 echo '<script type="text/javascript">window.location.href = "' . esc_url($url) . '";</script>';
95 109 echo '<noscript><meta http-equiv="refresh" content="0;url=' . esc_url($url) . '"></noscript>';
@@ -121,15 +135,18 @@
121 135 if (!Metasync::current_user_has_plugin_access()) {
122 136 wp_die('Insufficient permissions.');
123 137 }
124 138
125 - $source_urls = isset($_POST['source_url']) ? array_map('sanitize_text_field', $_POST['source_url']) : [];
126 - $search_types = isset($_POST['search_type']) ? array_map('sanitize_text_field', $_POST['search_type']) : [];
127 - $destination_url = isset($_POST['destination_url']) ? sanitize_text_field($_POST['destination_url']) : '';
139 + // Unslash before sanitizing: source URLs with apostrophes/backslashes
140 + // must be stored in their literal form so the front-end matcher (which
141 + // compares against the unslashed request URI) can hit them.
142 + $source_urls = isset($_POST['source_url']) ? array_map('sanitize_text_field', wp_unslash($_POST['source_url'])) : [];
143 + $search_types = isset($_POST['search_type']) ? array_map('sanitize_text_field', wp_unslash($_POST['search_type'])) : [];
144 + $destination_url = isset($_POST['destination_url']) ? sanitize_text_field(wp_unslash($_POST['destination_url'])) : '';
128 145 $redirect_type = isset($_POST['redirect_type']) ? intval($_POST['redirect_type']) : 301;
129 - $status = isset($_POST['status']) ? sanitize_text_field($_POST['status']) : 'active';
146 + $status = isset($_POST['status']) ? sanitize_text_field(wp_unslash($_POST['status'])) : 'active';
130 147 $regex_pattern = isset($_POST['regex_pattern']) ? wp_unslash(trim($_POST['regex_pattern'])) : '';
131 - $description = isset($_POST['description']) ? sanitize_text_field($_POST['description']) : '';
148 + $description = isset($_POST['description']) ? sanitize_text_field(wp_unslash($_POST['description'])) : '';
132 149 $redirect_id = isset($_POST['redirect_id']) ? intval($_POST['redirect_id']) : 0;
133 150
134 151 $validation_errors = [];
135 152
@@ -141,21 +158,20 @@
141 158
142 159 foreach ($source_urls as $source_url) {
143 160 $trimmed_url = trim($source_url);
144 161
145 - if (empty($trimmed_url)) {
146 - $empty_count++;
147 - continue;
148 - }
162 + if (!empty($trimmed_url)) {
163 + if (!$this->is_valid_url($trimmed_url)) {
164 + $validation_errors[] = 'Invalid source URL format: "' . esc_html($trimmed_url) . '". URLs should start with / for relative paths or be complete URLs.';
165 + }
149 166
150 - if (!$this->is_valid_url($trimmed_url)) {
151 - $validation_errors[] = 'Invalid source URL format: "' . esc_html($trimmed_url) . '". URLs should start with / for relative paths or be complete URLs.';
152 - }
153 -
154 - if (in_array($trimmed_url, $processed_sources)) {
155 - $validation_errors[] = 'Duplicate source URL detected: "' . esc_html($trimmed_url) . '".';
167 + if (in_array($trimmed_url, $processed_sources)) {
168 + $validation_errors[] = 'Duplicate source URL detected: "' . esc_html($trimmed_url) . '".';
169 + } else {
170 + $processed_sources[] = $trimmed_url;
171 + }
156 172 } else {
157 - $processed_sources[] = $trimmed_url;
173 + $empty_count++;
158 174 }
159 175 }
160 176
161 177 if ($empty_count === count($source_urls)) {
@@ -173,8 +189,14 @@
173 189 if (empty($trimmed_dest)) {
174 190 $validation_errors[] = 'Destination URL is required for this redirect type.';
175 191 } elseif (!$this->is_valid_url($trimmed_dest)) {
176 192 $validation_errors[] = 'Invalid destination URL format. URLs should start with / for relative paths or be complete URLs.';
193 + } elseif (!Metasync_Redirection_Validator::is_safe_destination_syntax($trimmed_dest)) {
194 + // Browsers treat backslashes as path separators, so '/\evil.com'
195 + // passes wp_validate_redirect yet navigates off-site; a leading
196 + // '//' is a protocol-relative host. Neither is a valid internal
197 + // destination regardless of the external-redirects setting.
198 + $validation_errors[] = 'Destination URL contains invalid characters.';
177 199 } elseif (!get_option('metasync_allow_external_redirects', 0) && wp_validate_redirect($trimmed_dest, '') !== $trimmed_dest) {
178 200 $validation_errors[] = 'Destination URL must point to this site. External redirect destinations are not allowed. Enable "Allow External Redirects" in the Redirections settings to permit off-site redirects.';
179 201 }
180 202 }
@@ -199,9 +221,12 @@
199 221 }
200 222 }
201 223
202 224 $pattern_type = 'exact';
203 - foreach ($search_types as $search_type) {
225 + // Derive the global type only from populated rows. A blank placeholder
226 + // with a changed select must not trigger regex validation or alter the
227 + // behavior of a redirect the user actually entered.
228 + foreach ($sources_from as $search_type) {
204 229 if (!empty($search_type)) {
205 230 $pattern_type = $search_type;
206 231 break;
207 232 }
@@ -220,12 +245,13 @@
220 245 $this->safe_redirect(admin_url('admin.php?page=' . Metasync_Admin::$page_slug . '-redirections'));
221 246 return;
222 247 }
223 248
224 - // Reject patterns with nested quantifiers that could cause ReDoS
249 + // Reject patterns with nested quantification that could cause ReDoS,
250 + // including alternation blowup ('((a|a)*)*') the older guard missed.
225 251 $raw_check = preg_replace('/^\S(.*)\S[a-zA-Z]*$/', '$1', $regex_pattern);
226 - if (preg_match('/(\([^)]*[+*][^)]*\))[+*?{]|(\[[^\]]*\])[+*][+*?{]/', $raw_check)) {
227 - set_transient('metasync_redirection_error_' . $uid, 'Regex pattern contains potentially unsafe nested quantifiers.', 45);
252 + if (!Metasync_Redirection_Validator::is_regex_safe($raw_check)) {
253 + set_transient('metasync_redirection_error_' . $uid, 'Regex pattern is too long or contains potentially unsafe nested quantifiers (including nested alternation).', 45);
228 254 $this->safe_redirect(admin_url('admin.php?page=' . Metasync_Admin::$page_slug . '-redirections'));
229 255 return;
230 256 }
231 257
@@ -258,8 +284,9 @@
258 284
259 285 // Loop detection: refuse to persist a chain that would resolve back to any source
260 286 if (!in_array($redirect_type, [410, 451])) {
261 287 require_once dirname(__FILE__, 2) . '/redirections/class-metasync-redirection.php';
288 + require_once dirname(__FILE__, 2) . '/redirections/class-metasync-redirection-validator.php';
262 289 $redirection_helper = new Metasync_Redirection($this->db_redirection);
263 290 foreach (array_keys($sources_from) as $source_url) {
264 291 $loop_error = $redirection_helper->validate_no_loop($source_url, $destination_url);
265 292 if ($loop_error !== null) {
@@ -275,8 +302,23 @@
275 302 set_transient('metasync_redirection_warning_' . $uid, $dest_warning, 45);
276 303 }
277 304 }
278 305
306 + // Non-blocking duplicate-source check: warn (but never block) when a
307 + // submitted source duplicates an existing ACTIVE rule's exact source.
308 + // Two active rows with the same normalized exact source path cannot
309 + // both fire — the engine's index only keeps one — so saving a second
310 + // active rule for a source that already has one creates a dead row.
311 + // Exclude the row being edited so an edit-and-re-save does not warn
312 + // against itself. An inactive submission cannot shadow (the engine's
313 + // index only holds active rows), so skip the check in that case.
314 + if ($status === 'active') {
315 + $duplicate_warnings = $this->check_duplicate_active_sources($sources_from, $pattern_type, $redirect_id);
316 + if ($duplicate_warnings !== '') {
317 + set_transient('metasync_redirection_warning_' . $uid, $duplicate_warnings, 45);
318 + }
319 + }
320 +
279 321 $data = [
280 322 'sources_from' => serialize($sources_from),
281 323 'url_redirect_to' => $destination_url,
282 324 'http_code' => $redirect_type,
@@ -301,9 +343,9 @@
301 343 $message = 'Redirection added successfully.';
302 344 }
303 345
304 346 } catch (Exception $e) {
305 - error_log('MetaSync error: ' . $e->getMessage());
347 + error_log('MetaSync error: ' . $e->getMessage()); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- genuine failure path, bounded, no secrets
306 348 set_transient('metasync_redirection_error_' . $uid, 'An error occurred while saving the redirection. Please try again.', 45);
307 349 $this->safe_redirect(admin_url('admin.php?page=' . Metasync_Admin::$page_slug . '-redirections'));
308 350 }
309 351
@@ -320,8 +362,123 @@
320 362
321 363 return filter_var($url, FILTER_VALIDATE_URL) !== false;
322 364 }
323 365
366 + /**
367 + * Non-blocking check for duplicate active exact sources.
368 + *
369 + * When a new (or edited) redirect saves an active exact-match source that
370 + * another active row already uses, only one of the two will ever fire —
371 + * the engine's exact-index keeps a single winner per source path. This
372 + * returns a human-readable warning string listing the colliding sources
373 + * (and the existing rule's destination) so the user knows the save
374 + * succeeded but created a shadowed rule. It never blocks the save.
375 + *
376 + * Normalization mirrors Metasync_Redirection::ensure_redirect_index()
377 + * exactly: extract the path from full URLs, force a leading slash, strip
378 + * a trailing slash, and treat '/' as the root. Only exact-pattern sources
379 + * are checked — wildcard/regex/contain/start/end sources do not collapse
380 + * onto a single index key.
381 + *
382 + * @param array $sources_from Submitted sources (url => pattern_type).
383 + * @param string $pattern_type Global pattern type for the submission.
384 + * @param int $redirect_id ID of the row being edited (excluded from the check), 0 for new.
385 + * @return string Warning message, or '' when no duplicate was found.
386 + */
387 + private function check_duplicate_active_sources(array $sources_from, $pattern_type, $redirect_id)
388 + {
389 + // Only exact-pattern submissions can shadow. If the global pattern type
390 + // is not exact (regex/wildcard/contain/start/end) the sources do not go
391 + // into the engine's exact index, so there is nothing to warn about.
392 + if ($pattern_type !== 'exact') {
393 + return '';
394 + }
395 +
396 + // Normalize the submitted sources into the same keys the engine uses.
397 + $submitted_norms = array();
398 + foreach (array_keys($sources_from) as $source_url) {
399 + $source_url = trim((string) $source_url);
400 + if ($source_url === '') {
401 + continue;
402 + }
403 + $norm = $source_url;
404 + if (strpos($norm, 'http') === 0) {
405 + $parsed = wp_parse_url($norm);
406 + $norm = isset($parsed['path']) ? $parsed['path'] : '/';
407 + }
408 + if ($norm === '' || $norm[0] !== '/') {
409 + $norm = '/' . $norm;
410 + }
411 + $norm = rtrim($norm, '/') ?: '/';
412 + $submitted_norms[$norm] = $source_url;
413 + }
414 +
415 + if (empty($submitted_norms)) {
416 + return '';
417 + }
418 +
419 + // Build an index of existing active rows' normalized exact sources,
420 + // excluding the row being edited so an edit does not warn against itself.
421 + $existing = array(); // norm => [ 'destination' => string ]
422 + $active_rows = $this->db_redirection->getAllActiveRecords();
423 + if (is_array($active_rows)) {
424 + foreach ($active_rows as $row) {
425 + if ($redirect_id > 0 && (int) $row->id === (int) $redirect_id) {
426 + continue;
427 + }
428 + $sources = !empty($row->sources_from)
429 + ? unserialize($row->sources_from, ['allowed_classes' => false])
430 + : array();
431 + $source_urls = is_array($sources) ? $sources : array();
432 + $global_pattern_type = isset($row->pattern_type) ? $row->pattern_type : null;
433 +
434 + foreach ($source_urls as $source_key => $source_value) {
435 + // Legacy list-format rows store the URL as the VALUE under a
436 + // numeric key; remap so the legacy source is normalized.
437 + if ((is_int($source_key) || ctype_digit((string) $source_key)) && is_string($source_value) && $source_value !== '') {
438 + $source_key = $source_value;
439 + }
440 + $row_pattern_type = in_array($source_value, ['exact', 'contain', 'start', 'end', 'wildcard', 'regex'])
441 + ? $source_value
442 + : ($global_pattern_type ? $global_pattern_type : 'exact');
443 +
444 + if ($row_pattern_type !== 'exact' || strpos((string) $source_key, '*') !== false) {
445 + continue;
446 + }
447 +
448 + $norm = (string) $source_key;
449 + if (strpos($norm, 'http') === 0) {
450 + $parsed = wp_parse_url($norm);
451 + $norm = isset($parsed['path']) ? $parsed['path'] : '/';
452 + }
453 + if ($norm === '' || $norm[0] !== '/') {
454 + $norm = '/' . $norm;
455 + }
456 + $norm = rtrim($norm, '/') ?: '/';
457 +
458 + $existing[$norm] = isset($row->url_redirect_to) ? (string) $row->url_redirect_to : '';
459 + }
460 + }
461 + }
462 +
463 + $collisions = array();
464 + foreach ($submitted_norms as $norm => $original_source) {
465 + if (isset($existing[$norm])) {
466 + $collisions[] = sprintf(
467 + 'Source "%s" is already used by an active redirect (destination: %s). Only one of the two rules will fire.',
468 + $original_source,
469 + $existing[$norm]
470 + );
471 + }
472 + }
473 +
474 + if (empty($collisions)) {
475 + return '';
476 + }
477 +
478 + return implode(' ', $collisions);
479 + }
480 +
324 481 private function add_tabbed_interface_assets()
325 482 {
326 483 ?>
327 484 <style>
@@ -511,8 +668,16 @@
511 668 }
512 669
513 670 private function render_tab_content($current_tab)
514 671 {
672 + // Both tables are rendered onto this one page (CSS hides the inactive
673 + // tab), so record which tab is actually being viewed. The list tables
674 + // use this to keep the hidden tab from emitting a duplicate set of
675 + // bulk-action controls - see get_bulk_actions() in either table.
676 + self::$combined_screen_active_tab = ($current_tab === '404-monitor')
677 + ? '404-monitor'
678 + : 'redirections';
679 +
515 680 $this->render_redirections_tab($current_tab);
516 681 $this->render_404_monitor_tab($current_tab);
517 682 }
518 683
@@ -518,14 +683,14 @@
518 683
519 684 private function render_redirections_tab($current_tab = null)
520 685 {
521 686 if ($current_tab === null) {
522 - $current_tab = isset($_GET['tab']) ? sanitize_text_field($_GET['tab']) : 'redirections';
687 + $current_tab = isset($_GET['tab']) ? sanitize_text_field(wp_unslash($_GET['tab'])) : 'redirections'; // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only tab selection
523 688 }
524 689 $active_class = $current_tab === 'redirections' ? 'active' : '';
525 690
526 691 ?>
527 - <div id="redirections-content" class="metasync-tab-content <?php echo $active_class; ?>">
692 + <div id="redirections-content" class="metasync-tab-content <?php echo esc_attr($active_class); ?>">
528 693 <?php
529 694 $redirection = new Metasync_Redirection($this->db_redirection);
530 695 $redirection->create_admin_redirection_interface();
531 696 ?>
@@ -535,14 +700,14 @@
535 700
536 701 private function render_404_monitor_tab($current_tab = null)
537 702 {
538 703 if ($current_tab === null) {
539 - $current_tab = isset($_GET['tab']) ? sanitize_text_field($_GET['tab']) : 'redirections';
704 + $current_tab = isset($_GET['tab']) ? sanitize_text_field(wp_unslash($_GET['tab'])) : 'redirections'; // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only tab selection
540 705 }
541 706 $active_class = $current_tab === '404-monitor' ? 'active' : '';
542 707
543 708 ?>
544 - <div id="404-monitor-content" class="metasync-tab-content <?php echo $active_class; ?>">
709 + <div id="404-monitor-content" class="metasync-tab-content <?php echo esc_attr($active_class); ?>">
545 710 <?php
546 711 try {
547 712 require_once plugin_dir_path(dirname(__FILE__)) . '404-monitor/class-metasync-404-monitor-database.php';
548 713 require_once plugin_dir_path(dirname(__FILE__)) . '404-monitor/class-metasync-404-monitor.php';
@@ -552,12 +717,12 @@
552 717
553 718 $ErrorMonitor->create_admin_plugin_interface();
554 719
555 720 } catch (Exception $e) {
556 - error_log('MetaSync 404 Monitor Error: ' . $e->getMessage());
721 + error_log('MetaSync 404 Monitor Error: ' . $e->getMessage()); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- genuine failure path, bounded, no secrets
557 722 echo '<div class="notice notice-error"><p>An error occurred while loading the 404 monitor. Please check the server logs for details.</p></div>';
558 723 } catch (Error $e) {
559 - error_log('MetaSync 404 Monitor Fatal Error: ' . $e->getMessage());
724 + error_log('MetaSync 404 Monitor Fatal Error: ' . $e->getMessage()); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- genuine failure path, bounded, no secrets
560 725 echo '<div class="notice notice-error"><p>A fatal error occurred while loading the 404 monitor. Please check the server logs for details.</p></div>';
561 726 }
562 727 ?>
563 728 </div>
@@ -570,9 +735,9 @@
570 735 require_once plugin_dir_path(dirname(__FILE__)) . '404-monitor/class-metasync-404-monitor-database.php';
571 736
572 737 $table_name = $wpdb->prefix . Metasync_Error_Monitor_Database::$table_name;
573 738
574 - if ($wpdb->get_var($wpdb->prepare("SHOW TABLES LIKE %s", $table_name)) != $table_name) {
739 + if ($wpdb->get_var($wpdb->prepare("SHOW TABLES LIKE %s", $table_name)) != $table_name) { // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- admin repair action — re-runs the migration check on demand from the UI
575 740 require_once plugin_dir_path(dirname(__FILE__)) . 'database/class-db-migrations.php';
576 741 MetaSync_DBMigration::run_migrations();
577 742 }
578 743 }
@@ -581,13 +746,13 @@
581 746 {
582 747 global $wpdb;
583 748 $table_name = $wpdb->prefix . 'metasync_redirections';
584 749
585 - if ($wpdb->get_var($wpdb->prepare("SHOW TABLES LIKE %s", $table_name)) != $table_name) {
750 + if ($wpdb->get_var($wpdb->prepare("SHOW TABLES LIKE %s", $table_name)) != $table_name) { // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- admin repair action — re-runs the migration check on demand from the UI
586 751 return;
587 752 }
588 753
589 - $columns = $wpdb->get_col("DESCRIBE {$table_name}");
754 + $columns = $wpdb->get_col("DESCRIBE {$wpdb->prefix}metasync_redirections"); // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- admin repair action — re-runs the migration check on demand from the UI
590 755 $required_columns = ['pattern_type', 'regex_pattern', 'description', 'created_at', 'updated_at', 'last_accessed_at'];
591 756
592 757 $missing_columns = array_diff($required_columns, $columns);
593 758
@@ -593,9 +758,9 @@
593 758
594 759 if (!empty($missing_columns)) {
595 760 add_action('admin_notices', function() use ($missing_columns) {
596 761 echo '<div class="notice notice-warning is-dismissible">';
597 - echo '<p><strong>MetaSync:</strong> Database structure needs updating. Missing columns: ' . implode(', ', $missing_columns) . '</p>';
762 + echo '<p><strong>MetaSync:</strong> Database structure needs updating. Missing columns: ' . esc_html(implode(', ', $missing_columns)) . '</p>';
598 763 echo '<p><button type="button" class="button button-secondary" onclick="updateDatabaseStructure()">Update Database Structure</button></p>';
599 764 echo '</div>';
600 765
601 766 echo '<script>
@@ -602,9 +767,9 @@
602 767 function updateDatabaseStructure() {
603 768 if (confirm("This will update your database structure. Continue?")) {
604 769 const formData = new FormData();
605 770 formData.append("action", "metasync_update_db_structure");
606 - formData.append("nonce", "' . wp_create_nonce('metasync_update_db_nonce') . '");
771 + formData.append("nonce", "' . esc_attr(wp_create_nonce('metasync_update_db_nonce')) . '");
607 772
608 773 fetch(ajaxurl, {
609 774 method: "POST",
610 775 body: formData