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-redirections-admin.php +96 -34 2.6.5 → 2.7.1 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);
@@ -62,17 +76,24 @@
62 76 }
63 77
64 78 public function display_redirection_messages()
65 79 {
66 - if ($error = get_transient('metasync_redirection_error')) {
80 + $uid = get_current_user_id();
81 +
82 + if ($error = get_transient('metasync_redirection_error_' . $uid)) {
67 83 echo '<div class="notice notice-error is-dismissible"><p>' . esc_html($error) . '</p></div>';
68 - delete_transient('metasync_redirection_error');
84 + delete_transient('metasync_redirection_error_' . $uid);
69 85 }
70 86
71 - if ($success = get_transient('metasync_redirection_success')) {
87 + if ($success = get_transient('metasync_redirection_success_' . $uid)) {
72 88 echo '<div class="notice notice-success is-dismissible"><p>' . esc_html($success) . '</p></div>';
73 - delete_transient('metasync_redirection_success');
89 + delete_transient('metasync_redirection_success_' . $uid);
74 90 }
91 +
92 + if ($warning = get_transient('metasync_redirection_warning_' . $uid)) {
93 + echo '<div class="notice notice-warning is-dismissible"><p>' . esc_html($warning) . '</p></div>';
94 + delete_transient('metasync_redirection_warning_' . $uid);
95 + }
75 96 }
76 97
77 98 /* ------------------------------------------------------------------
78 99 * Private helpers
@@ -80,9 +101,9 @@
80 101
81 102 private function safe_redirect($url)
82 103 {
83 104 if (!headers_sent()) {
84 - wp_redirect($url);
105 + wp_safe_redirect($url);
85 106 exit;
86 107 } else {
87 108 echo '<script type="text/javascript">window.location.href = "' . esc_url($url) . '";</script>';
88 109 echo '<noscript><meta http-equiv="refresh" content="0;url=' . esc_url($url) . '"></noscript>';
@@ -95,8 +116,10 @@
95 116 if (!isset($_POST['submit'])) {
96 117 return;
97 118 }
98 119
120 + $uid = get_current_user_id();
121 +
99 122 $nonce_valid = false;
100 123
101 124 if (isset($_POST['metasync_redirection_nonce']) && wp_verify_nonce($_POST['metasync_redirection_nonce'], 'metasync_redirection_form')) {
102 125 $nonce_valid = true;
@@ -112,15 +135,18 @@
112 135 if (!Metasync::current_user_has_plugin_access()) {
113 136 wp_die('Insufficient permissions.');
114 137 }
115 138
116 - $source_urls = isset($_POST['source_url']) ? array_map('sanitize_text_field', $_POST['source_url']) : [];
117 - $search_types = isset($_POST['search_type']) ? array_map('sanitize_text_field', $_POST['search_type']) : [];
118 - $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'])) : '';
119 145 $redirect_type = isset($_POST['redirect_type']) ? intval($_POST['redirect_type']) : 301;
120 - $status = isset($_POST['status']) ? sanitize_text_field($_POST['status']) : 'active';
146 + $status = isset($_POST['status']) ? sanitize_text_field(wp_unslash($_POST['status'])) : 'active';
121 147 $regex_pattern = isset($_POST['regex_pattern']) ? wp_unslash(trim($_POST['regex_pattern'])) : '';
122 - $description = isset($_POST['description']) ? sanitize_text_field($_POST['description']) : '';
148 + $description = isset($_POST['description']) ? sanitize_text_field(wp_unslash($_POST['description'])) : '';
123 149 $redirect_id = isset($_POST['redirect_id']) ? intval($_POST['redirect_id']) : 0;
124 150
125 151 $validation_errors = [];
126 152
@@ -164,8 +190,16 @@
164 190 if (empty($trimmed_dest)) {
165 191 $validation_errors[] = 'Destination URL is required for this redirect type.';
166 192 } elseif (!$this->is_valid_url($trimmed_dest)) {
167 193 $validation_errors[] = 'Invalid destination URL format. URLs should start with / for relative paths or be complete URLs.';
194 + } elseif (!Metasync_Redirection_Validator::is_safe_destination_syntax($trimmed_dest)) {
195 + // Browsers treat backslashes as path separators, so '/\evil.com'
196 + // passes wp_validate_redirect yet navigates off-site; a leading
197 + // '//' is a protocol-relative host. Neither is a valid internal
198 + // destination regardless of the external-redirects setting.
199 + $validation_errors[] = 'Destination URL contains invalid characters.';
200 + } elseif (!get_option('metasync_allow_external_redirects', 0) && wp_validate_redirect($trimmed_dest, '') !== $trimmed_dest) {
201 + $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.';
168 202 }
169 203 }
170 204
171 205 $allowed_statuses = ['active', 'inactive'];
@@ -174,9 +208,9 @@
174 208 }
175 209
176 210 if (!empty($validation_errors)) {
177 211 $error_message = implode(' ', $validation_errors);
178 - set_transient('metasync_redirection_error', $error_message, 45);
212 + set_transient('metasync_redirection_error_' . $uid, $error_message, 45);
179 213 $this->safe_redirect(admin_url('admin.php?page=' . Metasync_Admin::$page_slug . '-redirections'));
180 214 }
181 215
182 216 $sources_from = [];
@@ -197,13 +231,29 @@
197 231 }
198 232
199 233 if ($pattern_type === 'regex') {
200 234 if (empty($regex_pattern)) {
201 - set_transient('metasync_redirection_error', 'Please enter a regex pattern when using "Regex Pattern" as the pattern type.', 45);
235 + set_transient('metasync_redirection_error_' . $uid, 'Please enter a regex pattern when using "Regex Pattern" as the pattern type.', 45);
202 236 $this->safe_redirect(admin_url('admin.php?page=' . Metasync_Admin::$page_slug . '-redirections'));
203 237 return;
204 238 }
205 239
240 + // Limit pattern length to prevent ReDoS via catastrophic backtracking
241 + if (strlen($regex_pattern) > 500) {
242 + set_transient('metasync_redirection_error_' . $uid, 'Regex pattern is too long (max 500 characters).', 45);
243 + $this->safe_redirect(admin_url('admin.php?page=' . Metasync_Admin::$page_slug . '-redirections'));
244 + return;
245 + }
246 +
247 + // Reject patterns with nested quantification that could cause ReDoS,
248 + // including alternation blowup ('((a|a)*)*') the older guard missed.
249 + $raw_check = preg_replace('/^\S(.*)\S[a-zA-Z]*$/', '$1', $regex_pattern);
250 + if (!Metasync_Redirection_Validator::is_regex_safe($raw_check)) {
251 + set_transient('metasync_redirection_error_' . $uid, 'Regex pattern is too long or contains potentially unsafe nested quantifiers (including nested alternation).', 45);
252 + $this->safe_redirect(admin_url('admin.php?page=' . Metasync_Admin::$page_slug . '-redirections'));
253 + return;
254 + }
255 +
206 256 $test_pattern = $regex_pattern;
207 257 $delimiter_chars = ['/', '#', '~', '%', '@'];
208 258 $has_valid_delimiters = false;
209 259
@@ -219,13 +269,13 @@
219 269
220 270 if (!$has_valid_delimiters) {
221 271 $test_pattern = '/' . $test_pattern . '/';
222 272 }
223 - $is_valid = @preg_match($test_pattern, '');
273 + $is_valid = Metasync_Regex_Validator::is_valid($test_pattern);
224 274 if ($is_valid === false) {
225 275 $error_message = error_get_last();
226 276 $error_text = isset($error_message['message']) ? $error_message['message'] : 'Unknown regex error';
227 - set_transient('metasync_redirection_error', 'Invalid Regex Pattern: ' . $error_text . ' Please fix the regex pattern and try again.', 45);
277 + set_transient('metasync_redirection_error_' . $uid, 'Invalid Regex Pattern: ' . $error_text . ' Please fix the regex pattern and try again.', 45);
228 278 $this->safe_redirect(admin_url('admin.php?page=' . Metasync_Admin::$page_slug . '-redirections'));
229 279 return;
230 280 }
231 281 }
@@ -230,23 +280,26 @@
230 280 }
231 281 }
232 282
233 283 // Loop detection: refuse to persist a chain that would resolve back to any source
234 - if (!in_array($redirect_type, [410, 451]) && $redirect_id <= 0) {
284 + if (!in_array($redirect_type, [410, 451])) {
235 285 require_once dirname(__FILE__, 2) . '/redirections/class-metasync-redirection.php';
286 + require_once dirname(__FILE__, 2) . '/redirections/class-metasync-redirection-validator.php';
236 287 $redirection_helper = new Metasync_Redirection($this->db_redirection);
237 288 foreach (array_keys($sources_from) as $source_url) {
238 - $loop_chain = [];
239 - if ($redirection_helper->would_create_loop($source_url, $destination_url, $loop_chain)) {
240 - set_transient(
241 - 'metasync_redirection_error',
242 - 'Redirect would create a loop: ' . implode(' → ', $loop_chain),
243 - 45
244 - );
289 + $loop_error = $redirection_helper->validate_no_loop($source_url, $destination_url);
290 + if ($loop_error !== null) {
291 + set_transient('metasync_redirection_error_' . $uid, $loop_error, 45);
245 292 $this->safe_redirect(admin_url('admin.php?page=' . Metasync_Admin::$page_slug . '-redirections'));
246 293 return;
247 294 }
248 295 }
296 +
297 + // Non-blocking destination reachability check — surface a warning, never block the save.
298 + $dest_warning = $redirection_helper->destination_resolves_warning($destination_url, $redirect_type, $pattern_type);
299 + if ($dest_warning !== null) {
300 + set_transient('metasync_redirection_warning_' . $uid, $dest_warning, 45);
301 + }
249 302 }
250 303
251 304 $data = [
252 305 'sources_from' => serialize($sources_from),
@@ -273,13 +326,14 @@
273 326 $message = 'Redirection added successfully.';
274 327 }
275 328
276 329 } catch (Exception $e) {
277 - set_transient('metasync_redirection_error', 'Error saving redirection: ' . $e->getMessage(), 45);
330 + error_log('MetaSync error: ' . $e->getMessage()); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- genuine failure path, bounded, no secrets
331 + set_transient('metasync_redirection_error_' . $uid, 'An error occurred while saving the redirection. Please try again.', 45);
278 332 $this->safe_redirect(admin_url('admin.php?page=' . Metasync_Admin::$page_slug . '-redirections'));
279 333 }
280 334
281 - set_transient('metasync_redirection_success', $message, 45);
335 + set_transient('metasync_redirection_success_' . $uid, $message, 45);
282 336
283 337 $this->safe_redirect(admin_url('admin.php?page=' . Metasync_Admin::$page_slug . '-redirections'));
284 338 }
285 339
@@ -482,8 +536,16 @@
482 536 }
483 537
484 538 private function render_tab_content($current_tab)
485 539 {
540 + // Both tables are rendered onto this one page (CSS hides the inactive
541 + // tab), so record which tab is actually being viewed. The list tables
542 + // use this to keep the hidden tab from emitting a duplicate set of
543 + // bulk-action controls - see get_bulk_actions() in either table.
544 + self::$combined_screen_active_tab = ($current_tab === '404-monitor')
545 + ? '404-monitor'
546 + : 'redirections';
547 +
486 548 $this->render_redirections_tab($current_tab);
487 549 $this->render_404_monitor_tab($current_tab);
488 550 }
489 551
@@ -489,9 +551,9 @@
489 551
490 552 private function render_redirections_tab($current_tab = null)
491 553 {
492 554 if ($current_tab === null) {
493 - $current_tab = isset($_GET['tab']) ? sanitize_text_field($_GET['tab']) : 'redirections';
555 + $current_tab = isset($_GET['tab']) ? sanitize_text_field(wp_unslash($_GET['tab'])) : 'redirections'; // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only tab selection
494 556 }
495 557 $active_class = $current_tab === 'redirections' ? 'active' : '';
496 558
497 559 ?>
@@ -506,9 +568,9 @@
506 568
507 569 private function render_404_monitor_tab($current_tab = null)
508 570 {
509 571 if ($current_tab === null) {
510 - $current_tab = isset($_GET['tab']) ? sanitize_text_field($_GET['tab']) : 'redirections';
572 + $current_tab = isset($_GET['tab']) ? sanitize_text_field(wp_unslash($_GET['tab'])) : 'redirections'; // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only tab selection
511 573 }
512 574 $active_class = $current_tab === '404-monitor' ? 'active' : '';
513 575
514 576 ?>
@@ -523,13 +585,13 @@
523 585
524 586 $ErrorMonitor->create_admin_plugin_interface();
525 587
526 588 } catch (Exception $e) {
527 - echo '<div class="notice notice-error"><p>Error loading 404 monitor: ' . esc_html($e->getMessage()) . '</p></div>';
528 - error_log('MetaSync 404 Monitor Error: ' . $e->getMessage());
589 + error_log('MetaSync 404 Monitor Error: ' . $e->getMessage()); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- genuine failure path, bounded, no secrets
590 + echo '<div class="notice notice-error"><p>An error occurred while loading the 404 monitor. Please check the server logs for details.</p></div>';
529 591 } catch (Error $e) {
530 - echo '<div class="notice notice-error"><p>Fatal error loading 404 monitor: ' . esc_html($e->getMessage()) . '</p></div>';
531 - error_log('MetaSync 404 Monitor Fatal Error: ' . $e->getMessage());
592 + error_log('MetaSync 404 Monitor Fatal Error: ' . $e->getMessage()); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- genuine failure path, bounded, no secrets
593 + 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>';
532 594 }
533 595 ?>
534 596 </div>
535 597 <?php
@@ -541,9 +603,9 @@
541 603 require_once plugin_dir_path(dirname(__FILE__)) . '404-monitor/class-metasync-404-monitor-database.php';
542 604
543 605 $table_name = $wpdb->prefix . Metasync_Error_Monitor_Database::$table_name;
544 606
545 - if ($wpdb->get_var($wpdb->prepare("SHOW TABLES LIKE %s", $table_name)) != $table_name) {
607 + 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
546 608 require_once plugin_dir_path(dirname(__FILE__)) . 'database/class-db-migrations.php';
547 609 MetaSync_DBMigration::run_migrations();
548 610 }
549 611 }
@@ -552,13 +614,13 @@
552 614 {
553 615 global $wpdb;
554 616 $table_name = $wpdb->prefix . 'metasync_redirections';
555 617
556 - if ($wpdb->get_var($wpdb->prepare("SHOW TABLES LIKE %s", $table_name)) != $table_name) {
618 + 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
557 619 return;
558 620 }
559 621
560 - $columns = $wpdb->get_col("DESCRIBE {$table_name}");
622 + $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
561 623 $required_columns = ['pattern_type', 'regex_pattern', 'description', 'created_at', 'updated_at', 'last_accessed_at'];
562 624
563 625 $missing_columns = array_diff($required_columns, $columns);
564 626