| @@ -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 | |