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 | redirections/class-metasync-redirection.php +84 -18 2.6.26 → 2.7.2 View file →
@@ -78,8 +78,12 @@
78 78 // in the exact index instead of '/0'.
79 79 if ((is_int($source_key) || ctype_digit((string) $source_key)) && is_string($source_value) && $source_value !== '') {
80 80 $source_key = $source_value;
81 81 }
82 + // Rows saved before the input-unslash fix may still hold
83 + // magic-quoted sources; heal at read time so they keep
84 + // matching the unslashed request URI.
85 + $source_key = wp_unslash((string) $source_key);
82 86 $pattern_type = in_array($source_value, array('exact', 'contain', 'start', 'end', 'wildcard', 'regex'))
83 87 ? $source_value
84 88 : ($global_pattern_type ? $global_pattern_type : 'exact');
85 89
@@ -86,9 +90,9 @@
86 90 if ($pattern_type === 'exact' && strpos((string) $source_key, '*') === false) {
87 91 // Normalize: extract path from full URLs, ensure leading slash, strip trailing slash
88 92 $norm = (string) $source_key;
89 93 if (strpos($norm, 'http') === 0) {
90 - $parsed = parse_url($norm);
94 + $parsed = wp_parse_url($norm);
91 95 $norm = isset($parsed['path']) ? $parsed['path'] : '/';
92 96 }
93 97 if ($norm === '' || $norm[0] !== '/') {
94 98 $norm = '/' . $norm;
@@ -114,9 +118,9 @@
114 118
115 119 public function create_admin_redirection_interface()
116 120 {
117 121 # Check if we should show import interface
118 - $request_data = metasync_sanitize_input_array($_REQUEST);
122 + $request_data = metasync_sanitize_input_array($_REQUEST); // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only view switch, the import verifies its own nonce
119 123 if (isset($request_data['action']) && $request_data['action'] === 'import') {
120 124 $this->show_import_interface();
121 125 return;
122 126 }
@@ -158,9 +162,9 @@
158 162 wp_send_json_error(['message' => 'Insufficient permissions.']);
159 163 return;
160 164 }
161 165
162 - $plugin = isset($_POST['plugin']) ? sanitize_text_field($_POST['plugin']) : '';
166 + $plugin = isset($_POST['plugin']) ? sanitize_text_field(wp_unslash($_POST['plugin'])) : '';
163 167
164 168 if (empty($plugin)) {
165 169 wp_send_json_error(['message' => 'No plugin specified.']);
166 170 return;
@@ -176,8 +180,10 @@
176 180 try {
177 181 # Perform import
178 182 $result = $this->importer->import_from_plugin($plugin);
179 183
184 + Metasync_PostHog::import_batch($plugin, 'redirections', $result);
185 +
180 186 if ($result['success']) {
181 187 wp_send_json_success($result);
182 188 } else {
183 189 wp_send_json_error($result);
@@ -197,9 +203,9 @@
197 203 * this is the first implementation of it.
198 204 */
199 205 private function handle_csv_import_ajax()
200 206 {
201 - $file = isset($_FILES['csv_file']) && is_array($_FILES['csv_file']) ? $_FILES['csv_file'] : [];
207 + $file = isset($_FILES['csv_file']) && is_array($_FILES['csv_file']) ? $_FILES['csv_file'] : []; // phpcs:ignore WordPress.Security.NonceVerification.Missing -- private, caller verifies a nonce and capability first
202 208
203 209 // wp_send_json_error() ends the request, so no return is needed (or
204 210 // reachable) after any of these guards.
205 211 if (empty($file['tmp_name']) || (isset($file['error']) && (int) $file['error'] !== UPLOAD_ERR_OK)) {
@@ -219,8 +225,10 @@
219 225 }
220 226
221 227 $result = $this->importer->import_csv_file($file['tmp_name']);
222 228
229 + Metasync_PostHog::import_batch('csv', 'redirections', $result);
230 +
223 231 if (!empty($result['success'])) {
224 232 wp_send_json_success($result);
225 233 } else {
226 234 wp_send_json_error($result);
@@ -265,8 +273,12 @@
265 273 if (ctype_digit($source_key) && is_string($source_value) && $source_value !== '') {
266 274 $source_key = $source_value;
267 275 }
268 276
277 + // Heal magic-quoted sources saved before the input-unslash fix so
278 + // pattern matching sees the literal value the user typed.
279 + $source_key = wp_unslash((string) $source_key);
280 +
269 281 // Determine pattern type: use source value if it's a valid pattern, otherwise use global pattern_type
270 282 $pattern_type = in_array($source_value, ['exact', 'contain', 'start', 'end', 'wildcard', 'regex'])
271 283 ? $source_value
272 284 : ($global_pattern_type ? $global_pattern_type : 'exact');
@@ -276,9 +288,9 @@
276 288 $normalized_source = $source_key;
277 289
278 290 // If source is a full URL, extract just the path part
279 291 if (strpos($source_key, 'http') === 0) {
280 - $parsed_url = parse_url($source_key);
292 + $parsed_url = wp_parse_url($source_key);
281 293 $normalized_source = $parsed_url['path'] ?? '';
282 294 }
283 295
284 296 # Ensure both have leading slashes for proper comparison
@@ -414,9 +426,16 @@
414 426 $destination = esc_url_raw($destination);
415 427 if (empty($destination)) {
416 428 $destination = home_url();
417 429 }
418 - wp_redirect($destination, $row->http_code);
430 + // Same syntax hygiene as the default branch: the opt-in
431 + // permits external hosts, never syntactic evasion, so a
432 + // backslash trick like '/\evil.com' still bounces home.
433 + $checked = Metasync_Redirection_Validator::normalize_destination($destination);
434 + if (!Metasync_Redirection_Validator::is_safe_destination_syntax($checked)) {
435 + $checked = home_url();
436 + }
437 + $this->safe_redirect_to_destination($checked, $row->http_code);
419 438 } else {
420 439 // Stored destinations can carry backslashes (imports,
421 440 // older rows). Browsers read them as path separators, so
422 441 // '/\evil.com' would slip past wp_validate_redirect and
@@ -425,11 +444,11 @@
425 444 $checked = Metasync_Redirection_Validator::normalize_destination($destination);
426 445 if (!Metasync_Redirection_Validator::is_safe_destination_syntax($checked)) {
427 446 $checked = home_url();
428 447 }
429 - wp_redirect(wp_validate_redirect($checked, home_url()), $row->http_code);
448 + $this->safe_redirect_to_destination(wp_validate_redirect($checked, home_url()), $row->http_code);
430 449 }
431 - die;
450 + // Both branches terminate inside safe_redirect_to_destination().
432 451 }
433 452 // Match found and processed, return true to stop checking other rules
434 453 return true;
435 454 }
@@ -439,8 +458,49 @@
439 458 return false;
440 459 }
441 460
442 461 /**
462 + * Emit a redirect through wp_safe_redirect() and end execution.
463 + *
464 + * wp_safe_redirect() confines the Location header to hosts WordPress
465 + * trusts (this site plus anything on the allowed_redirect_hosts
466 + * allowlist). The one intentional external destination is an exact-match
467 + * rule saved by an admin while the metasync_allow_external_redirects
468 + * opt-in is enabled, so the parsed host of the already syntax-validated
469 + * destination is allowlisted for this request only. Everything else —
470 + * same-site destinations, relative paths — passes through untouched.
471 + *
472 + * @param string $destination Validated destination URL or path.
473 + * @param string|int $http_code Redirect status code from the rule row.
474 + * @return void Never returns; wp_safe_redirect() is followed by exit.
475 + */
476 + private function safe_redirect_to_destination($destination, $http_code)
477 + {
478 + $host = wp_parse_url($destination, PHP_URL_HOST);
479 + $grant_host = null;
480 + if (is_string($host) && $host !== '') {
481 + // Scope the temporary host grant to this one redirect. The filter
482 + // runs while wp_safe_redirect() validates the Location header and
483 + // is removed before exit: shutdown callbacks registered elsewhere
484 + // still fire after exit, so the grant must not outlive the call.
485 + $grant_host = static function ($allowed_hosts, $checked_host = '') use ($host) {
486 + if (is_array($allowed_hosts)) {
487 + $allowed_hosts[] = $host;
488 + }
489 + return $allowed_hosts;
490 + };
491 + add_filter('allowed_redirect_hosts', $grant_host, 10, 2);
492 + }
493 + wp_safe_redirect($destination, $http_code);
494 + if (null !== $grant_host) {
495 + // Removal matches on priority + callback identity; core's
496 + // remove_filter() takes no accepted-args parameter.
497 + remove_filter('allowed_redirect_hosts', $grant_host, 10);
498 + }
499 + exit;
500 + }
501 +
502 + /**
443 503 * Resolve a URL through the redirect table to its final destination (follows redirect chains).
444 504 * Used by OTTO and other backend processing so the final canonical URL is used before 404 checks.
445 505 *
446 506 * @param string $url Full URL (e.g. https://example.com/old-page)
@@ -451,9 +511,9 @@
451 511 {
452 512 if (empty($url) || !is_string($url)) {
453 513 return $url;
454 514 }
455 - $parsed = parse_url($url);
515 + $parsed = wp_parse_url($url);
456 516 $scheme = isset($parsed['scheme']) ? $parsed['scheme'] : 'https';
457 517 $host = isset($parsed['host']) ? $parsed['host'] : '';
458 518 $base = $scheme . '://' . $host;
459 519 // Track the query separately from the path: sources match on the path
@@ -475,9 +535,9 @@
475 535 }
476 536 $dest = $this->append_query_string($dest, $query);
477 537 if (strpos($dest, 'http') === 0) {
478 538 $url = $dest;
479 - $parsed = parse_url($url);
539 + $parsed = wp_parse_url($url);
480 540 $scheme = isset($parsed['scheme']) ? $parsed['scheme'] : 'https';
481 541 $host = isset($parsed['host']) ? $parsed['host'] : '';
482 542 $base = $scheme . '://' . $host;
483 543 $uri = isset($parsed['path']) ? $parsed['path'] : '/';
@@ -640,12 +700,12 @@
640 700 // Determine if the destination is external by checking for a host component
641 701 // that differs from the local site. Relative paths (e.g. /about) have no host
642 702 // and are always internal.
643 703 $is_external = false;
644 - $parsed_dest = parse_url($destination);
704 + $parsed_dest = wp_parse_url($destination);
645 705 if (!empty($parsed_dest['host'])) {
646 - $site_host = parse_url(site_url(), PHP_URL_HOST);
647 - $is_external = (strcasecmp($parsed_dest['host'], $site_host) !== 0);
706 + $site_host = wp_parse_url(site_url(), PHP_URL_HOST);
707 + $is_external = false === $site_host || strcasecmp($parsed_dest['host'], (string) $site_host) !== 0;
648 708 }
649 709
650 710 if ($is_external) {
651 711 // Only probe external targets when off-site redirects are explicitly enabled,
@@ -690,9 +750,9 @@
690 750 if (!is_string($url) || $url === '') {
691 751 return '/';
692 752 }
693 753 if (strpos($url, 'http') === 0) {
694 - $parsed = parse_url($url);
754 + $parsed = wp_parse_url($url);
695 755 $path = isset($parsed['path']) ? $parsed['path'] : '/';
696 756 } else {
697 757 $path = $url;
698 758 }
@@ -984,8 +1044,13 @@
984 1044 if ((is_int($source_key) || ctype_digit((string) $source_key)) && is_string($source_value) && $source_value !== '') {
985 1045 $source_key = $source_value;
986 1046 }
987 1047
1048 + // Heal magic-quoted sources saved before the input-unslash fix so
1049 + // this health-check view matches with the same semantics as the
1050 + // live front-end matcher.
1051 + $source_key = wp_unslash((string) $source_key);
1052 +
988 1053 // Resolve pattern type: per-source value (exact, contain, start, end, wildcard, regex) or row-level default
989 1054 $pattern_type = in_array($source_value, array('exact', 'contain', 'start', 'end', 'wildcard', 'regex'))
990 1055 ? $source_value
991 1056 : ($global_pattern_type ? $global_pattern_type : 'exact');
@@ -994,9 +1059,9 @@
994 1059 $normalized_source = $source_key;
995 1060
996 1061 // If source is a full URL, use only the path for matching (consistent with front-end redirect behavior)
997 1062 if (strpos($source_key, 'http') === 0) {
998 - $parsed_src = parse_url($source_key);
1063 + $parsed_src = wp_parse_url($source_key);
999 1064 $normalized_source = isset($parsed_src['path']) ? $parsed_src['path'] : '';
1000 1065 }
1001 1066
1002 1067 // Ensure leading slash for reliable path comparison
@@ -1189,10 +1254,11 @@
1189 1254 if (is_admin()) {
1190 1255 return;
1191 1256 }
1192 1257
1193 - // Get current URI
1194 - $request_uri = isset($_SERVER['REQUEST_URI']) ? (string) $_SERVER['REQUEST_URI'] : '/';
1258 + // Get current URI. wp_magic_quotes() slashes $_SERVER too, so unslash
1259 + // here to compare against the literal (unslashed) stored sources.
1260 + $request_uri = isset($_SERVER['REQUEST_URI']) ? wp_unslash((string) $_SERVER['REQUEST_URI']) : '/';
1195 1261
1196 1262 // Keep the raw query for the redirect destination. It is deliberately
1197 1263 // excluded from matching below, but must survive the browser redirect.
1198 1264 $query_pos = strpos($request_uri, '?');
@@ -1378,9 +1444,9 @@
1378 1444 # return $test_result !== false;
1379 1445
1380 1446 // Use error handler to catch warnings from malformed patterns
1381 1447 $error_occurred = false;
1382 - set_error_handler(function() use (&$error_occurred) {
1448 + set_error_handler(function() use (&$error_occurred) { // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_set_error_handler -- genuine failure path, bounded, no secrets
1383 1449 $error_occurred = true;
1384 1450 return true; // Suppress the error
1385 1451 }, E_WARNING);
1386 1452