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 +244 -50 2.6.5 → 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);
@@ -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
@@ -132,21 +158,20 @@
132 158
133 159 foreach ($source_urls as $source_url) {
134 160 $trimmed_url = trim($source_url);
135 161
136 - if (empty($trimmed_url)) {
137 - $empty_count++;
138 - continue;
139 - }
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 + }
140 166
141 - if (!$this->is_valid_url($trimmed_url)) {
142 - $validation_errors[] = 'Invalid source URL format: "' . esc_html($trimmed_url) . '". URLs should start with / for relative paths or be complete URLs.';
143 - }
144 -
145 - if (in_array($trimmed_url, $processed_sources)) {
146 - $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 + }
147 172 } else {
148 - $processed_sources[] = $trimmed_url;
173 + $empty_count++;
149 174 }
150 175 }
151 176
152 177 if ($empty_count === count($source_urls)) {
@@ -164,8 +189,16 @@
164 189 if (empty($trimmed_dest)) {
165 190 $validation_errors[] = 'Destination URL is required for this redirect type.';
166 191 } elseif (!$this->is_valid_url($trimmed_dest)) {
167 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.';
199 + } elseif (!get_option('metasync_allow_external_redirects', 0) && wp_validate_redirect($trimmed_dest, '') !== $trimmed_dest) {
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.';
168 201 }
169 202 }
170 203
171 204 $allowed_statuses = ['active', 'inactive'];
@@ -174,9 +207,9 @@
174 207 }
175 208
176 209 if (!empty($validation_errors)) {
177 210 $error_message = implode(' ', $validation_errors);
178 - set_transient('metasync_redirection_error', $error_message, 45);
211 + set_transient('metasync_redirection_error_' . $uid, $error_message, 45);
179 212 $this->safe_redirect(admin_url('admin.php?page=' . Metasync_Admin::$page_slug . '-redirections'));
180 213 }
181 214
182 215 $sources_from = [];
@@ -188,9 +221,12 @@
188 221 }
189 222 }
190 223
191 224 $pattern_type = 'exact';
192 - 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) {
193 229 if (!empty($search_type)) {
194 230 $pattern_type = $search_type;
195 231 break;
196 232 }
@@ -197,13 +233,29 @@
197 233 }
198 234
199 235 if ($pattern_type === 'regex') {
200 236 if (empty($regex_pattern)) {
201 - set_transient('metasync_redirection_error', 'Please enter a regex pattern when using "Regex Pattern" as the pattern type.', 45);
237 + set_transient('metasync_redirection_error_' . $uid, 'Please enter a regex pattern when using "Regex Pattern" as the pattern type.', 45);
202 238 $this->safe_redirect(admin_url('admin.php?page=' . Metasync_Admin::$page_slug . '-redirections'));
203 239 return;
204 240 }
205 241
242 + // Limit pattern length to prevent ReDoS via catastrophic backtracking
243 + if (strlen($regex_pattern) > 500) {
244 + set_transient('metasync_redirection_error_' . $uid, 'Regex pattern is too long (max 500 characters).', 45);
245 + $this->safe_redirect(admin_url('admin.php?page=' . Metasync_Admin::$page_slug . '-redirections'));
246 + return;
247 + }
248 +
249 + // Reject patterns with nested quantification that could cause ReDoS,
250 + // including alternation blowup ('((a|a)*)*') the older guard missed.
251 + $raw_check = preg_replace('/^\S(.*)\S[a-zA-Z]*$/', '$1', $regex_pattern);
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);
254 + $this->safe_redirect(admin_url('admin.php?page=' . Metasync_Admin::$page_slug . '-redirections'));
255 + return;
256 + }
257 +
206 258 $test_pattern = $regex_pattern;
207 259 $delimiter_chars = ['/', '#', '~', '%', '@'];
208 260 $has_valid_delimiters = false;
209 261
@@ -219,13 +271,13 @@
219 271
220 272 if (!$has_valid_delimiters) {
221 273 $test_pattern = '/' . $test_pattern . '/';
222 274 }
223 - $is_valid = @preg_match($test_pattern, '');
275 + $is_valid = Metasync_Regex_Validator::is_valid($test_pattern);
224 276 if ($is_valid === false) {
225 277 $error_message = error_get_last();
226 278 $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);
279 + set_transient('metasync_redirection_error_' . $uid, 'Invalid Regex Pattern: ' . $error_text . ' Please fix the regex pattern and try again.', 45);
228 280 $this->safe_redirect(admin_url('admin.php?page=' . Metasync_Admin::$page_slug . '-redirections'));
229 281 return;
230 282 }
231 283 }
@@ -230,25 +282,43 @@
230 282 }
231 283 }
232 284
233 285 // 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) {
286 + if (!in_array($redirect_type, [410, 451])) {
235 287 require_once dirname(__FILE__, 2) . '/redirections/class-metasync-redirection.php';
288 + require_once dirname(__FILE__, 2) . '/redirections/class-metasync-redirection-validator.php';
236 289 $redirection_helper = new Metasync_Redirection($this->db_redirection);
237 290 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 - );
291 + $loop_error = $redirection_helper->validate_no_loop($source_url, $destination_url);
292 + if ($loop_error !== null) {
293 + set_transient('metasync_redirection_error_' . $uid, $loop_error, 45);
245 294 $this->safe_redirect(admin_url('admin.php?page=' . Metasync_Admin::$page_slug . '-redirections'));
246 295 return;
247 296 }
248 297 }
298 +
299 + // Non-blocking destination reachability check — surface a warning, never block the save.
300 + $dest_warning = $redirection_helper->destination_resolves_warning($destination_url, $redirect_type, $pattern_type);
301 + if ($dest_warning !== null) {
302 + set_transient('metasync_redirection_warning_' . $uid, $dest_warning, 45);
303 + }
249 304 }
250 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 +
251 321 $data = [
252 322 'sources_from' => serialize($sources_from),
253 323 'url_redirect_to' => $destination_url,
254 324 'http_code' => $redirect_type,
@@ -273,13 +343,14 @@
273 343 $message = 'Redirection added successfully.';
274 344 }
275 345
276 346 } catch (Exception $e) {
277 - set_transient('metasync_redirection_error', 'Error saving redirection: ' . $e->getMessage(), 45);
347 + error_log('MetaSync error: ' . $e->getMessage()); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- genuine failure path, bounded, no secrets
348 + set_transient('metasync_redirection_error_' . $uid, 'An error occurred while saving the redirection. Please try again.', 45);
278 349 $this->safe_redirect(admin_url('admin.php?page=' . Metasync_Admin::$page_slug . '-redirections'));
279 350 }
280 351
281 - set_transient('metasync_redirection_success', $message, 45);
352 + set_transient('metasync_redirection_success_' . $uid, $message, 45);
282 353
283 354 $this->safe_redirect(admin_url('admin.php?page=' . Metasync_Admin::$page_slug . '-redirections'));
284 355 }
285 356
@@ -291,8 +362,123 @@
291 362
292 363 return filter_var($url, FILTER_VALIDATE_URL) !== false;
293 364 }
294 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 +
295 481 private function add_tabbed_interface_assets()
296 482 {
297 483 ?>
298 484 <style>
@@ -482,8 +668,16 @@
482 668 }
483 669
484 670 private function render_tab_content($current_tab)
485 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 +
486 680 $this->render_redirections_tab($current_tab);
487 681 $this->render_404_monitor_tab($current_tab);
488 682 }
489 683
@@ -489,14 +683,14 @@
489 683
490 684 private function render_redirections_tab($current_tab = null)
491 685 {
492 686 if ($current_tab === null) {
493 - $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
494 688 }
495 689 $active_class = $current_tab === 'redirections' ? 'active' : '';
496 690
497 691 ?>
498 - <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); ?>">
499 693 <?php
500 694 $redirection = new Metasync_Redirection($this->db_redirection);
501 695 $redirection->create_admin_redirection_interface();
502 696 ?>
@@ -506,14 +700,14 @@
506 700
507 701 private function render_404_monitor_tab($current_tab = null)
508 702 {
509 703 if ($current_tab === null) {
510 - $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
511 705 }
512 706 $active_class = $current_tab === '404-monitor' ? 'active' : '';
513 707
514 708 ?>
515 - <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); ?>">
516 710 <?php
517 711 try {
518 712 require_once plugin_dir_path(dirname(__FILE__)) . '404-monitor/class-metasync-404-monitor-database.php';
519 713 require_once plugin_dir_path(dirname(__FILE__)) . '404-monitor/class-metasync-404-monitor.php';
@@ -523,13 +717,13 @@
523 717
524 718 $ErrorMonitor->create_admin_plugin_interface();
525 719
526 720 } 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());
721 + error_log('MetaSync 404 Monitor Error: ' . $e->getMessage()); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- genuine failure path, bounded, no secrets
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>';
529 723 } 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());
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
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>';
532 726 }
533 727 ?>
534 728 </div>
535 729 <?php
@@ -541,9 +735,9 @@
541 735 require_once plugin_dir_path(dirname(__FILE__)) . '404-monitor/class-metasync-404-monitor-database.php';
542 736
543 737 $table_name = $wpdb->prefix . Metasync_Error_Monitor_Database::$table_name;
544 738
545 - 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
546 740 require_once plugin_dir_path(dirname(__FILE__)) . 'database/class-db-migrations.php';
547 741 MetaSync_DBMigration::run_migrations();
548 742 }
549 743 }
@@ -552,13 +746,13 @@
552 746 {
553 747 global $wpdb;
554 748 $table_name = $wpdb->prefix . 'metasync_redirections';
555 749
556 - 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
557 751 return;
558 752 }
559 753
560 - $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
561 755 $required_columns = ['pattern_type', 'regex_pattern', 'description', 'created_at', 'updated_at', 'last_accessed_at'];
562 756
563 757 $missing_columns = array_diff($required_columns, $columns);
564 758
@@ -564,9 +758,9 @@
564 758
565 759 if (!empty($missing_columns)) {
566 760 add_action('admin_notices', function() use ($missing_columns) {
567 761 echo '<div class="notice notice-warning is-dismissible">';
568 - 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>';
569 763 echo '<p><button type="button" class="button button-secondary" onclick="updateDatabaseStructure()">Update Database Structure</button></p>';
570 764 echo '</div>';
571 765
572 766 echo '<script>
@@ -573,9 +767,9 @@
573 767 function updateDatabaseStructure() {
574 768 if (confirm("This will update your database structure. Continue?")) {
575 769 const formData = new FormData();
576 770 formData.append("action", "metasync_update_db_structure");
577 - formData.append("nonce", "' . wp_create_nonce('metasync_update_db_nonce') . '");
771 + formData.append("nonce", "' . esc_attr(wp_create_nonce('metasync_update_db_nonce')) . '");
578 772
579 773 fetch(ajaxurl, {
580 774 method: "POST",
581 775 body: formData