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 +77 -29 2.6.8 → 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,14 @@
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.';
168 200 } elseif (!get_option('metasync_allow_external_redirects', 0) && wp_validate_redirect($trimmed_dest, '') !== $trimmed_dest) {
169 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.';
170 202 }
171 203 }
@@ -176,9 +208,9 @@
176 208 }
177 209
178 210 if (!empty($validation_errors)) {
179 211 $error_message = implode(' ', $validation_errors);
180 - set_transient('metasync_redirection_error', $error_message, 45);
212 + set_transient('metasync_redirection_error_' . $uid, $error_message, 45);
181 213 $this->safe_redirect(admin_url('admin.php?page=' . Metasync_Admin::$page_slug . '-redirections'));
182 214 }
183 215
184 216 $sources_from = [];
@@ -199,9 +231,9 @@
199 231 }
200 232
201 233 if ($pattern_type === 'regex') {
202 234 if (empty($regex_pattern)) {
203 - 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);
204 236 $this->safe_redirect(admin_url('admin.php?page=' . Metasync_Admin::$page_slug . '-redirections'));
205 237 return;
206 238 }
207 239
@@ -206,17 +238,18 @@
206 238 }
207 239
208 240 // Limit pattern length to prevent ReDoS via catastrophic backtracking
209 241 if (strlen($regex_pattern) > 500) {
210 - set_transient('metasync_redirection_error', 'Regex pattern is too long (max 500 characters).', 45);
242 + set_transient('metasync_redirection_error_' . $uid, 'Regex pattern is too long (max 500 characters).', 45);
211 243 $this->safe_redirect(admin_url('admin.php?page=' . Metasync_Admin::$page_slug . '-redirections'));
212 244 return;
213 245 }
214 246
215 - // Reject patterns with nested quantifiers that could cause ReDoS
247 + // Reject patterns with nested quantification that could cause ReDoS,
248 + // including alternation blowup ('((a|a)*)*') the older guard missed.
216 249 $raw_check = preg_replace('/^\S(.*)\S[a-zA-Z]*$/', '$1', $regex_pattern);
217 - if (preg_match('/(\([^)]*[+*][^)]*\))[+*?{]|(\[[^\]]*\])[+*][+*?{]/', $raw_check)) {
218 - set_transient('metasync_redirection_error', 'Regex pattern contains potentially unsafe nested quantifiers.', 45);
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);
219 252 $this->safe_redirect(admin_url('admin.php?page=' . Metasync_Admin::$page_slug . '-redirections'));
220 253 return;
221 254 }
222 255
@@ -240,9 +273,9 @@
240 273 $is_valid = Metasync_Regex_Validator::is_valid($test_pattern);
241 274 if ($is_valid === false) {
242 275 $error_message = error_get_last();
243 276 $error_text = isset($error_message['message']) ? $error_message['message'] : 'Unknown regex error';
244 - 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);
245 278 $this->safe_redirect(admin_url('admin.php?page=' . Metasync_Admin::$page_slug . '-redirections'));
246 279 return;
247 280 }
248 281 }
@@ -249,17 +282,24 @@
249 282
250 283 // Loop detection: refuse to persist a chain that would resolve back to any source
251 284 if (!in_array($redirect_type, [410, 451])) {
252 285 require_once dirname(__FILE__, 2) . '/redirections/class-metasync-redirection.php';
286 + require_once dirname(__FILE__, 2) . '/redirections/class-metasync-redirection-validator.php';
253 287 $redirection_helper = new Metasync_Redirection($this->db_redirection);
254 288 foreach (array_keys($sources_from) as $source_url) {
255 289 $loop_error = $redirection_helper->validate_no_loop($source_url, $destination_url);
256 290 if ($loop_error !== null) {
257 - set_transient('metasync_redirection_error', $loop_error, 45);
291 + set_transient('metasync_redirection_error_' . $uid, $loop_error, 45);
258 292 $this->safe_redirect(admin_url('admin.php?page=' . Metasync_Admin::$page_slug . '-redirections'));
259 293 return;
260 294 }
261 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 + }
262 302 }
263 303
264 304 $data = [
265 305 'sources_from' => serialize($sources_from),
@@ -286,14 +326,14 @@
286 326 $message = 'Redirection added successfully.';
287 327 }
288 328
289 329 } catch (Exception $e) {
290 - error_log('MetaSync error: ' . $e->getMessage());
291 - set_transient('metasync_redirection_error', 'An error occurred while saving the redirection. Please try again.', 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);
292 332 $this->safe_redirect(admin_url('admin.php?page=' . Metasync_Admin::$page_slug . '-redirections'));
293 333 }
294 334
295 - set_transient('metasync_redirection_success', $message, 45);
335 + set_transient('metasync_redirection_success_' . $uid, $message, 45);
296 336
297 337 $this->safe_redirect(admin_url('admin.php?page=' . Metasync_Admin::$page_slug . '-redirections'));
298 338 }
299 339
@@ -496,8 +536,16 @@
496 536 }
497 537
498 538 private function render_tab_content($current_tab)
499 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 +
500 548 $this->render_redirections_tab($current_tab);
501 549 $this->render_404_monitor_tab($current_tab);
502 550 }
503 551
@@ -503,9 +551,9 @@
503 551
504 552 private function render_redirections_tab($current_tab = null)
505 553 {
506 554 if ($current_tab === null) {
507 - $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
508 556 }
509 557 $active_class = $current_tab === 'redirections' ? 'active' : '';
510 558
511 559 ?>
@@ -520,9 +568,9 @@
520 568
521 569 private function render_404_monitor_tab($current_tab = null)
522 570 {
523 571 if ($current_tab === null) {
524 - $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
525 573 }
526 574 $active_class = $current_tab === '404-monitor' ? 'active' : '';
527 575
528 576 ?>
@@ -537,12 +585,12 @@
537 585
538 586 $ErrorMonitor->create_admin_plugin_interface();
539 587
540 588 } catch (Exception $e) {
541 - 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
542 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>';
543 591 } catch (Error $e) {
544 - 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
545 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>';
546 594 }
547 595 ?>
548 596 </div>
@@ -555,9 +603,9 @@
555 603 require_once plugin_dir_path(dirname(__FILE__)) . '404-monitor/class-metasync-404-monitor-database.php';
556 604
557 605 $table_name = $wpdb->prefix . Metasync_Error_Monitor_Database::$table_name;
558 606
559 - 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
560 608 require_once plugin_dir_path(dirname(__FILE__)) . 'database/class-db-migrations.php';
561 609 MetaSync_DBMigration::run_migrations();
562 610 }
563 611 }
@@ -566,13 +614,13 @@
566 614 {
567 615 global $wpdb;
568 616 $table_name = $wpdb->prefix . 'metasync_redirections';
569 617
570 - 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
571 619 return;
572 620 }
573 621
574 - $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
575 623 $required_columns = ['pattern_type', 'regex_pattern', 'description', 'created_at', 'updated_at', 'last_accessed_at'];
576 624
577 625 $missing_columns = array_diff($required_columns, $columns);
578 626