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