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 +177 -34 2.6.21 → 2.7.2 View file →
@@ -65,9 +65,9 @@
65 65 $this->check_database_structure();
66 66
67 67 $this->ensure_404_monitor_table();
68 68
69 - $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
70 70
71 71 $this->add_tabbed_interface_assets();
72 72 $this->admin->render_layout_open('Redirections', 'redirections', 'Manage URL redirects and monitor 404 errors on your site.');
73 73 $this->render_tab_navigation($current_tab);
@@ -101,9 +101,9 @@
101 101
102 102 private function safe_redirect($url)
103 103 {
104 104 if (!headers_sent()) {
105 - wp_redirect($url);
105 + wp_safe_redirect($url);
106 106 exit;
107 107 } else {
108 108 echo '<script type="text/javascript">window.location.href = "' . esc_url($url) . '";</script>';
109 109 echo '<noscript><meta http-equiv="refresh" content="0;url=' . esc_url($url) . '"></noscript>';
@@ -135,15 +135,18 @@
135 135 if (!Metasync::current_user_has_plugin_access()) {
136 136 wp_die('Insufficient permissions.');
137 137 }
138 138
139 - $source_urls = isset($_POST['source_url']) ? array_map('sanitize_text_field', $_POST['source_url']) : [];
140 - $search_types = isset($_POST['search_type']) ? array_map('sanitize_text_field', $_POST['search_type']) : [];
141 - $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'])) : '';
142 145 $redirect_type = isset($_POST['redirect_type']) ? intval($_POST['redirect_type']) : 301;
143 - $status = isset($_POST['status']) ? sanitize_text_field($_POST['status']) : 'active';
146 + $status = isset($_POST['status']) ? sanitize_text_field(wp_unslash($_POST['status'])) : 'active';
144 147 $regex_pattern = isset($_POST['regex_pattern']) ? wp_unslash(trim($_POST['regex_pattern'])) : '';
145 - $description = isset($_POST['description']) ? sanitize_text_field($_POST['description']) : '';
148 + $description = isset($_POST['description']) ? sanitize_text_field(wp_unslash($_POST['description'])) : '';
146 149 $redirect_id = isset($_POST['redirect_id']) ? intval($_POST['redirect_id']) : 0;
147 150
148 151 $validation_errors = [];
149 152
@@ -155,21 +158,20 @@
155 158
156 159 foreach ($source_urls as $source_url) {
157 160 $trimmed_url = trim($source_url);
158 161
159 - if (empty($trimmed_url)) {
160 - $empty_count++;
161 - continue;
162 - }
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 + }
163 166
164 - if (!$this->is_valid_url($trimmed_url)) {
165 - $validation_errors[] = 'Invalid source URL format: "' . esc_html($trimmed_url) . '". URLs should start with / for relative paths or be complete URLs.';
166 - }
167 -
168 - if (in_array($trimmed_url, $processed_sources)) {
169 - $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 + }
170 172 } else {
171 - $processed_sources[] = $trimmed_url;
173 + $empty_count++;
172 174 }
173 175 }
174 176
175 177 if ($empty_count === count($source_urls)) {
@@ -187,8 +189,14 @@
187 189 if (empty($trimmed_dest)) {
188 190 $validation_errors[] = 'Destination URL is required for this redirect type.';
189 191 } elseif (!$this->is_valid_url($trimmed_dest)) {
190 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.';
191 199 } elseif (!get_option('metasync_allow_external_redirects', 0) && wp_validate_redirect($trimmed_dest, '') !== $trimmed_dest) {
192 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.';
193 201 }
194 202 }
@@ -213,9 +221,12 @@
213 221 }
214 222 }
215 223
216 224 $pattern_type = 'exact';
217 - 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) {
218 229 if (!empty($search_type)) {
219 230 $pattern_type = $search_type;
220 231 break;
221 232 }
@@ -234,12 +245,13 @@
234 245 $this->safe_redirect(admin_url('admin.php?page=' . Metasync_Admin::$page_slug . '-redirections'));
235 246 return;
236 247 }
237 248
238 - // 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.
239 251 $raw_check = preg_replace('/^\S(.*)\S[a-zA-Z]*$/', '$1', $regex_pattern);
240 - if (preg_match('/(\([^)]*[+*][^)]*\))[+*?{]|(\[[^\]]*\])[+*][+*?{]/', $raw_check)) {
241 - 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);
242 254 $this->safe_redirect(admin_url('admin.php?page=' . Metasync_Admin::$page_slug . '-redirections'));
243 255 return;
244 256 }
245 257
@@ -272,8 +284,9 @@
272 284
273 285 // Loop detection: refuse to persist a chain that would resolve back to any source
274 286 if (!in_array($redirect_type, [410, 451])) {
275 287 require_once dirname(__FILE__, 2) . '/redirections/class-metasync-redirection.php';
288 + require_once dirname(__FILE__, 2) . '/redirections/class-metasync-redirection-validator.php';
276 289 $redirection_helper = new Metasync_Redirection($this->db_redirection);
277 290 foreach (array_keys($sources_from) as $source_url) {
278 291 $loop_error = $redirection_helper->validate_no_loop($source_url, $destination_url);
279 292 if ($loop_error !== null) {
@@ -289,8 +302,23 @@
289 302 set_transient('metasync_redirection_warning_' . $uid, $dest_warning, 45);
290 303 }
291 304 }
292 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 +
293 321 $data = [
294 322 'sources_from' => serialize($sources_from),
295 323 'url_redirect_to' => $destination_url,
296 324 'http_code' => $redirect_type,
@@ -315,9 +343,9 @@
315 343 $message = 'Redirection added successfully.';
316 344 }
317 345
318 346 } catch (Exception $e) {
319 - 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
320 348 set_transient('metasync_redirection_error_' . $uid, 'An error occurred while saving the redirection. Please try again.', 45);
321 349 $this->safe_redirect(admin_url('admin.php?page=' . Metasync_Admin::$page_slug . '-redirections'));
322 350 }
323 351
@@ -334,8 +362,123 @@
334 362
335 363 return filter_var($url, FILTER_VALIDATE_URL) !== false;
336 364 }
337 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 +
338 481 private function add_tabbed_interface_assets()
339 482 {
340 483 ?>
341 484 <style>
@@ -540,14 +683,14 @@
540 683
541 684 private function render_redirections_tab($current_tab = null)
542 685 {
543 686 if ($current_tab === null) {
544 - $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
545 688 }
546 689 $active_class = $current_tab === 'redirections' ? 'active' : '';
547 690
548 691 ?>
549 - <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); ?>">
550 693 <?php
551 694 $redirection = new Metasync_Redirection($this->db_redirection);
552 695 $redirection->create_admin_redirection_interface();
553 696 ?>
@@ -557,14 +700,14 @@
557 700
558 701 private function render_404_monitor_tab($current_tab = null)
559 702 {
560 703 if ($current_tab === null) {
561 - $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
562 705 }
563 706 $active_class = $current_tab === '404-monitor' ? 'active' : '';
564 707
565 708 ?>
566 - <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); ?>">
567 710 <?php
568 711 try {
569 712 require_once plugin_dir_path(dirname(__FILE__)) . '404-monitor/class-metasync-404-monitor-database.php';
570 713 require_once plugin_dir_path(dirname(__FILE__)) . '404-monitor/class-metasync-404-monitor.php';
@@ -574,12 +717,12 @@
574 717
575 718 $ErrorMonitor->create_admin_plugin_interface();
576 719
577 720 } catch (Exception $e) {
578 - 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
579 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>';
580 723 } catch (Error $e) {
581 - 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
582 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>';
583 726 }
584 727 ?>
585 728 </div>
@@ -592,9 +735,9 @@
592 735 require_once plugin_dir_path(dirname(__FILE__)) . '404-monitor/class-metasync-404-monitor-database.php';
593 736
594 737 $table_name = $wpdb->prefix . Metasync_Error_Monitor_Database::$table_name;
595 738
596 - 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
597 740 require_once plugin_dir_path(dirname(__FILE__)) . 'database/class-db-migrations.php';
598 741 MetaSync_DBMigration::run_migrations();
599 742 }
600 743 }
@@ -603,13 +746,13 @@
603 746 {
604 747 global $wpdb;
605 748 $table_name = $wpdb->prefix . 'metasync_redirections';
606 749
607 - 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
608 751 return;
609 752 }
610 753
611 - $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
612 755 $required_columns = ['pattern_type', 'regex_pattern', 'description', 'created_at', 'updated_at', 'last_accessed_at'];
613 756
614 757 $missing_columns = array_diff($required_columns, $columns);
615 758
@@ -615,9 +758,9 @@
615 758
616 759 if (!empty($missing_columns)) {
617 760 add_action('admin_notices', function() use ($missing_columns) {
618 761 echo '<div class="notice notice-warning is-dismissible">';
619 - 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>';
620 763 echo '<p><button type="button" class="button button-secondary" onclick="updateDatabaseStructure()">Update Database Structure</button></p>';
621 764 echo '</div>';
622 765
623 766 echo '<script>
@@ -624,9 +767,9 @@
624 767 function updateDatabaseStructure() {
625 768 if (confirm("This will update your database structure. Continue?")) {
626 769 const formData = new FormData();
627 770 formData.append("action", "metasync_update_db_structure");
628 - formData.append("nonce", "' . wp_create_nonce('metasync_update_db_nonce') . '");
771 + formData.append("nonce", "' . esc_attr(wp_create_nonce('metasync_update_db_nonce')) . '");
629 772
630 773 fetch(ajaxurl, {
631 774 method: "POST",
632 775 body: formData