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 | redirections/class-metasync-redirection.php +80 -18 2.7.0 → 2.7.1 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;
@@ -197,9 +201,9 @@
197 201 * this is the first implementation of it.
198 202 */
199 203 private function handle_csv_import_ajax()
200 204 {
201 - $file = isset($_FILES['csv_file']) && is_array($_FILES['csv_file']) ? $_FILES['csv_file'] : [];
205 + $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 206
203 207 // wp_send_json_error() ends the request, so no return is needed (or
204 208 // reachable) after any of these guards.
205 209 if (empty($file['tmp_name']) || (isset($file['error']) && (int) $file['error'] !== UPLOAD_ERR_OK)) {
@@ -265,8 +269,12 @@
265 269 if (ctype_digit($source_key) && is_string($source_value) && $source_value !== '') {
266 270 $source_key = $source_value;
267 271 }
268 272
273 + // Heal magic-quoted sources saved before the input-unslash fix so
274 + // pattern matching sees the literal value the user typed.
275 + $source_key = wp_unslash((string) $source_key);
276 +
269 277 // Determine pattern type: use source value if it's a valid pattern, otherwise use global pattern_type
270 278 $pattern_type = in_array($source_value, ['exact', 'contain', 'start', 'end', 'wildcard', 'regex'])
271 279 ? $source_value
272 280 : ($global_pattern_type ? $global_pattern_type : 'exact');
@@ -276,9 +284,9 @@
276 284 $normalized_source = $source_key;
277 285
278 286 // If source is a full URL, extract just the path part
279 287 if (strpos($source_key, 'http') === 0) {
280 - $parsed_url = parse_url($source_key);
288 + $parsed_url = wp_parse_url($source_key);
281 289 $normalized_source = $parsed_url['path'] ?? '';
282 290 }
283 291
284 292 # Ensure both have leading slashes for proper comparison
@@ -414,9 +422,16 @@
414 422 $destination = esc_url_raw($destination);
415 423 if (empty($destination)) {
416 424 $destination = home_url();
417 425 }
418 - wp_redirect($destination, $row->http_code);
426 + // Same syntax hygiene as the default branch: the opt-in
427 + // permits external hosts, never syntactic evasion, so a
428 + // backslash trick like '/\evil.com' still bounces home.
429 + $checked = Metasync_Redirection_Validator::normalize_destination($destination);
430 + if (!Metasync_Redirection_Validator::is_safe_destination_syntax($checked)) {
431 + $checked = home_url();
432 + }
433 + $this->safe_redirect_to_destination($checked, $row->http_code);
419 434 } else {
420 435 // Stored destinations can carry backslashes (imports,
421 436 // older rows). Browsers read them as path separators, so
422 437 // '/\evil.com' would slip past wp_validate_redirect and
@@ -425,11 +440,11 @@
425 440 $checked = Metasync_Redirection_Validator::normalize_destination($destination);
426 441 if (!Metasync_Redirection_Validator::is_safe_destination_syntax($checked)) {
427 442 $checked = home_url();
428 443 }
429 - wp_redirect(wp_validate_redirect($checked, home_url()), $row->http_code);
444 + $this->safe_redirect_to_destination(wp_validate_redirect($checked, home_url()), $row->http_code);
430 445 }
431 - die;
446 + // Both branches terminate inside safe_redirect_to_destination().
432 447 }
433 448 // Match found and processed, return true to stop checking other rules
434 449 return true;
435 450 }
@@ -439,8 +454,49 @@
439 454 return false;
440 455 }
441 456
442 457 /**
458 + * Emit a redirect through wp_safe_redirect() and end execution.
459 + *
460 + * wp_safe_redirect() confines the Location header to hosts WordPress
461 + * trusts (this site plus anything on the allowed_redirect_hosts
462 + * allowlist). The one intentional external destination is an exact-match
463 + * rule saved by an admin while the metasync_allow_external_redirects
464 + * opt-in is enabled, so the parsed host of the already syntax-validated
465 + * destination is allowlisted for this request only. Everything else —
466 + * same-site destinations, relative paths — passes through untouched.
467 + *
468 + * @param string $destination Validated destination URL or path.
469 + * @param string|int $http_code Redirect status code from the rule row.
470 + * @return void Never returns; wp_safe_redirect() is followed by exit.
471 + */
472 + private function safe_redirect_to_destination($destination, $http_code)
473 + {
474 + $host = wp_parse_url($destination, PHP_URL_HOST);
475 + $grant_host = null;
476 + if (is_string($host) && $host !== '') {
477 + // Scope the temporary host grant to this one redirect. The filter
478 + // runs while wp_safe_redirect() validates the Location header and
479 + // is removed before exit: shutdown callbacks registered elsewhere
480 + // still fire after exit, so the grant must not outlive the call.
481 + $grant_host = static function ($allowed_hosts, $checked_host = '') use ($host) {
482 + if (is_array($allowed_hosts)) {
483 + $allowed_hosts[] = $host;
484 + }
485 + return $allowed_hosts;
486 + };
487 + add_filter('allowed_redirect_hosts', $grant_host, 10, 2);
488 + }
489 + wp_safe_redirect($destination, $http_code);
490 + if (null !== $grant_host) {
491 + // Removal matches on priority + callback identity; core's
492 + // remove_filter() takes no accepted-args parameter.
493 + remove_filter('allowed_redirect_hosts', $grant_host, 10);
494 + }
495 + exit;
496 + }
497 +
498 + /**
443 499 * Resolve a URL through the redirect table to its final destination (follows redirect chains).
444 500 * Used by OTTO and other backend processing so the final canonical URL is used before 404 checks.
445 501 *
446 502 * @param string $url Full URL (e.g. https://example.com/old-page)
@@ -451,9 +507,9 @@
451 507 {
452 508 if (empty($url) || !is_string($url)) {
453 509 return $url;
454 510 }
455 - $parsed = parse_url($url);
511 + $parsed = wp_parse_url($url);
456 512 $scheme = isset($parsed['scheme']) ? $parsed['scheme'] : 'https';
457 513 $host = isset($parsed['host']) ? $parsed['host'] : '';
458 514 $base = $scheme . '://' . $host;
459 515 // Track the query separately from the path: sources match on the path
@@ -475,9 +531,9 @@
475 531 }
476 532 $dest = $this->append_query_string($dest, $query);
477 533 if (strpos($dest, 'http') === 0) {
478 534 $url = $dest;
479 - $parsed = parse_url($url);
535 + $parsed = wp_parse_url($url);
480 536 $scheme = isset($parsed['scheme']) ? $parsed['scheme'] : 'https';
481 537 $host = isset($parsed['host']) ? $parsed['host'] : '';
482 538 $base = $scheme . '://' . $host;
483 539 $uri = isset($parsed['path']) ? $parsed['path'] : '/';
@@ -640,12 +696,12 @@
640 696 // Determine if the destination is external by checking for a host component
641 697 // that differs from the local site. Relative paths (e.g. /about) have no host
642 698 // and are always internal.
643 699 $is_external = false;
644 - $parsed_dest = parse_url($destination);
700 + $parsed_dest = wp_parse_url($destination);
645 701 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);
702 + $site_host = wp_parse_url(site_url(), PHP_URL_HOST);
703 + $is_external = false === $site_host || strcasecmp($parsed_dest['host'], (string) $site_host) !== 0;
648 704 }
649 705
650 706 if ($is_external) {
651 707 // Only probe external targets when off-site redirects are explicitly enabled,
@@ -690,9 +746,9 @@
690 746 if (!is_string($url) || $url === '') {
691 747 return '/';
692 748 }
693 749 if (strpos($url, 'http') === 0) {
694 - $parsed = parse_url($url);
750 + $parsed = wp_parse_url($url);
695 751 $path = isset($parsed['path']) ? $parsed['path'] : '/';
696 752 } else {
697 753 $path = $url;
698 754 }
@@ -984,8 +1040,13 @@
984 1040 if ((is_int($source_key) || ctype_digit((string) $source_key)) && is_string($source_value) && $source_value !== '') {
985 1041 $source_key = $source_value;
986 1042 }
987 1043
1044 + // Heal magic-quoted sources saved before the input-unslash fix so
1045 + // this health-check view matches with the same semantics as the
1046 + // live front-end matcher.
1047 + $source_key = wp_unslash((string) $source_key);
1048 +
988 1049 // Resolve pattern type: per-source value (exact, contain, start, end, wildcard, regex) or row-level default
989 1050 $pattern_type = in_array($source_value, array('exact', 'contain', 'start', 'end', 'wildcard', 'regex'))
990 1051 ? $source_value
991 1052 : ($global_pattern_type ? $global_pattern_type : 'exact');
@@ -994,9 +1055,9 @@
994 1055 $normalized_source = $source_key;
995 1056
996 1057 // If source is a full URL, use only the path for matching (consistent with front-end redirect behavior)
997 1058 if (strpos($source_key, 'http') === 0) {
998 - $parsed_src = parse_url($source_key);
1059 + $parsed_src = wp_parse_url($source_key);
999 1060 $normalized_source = isset($parsed_src['path']) ? $parsed_src['path'] : '';
1000 1061 }
1001 1062
1002 1063 // Ensure leading slash for reliable path comparison
@@ -1189,10 +1250,11 @@
1189 1250 if (is_admin()) {
1190 1251 return;
1191 1252 }
1192 1253
1193 - // Get current URI
1194 - $request_uri = isset($_SERVER['REQUEST_URI']) ? (string) $_SERVER['REQUEST_URI'] : '/';
1254 + // Get current URI. wp_magic_quotes() slashes $_SERVER too, so unslash
1255 + // here to compare against the literal (unslashed) stored sources.
1256 + $request_uri = isset($_SERVER['REQUEST_URI']) ? wp_unslash((string) $_SERVER['REQUEST_URI']) : '/';
1195 1257
1196 1258 // Keep the raw query for the redirect destination. It is deliberately
1197 1259 // excluded from matching below, but must survive the browser redirect.
1198 1260 $query_pos = strpos($request_uri, '?');
@@ -1378,9 +1440,9 @@
1378 1440 # return $test_result !== false;
1379 1441
1380 1442 // Use error handler to catch warnings from malformed patterns
1381 1443 $error_occurred = false;
1382 - set_error_handler(function() use (&$error_occurred) {
1444 + set_error_handler(function() use (&$error_occurred) { // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_set_error_handler -- genuine failure path, bounded, no secrets
1383 1445 $error_occurred = true;
1384 1446 return true; // Suppress the error
1385 1447 }, E_WARNING);
1386 1448