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 +304 -30 2.6.13 → 2.7.1 View file →
@@ -17,8 +17,12 @@
17 17 if (!defined('ABSPATH')) {
18 18 exit;
19 19 }
20 20
21 +if (!class_exists('Metasync_Redirection_Validator')) {
22 + require_once dirname(__FILE__) . '/class-metasync-redirection-validator.php';
23 +}
24 +
21 25 class Metasync_Redirection
22 26 {
23 27
24 28 private $db_redirection;
@@ -67,8 +71,19 @@
67 71 $global_pattern_type = isset($row->pattern_type) ? $row->pattern_type : null;
68 72 $is_pattern = false;
69 73
70 74 foreach ($source_urls as $source_key => $source_value) {
75 + // Legacy list-format rows store the URL as the VALUE under a
76 + // numeric key ('0' => '/old'); modern rows key by URL with the
77 + // pattern type as the value. Remap so the legacy source lands
78 + // in the exact index instead of '/0'.
79 + if ((is_int($source_key) || ctype_digit((string) $source_key)) && is_string($source_value) && $source_value !== '') {
80 + $source_key = $source_value;
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);
71 86 $pattern_type = in_array($source_value, array('exact', 'contain', 'start', 'end', 'wildcard', 'regex'))
72 87 ? $source_value
73 88 : ($global_pattern_type ? $global_pattern_type : 'exact');
74 89
@@ -75,9 +90,9 @@
75 90 if ($pattern_type === 'exact' && strpos((string) $source_key, '*') === false) {
76 91 // Normalize: extract path from full URLs, ensure leading slash, strip trailing slash
77 92 $norm = (string) $source_key;
78 93 if (strpos($norm, 'http') === 0) {
79 - $parsed = parse_url($norm);
94 + $parsed = wp_parse_url($norm);
80 95 $norm = isset($parsed['path']) ? $parsed['path'] : '/';
81 96 }
82 97 if ($norm === '' || $norm[0] !== '/') {
83 98 $norm = '/' . $norm;
@@ -103,9 +118,9 @@
103 118
104 119 public function create_admin_redirection_interface()
105 120 {
106 121 # Check if we should show import interface
107 - $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
108 123 if (isset($request_data['action']) && $request_data['action'] === 'import') {
109 124 $this->show_import_interface();
110 125 return;
111 126 }
@@ -147,9 +162,9 @@
147 162 wp_send_json_error(['message' => 'Insufficient permissions.']);
148 163 return;
149 164 }
150 165
151 - $plugin = isset($_POST['plugin']) ? sanitize_text_field($_POST['plugin']) : '';
166 + $plugin = isset($_POST['plugin']) ? sanitize_text_field(wp_unslash($_POST['plugin'])) : '';
152 167
153 168 if (empty($plugin)) {
154 169 wp_send_json_error(['message' => 'No plugin specified.']);
155 170 return;
@@ -154,8 +169,15 @@
154 169 wp_send_json_error(['message' => 'No plugin specified.']);
155 170 return;
156 171 }
157 172
173 + # CSV is a file upload, not a plugin, so it takes its own path with
174 + # upload validation before the importer sees the temp file.
175 + if ($plugin === 'csv') {
176 + $this->handle_csv_import_ajax();
177 + return;
178 + }
179 +
158 180 try {
159 181 # Perform import
160 182 $result = $this->importer->import_from_plugin($plugin);
161 183
@@ -172,8 +194,44 @@
172 194 ]);
173 195 }
174 196 }
175 197
198 + /**
199 + * Handle the AJAX CSV import: validate the upload, then hand the temp
200 + * file to the importer. The readme has advertised CSV import since 2.5.x;
201 + * this is the first implementation of it.
202 + */
203 + private function handle_csv_import_ajax()
204 + {
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
206 +
207 + // wp_send_json_error() ends the request, so no return is needed (or
208 + // reachable) after any of these guards.
209 + if (empty($file['tmp_name']) || (isset($file['error']) && (int) $file['error'] !== UPLOAD_ERR_OK)) {
210 + wp_send_json_error(['message' => 'Upload failed. Please choose a .csv file and try again.']);
211 + }
212 +
213 + if (!is_uploaded_file($file['tmp_name'])) {
214 + wp_send_json_error(['message' => 'Invalid upload.']);
215 + }
216 +
217 + if (!preg_match('/\.csv$/i', (string) $file['name'])) {
218 + wp_send_json_error(['message' => 'Only .csv files are supported.']);
219 + }
220 +
221 + if ((int) $file['size'] > 2 * MB_IN_BYTES) {
222 + wp_send_json_error(['message' => 'The CSV file is too large. Maximum size is 2 MB.']);
223 + }
224 +
225 + $result = $this->importer->import_csv_file($file['tmp_name']);
226 +
227 + if (!empty($result['success'])) {
228 + wp_send_json_success($result);
229 + } else {
230 + wp_send_json_error($result);
231 + }
232 + }
233 +
176 234 public function get_current_page_url()
177 235 {
178 236 $server_data = metasync_sanitize_input_array($_SERVER);
179 237 $link = '://' . $server_data['HTTP_HOST'] . $server_data['REQUEST_URI'];
@@ -180,10 +238,20 @@
180 238 $link = (is_ssl() ? 'https' : 'http') . $link;
181 239 return sanitize_url($link);
182 240 }
183 241
184 - public function source_url_redirection(object $row, string $uri)
242 + public function source_url_redirection(object $row, string $uri, string $incoming_query = '')
185 243 {
244 + // Redirection feature switched off: rows owned by the per-post Redirection
245 + // meta box must not fire. Returning false rather than aborting means the
246 + // caller carries on evaluating the remaining rules, so rules added by hand
247 + // on the Redirections screen keep working exactly as before.
248 + if (Metasync_Feature_Flags::is_disabled(Metasync_Feature_Flags::REDIRECTION)
249 + && class_exists('Metasync_Post_Meta_Settings')
250 + && Metasync_Post_Meta_Settings::owns_redirect_row($row)) {
251 + return false;
252 + }
253 +
186 254 // Optimize: unserialize only once
187 255 $sources_from = unserialize($row->sources_from, ['allowed_classes' => false]); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.serialize_unserialize
188 256 $source_urls = is_array($sources_from) ? $sources_from : [];
189 257 $global_pattern_type = isset($row->pattern_type) ? $row->pattern_type : null;
@@ -195,8 +263,18 @@
195 263
196 264 // Ensure source_key is always a string (handles numeric array indexes)
197 265 $source_key = (string) $source_key;
198 266
267 + // Legacy list-format rows store the URL as the VALUE under a
268 + // numeric key ('0' => '/old'); remap so matching uses the URL.
269 + if (ctype_digit($source_key) && is_string($source_value) && $source_value !== '') {
270 + $source_key = $source_value;
271 + }
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 +
199 277 // Determine pattern type: use source value if it's a valid pattern, otherwise use global pattern_type
200 278 $pattern_type = in_array($source_value, ['exact', 'contain', 'start', 'end', 'wildcard', 'regex'])
201 279 ? $source_value
202 280 : ($global_pattern_type ? $global_pattern_type : 'exact');
@@ -206,9 +284,9 @@
206 284 $normalized_source = $source_key;
207 285
208 286 // If source is a full URL, extract just the path part
209 287 if (strpos($source_key, 'http') === 0) {
210 - $parsed_url = parse_url($source_key);
288 + $parsed_url = wp_parse_url($source_key);
211 289 $normalized_source = $parsed_url['path'] ?? '';
212 290 }
213 291
214 292 # Ensure both have leading slashes for proper comparison
@@ -337,8 +415,9 @@
337 415 }
338 416 if ($row->url_redirect_to) {
339 417 // Replace wildcards or $1 placeholders in destination URL
340 418 $destination = $this->process_destination_url($row->url_redirect_to, $captured_path);
419 + $destination = $this->append_query_string($destination, $incoming_query);
341 420 $is_exact = isset($row->pattern_type) && $row->pattern_type === 'exact';
342 421 if (get_option('metasync_allow_external_redirects', 0) && $is_exact) {
343 422 $destination = esc_url_raw($destination);
344 423 if (empty($destination)) {
@@ -343,13 +422,29 @@
343 422 $destination = esc_url_raw($destination);
344 423 if (empty($destination)) {
345 424 $destination = home_url();
346 425 }
347 - 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);
348 434 } else {
349 - wp_redirect(wp_validate_redirect($destination, home_url()), $row->http_code);
435 + // Stored destinations can carry backslashes (imports,
436 + // older rows). Browsers read them as path separators, so
437 + // '/\evil.com' would slip past wp_validate_redirect and
438 + // still navigate off-site. Normalize first, and bounce
439 + // anything still syntactically evasive to the home page.
440 + $checked = Metasync_Redirection_Validator::normalize_destination($destination);
441 + if (!Metasync_Redirection_Validator::is_safe_destination_syntax($checked)) {
442 + $checked = home_url();
443 + }
444 + $this->safe_redirect_to_destination(wp_validate_redirect($checked, home_url()), $row->http_code);
350 445 }
351 - die;
446 + // Both branches terminate inside safe_redirect_to_destination().
352 447 }
353 448 // Match found and processed, return true to stop checking other rules
354 449 return true;
355 450 }
@@ -359,8 +454,49 @@
359 454 return false;
360 455 }
361 456
362 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 + /**
363 499 * Resolve a URL through the redirect table to its final destination (follows redirect chains).
364 500 * Used by OTTO and other backend processing so the final canonical URL is used before 404 checks.
365 501 *
366 502 * @param string $url Full URL (e.g. https://example.com/old-page)
@@ -371,19 +507,21 @@
371 507 {
372 508 if (empty($url) || !is_string($url)) {
373 509 return $url;
374 510 }
375 - $parsed = parse_url($url);
511 + $parsed = wp_parse_url($url);
376 512 $scheme = isset($parsed['scheme']) ? $parsed['scheme'] : 'https';
377 513 $host = isset($parsed['host']) ? $parsed['host'] : '';
378 514 $base = $scheme . '://' . $host;
515 + // Track the query separately from the path: sources match on the path
516 + // only, and each destination may carry its own query. Folding the
517 + // query into $uri (as this used to) made '/old?x=1' miss an '/old'
518 + // rule and swallowed destination queries along the chain.
379 519 $uri = isset($parsed['path']) ? $parsed['path'] : '/';
380 - if (!empty($parsed['query'])) {
381 - $uri .= '?' . $parsed['query'];
382 - }
520 + $query = isset($parsed['query']) ? $parsed['query'] : '';
383 521 $seen = array();
384 522 for ($i = 0; $i < $max_hops; $i++) {
385 - $uri_key = $uri;
523 + $uri_key = $query === '' ? $uri : $uri . '?' . $query;
386 524 if (isset($seen[$uri_key])) {
387 525 break; // cycle detected
388 526 }
389 527 $seen[$uri_key] = true;
@@ -390,21 +528,22 @@
390 528 $dest = $this->get_redirect_destination_for_uri($uri);
391 529 if ($dest === null) {
392 530 break;
393 531 }
532 + $dest = $this->append_query_string($dest, $query);
394 533 if (strpos($dest, 'http') === 0) {
395 534 $url = $dest;
396 - $parsed = parse_url($url);
535 + $parsed = wp_parse_url($url);
397 536 $scheme = isset($parsed['scheme']) ? $parsed['scheme'] : 'https';
398 537 $host = isset($parsed['host']) ? $parsed['host'] : '';
399 538 $base = $scheme . '://' . $host;
400 539 $uri = isset($parsed['path']) ? $parsed['path'] : '/';
401 - if (!empty($parsed['query'])) {
402 - $uri .= '?' . $parsed['query'];
403 - }
540 + $query = isset($parsed['query']) ? $parsed['query'] : '';
404 541 } else {
405 - $uri = (isset($dest[0]) && $dest[0] === '/') ? $dest : '/' . $dest;
406 - $url = $base . $uri;
542 + $dest_parts = explode('?', $dest, 2);
543 + $uri = (isset($dest_parts[0][0]) && $dest_parts[0][0] === '/') ? $dest_parts[0] : '/' . $dest_parts[0];
544 + $query = isset($dest_parts[1]) ? $dest_parts[1] : '';
545 + $url = $base . $uri . ($query === '' ? '' : '?' . $query);
407 546 }
408 547 }
409 548 return $url;
410 549 }
@@ -418,8 +557,15 @@
418 557 * @return string|null Destination URL or path, or null if no match
419 558 */
420 559 private function get_redirect_destination_for_uri($uri)
421 560 {
561 + // Defensive: callers may pass a path?query form; sources match on the
562 + // path only, exactly like the live template-redirect path.
563 + $qpos = strpos((string) $uri, '?');
564 + if ($qpos !== false) {
565 + $uri = substr($uri, 0, $qpos);
566 + }
567 +
422 568 $redirections = $this->db_redirection->getAllActiveRecords();
423 569 if (empty($redirections)) {
424 570 return null;
425 571 }
@@ -550,12 +696,12 @@
550 696 // Determine if the destination is external by checking for a host component
551 697 // that differs from the local site. Relative paths (e.g. /about) have no host
552 698 // and are always internal.
553 699 $is_external = false;
554 - $parsed_dest = parse_url($destination);
700 + $parsed_dest = wp_parse_url($destination);
555 701 if (!empty($parsed_dest['host'])) {
556 - $site_host = parse_url(site_url(), PHP_URL_HOST);
557 - $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;
558 704 }
559 705
560 706 if ($is_external) {
561 707 // Only probe external targets when off-site redirects are explicitly enabled,
@@ -600,9 +746,9 @@
600 746 if (!is_string($url) || $url === '') {
601 747 return '/';
602 748 }
603 749 if (strpos($url, 'http') === 0) {
604 - $parsed = parse_url($url);
750 + $parsed = wp_parse_url($url);
605 751 $path = isset($parsed['path']) ? $parsed['path'] : '/';
606 752 } else {
607 753 $path = $url;
608 754 }
@@ -644,8 +790,14 @@
644 790 $source_urls = is_array($sources_from) ? $sources_from : array();
645 791 $global_pattern_type = isset($row->pattern_type) ? $row->pattern_type : null;
646 792
647 793 foreach ($source_urls as $source_key => $source_value) {
794 + // Legacy list-format rows store the URL as the VALUE under a
795 + // numeric key ('0' => '/old'); remap so health checks match
796 + // the URL rather than a bogus '/0'.
797 + if ((is_int($source_key) || ctype_digit((string) $source_key)) && is_string($source_value) && $source_value !== '') {
798 + $source_key = $source_value;
799 + }
648 800 $pattern_type = in_array($source_value, array('exact', 'contain', 'start', 'end', 'wildcard', 'regex'))
649 801 ? $source_value
650 802 : ($global_pattern_type ? $global_pattern_type : 'exact');
651 803
@@ -758,8 +910,14 @@
758 910 if ($post_id === 0) {
759 911 $post_id = url_to_postid(site_url($current . '/'));
760 912 }
761 913 $is_dead = ($post_id === 0);
914 + // url_to_postid() knows only posts and pages — a taxonomy
915 + // archive (category/tag/custom term URL) returns 0 and a
916 + // healthy destination was flagged dead. Resolve those too.
917 + if ($is_dead && $this->uri_resolves_to_term_archive($current)) {
918 + $is_dead = false;
919 + }
762 920 }
763 921 $status = $is_dead ? 'dead_end' : 'ok';
764 922 }
765 923
@@ -777,8 +935,68 @@
777 935 return $results;
778 936 }
779 937
780 938 /**
939 + * Whether a URI path resolves to a taxonomy archive (category, tag, or a
940 + * custom taxonomy term). url_to_postid() returns 0 for these, so without
941 + * this check the health check reports them as dead ends.
942 + *
943 + * @param string $path URI path, e.g. /category/news/.
944 + * @return bool True when the path maps to an existing term archive.
945 + */
946 + private function uri_resolves_to_term_archive($path)
947 + {
948 + if (!function_exists('get_term_by') || !function_exists('get_taxonomies')) {
949 + return false;
950 + }
951 +
952 + $path = trim((string) $path, '/');
953 + if ($path === '') {
954 + return false;
955 + }
956 + $segments = explode('/', $path);
957 + $first = array_shift($segments);
958 + if ($first === '' || empty($segments)) {
959 + return false;
960 + }
961 +
962 + // Built-in taxonomies honor the category_base/tag_base options; the
963 + // defaults are 'category' and 'tag'.
964 + $category_base = trim((string) get_option('category_base'), '/');
965 + $tag_base = trim((string) get_option('tag_base'), '/');
966 + $bases = array(
967 + $category_base !== '' ? $category_base : 'category' => 'category',
968 + $tag_base !== '' ? $tag_base : 'tag' => 'post_tag',
969 + );
970 + $taxonomy = isset($bases[$first]) ? $bases[$first] : null;
971 +
972 + // Custom taxonomies carry their own rewrite base (defaults to the
973 + // taxonomy name).
974 + if ($taxonomy === null) {
975 + foreach (get_taxonomies(array('publicly_queryable' => true, '_builtin' => false), 'objects') as $tax_object) {
976 + $base = isset($tax_object->rewrite['slug']) ? trim((string) $tax_object->rewrite['slug'], '/') : $tax_object->name;
977 + if ($base === $first) {
978 + $taxonomy = $tax_object->name;
979 + break;
980 + }
981 + }
982 + }
983 + if ($taxonomy === null) {
984 + return false;
985 + }
986 +
987 + // get_term_by() returns WP_Term|false for a registered taxonomy — never
988 + // a WP_Error — so a false check is the whole story here.
989 + $term = get_term_by('slug', implode('/', $segments), $taxonomy);
990 + if ($term === false) {
991 + // Hierarchical terms live at parent/child paths, and the child's
992 + // own slug is only the last segment.
993 + $term = get_term_by('slug', end($segments), $taxonomy);
994 + }
995 + return ($term !== false);
996 + }
997 +
998 + /**
781 999 * Handle AJAX health check request from admin UI.
782 1000 */
783 1001 public function handle_health_check_ajax()
784 1002 {
@@ -816,8 +1034,19 @@
816 1034 foreach ($source_urls as $source_key => $source_value) {
817 1035 $match_found = false;
818 1036 $captured_path = ''; // Used for wildcard/regex replacement in destination (e.g. * or $1)
819 1037
1038 + // Legacy list-format rows store the URL as the VALUE under a
1039 + // numeric key ('0' => '/old'); remap so matching uses the URL.
1040 + if ((is_int($source_key) || ctype_digit((string) $source_key)) && is_string($source_value) && $source_value !== '') {
1041 + $source_key = $source_value;
1042 + }
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 +
820 1049 // Resolve pattern type: per-source value (exact, contain, start, end, wildcard, regex) or row-level default
821 1050 $pattern_type = in_array($source_value, array('exact', 'contain', 'start', 'end', 'wildcard', 'regex'))
822 1051 ? $source_value
823 1052 : ($global_pattern_type ? $global_pattern_type : 'exact');
@@ -826,9 +1055,9 @@
826 1055 $normalized_source = $source_key;
827 1056
828 1057 // If source is a full URL, use only the path for matching (consistent with front-end redirect behavior)
829 1058 if (strpos($source_key, 'http') === 0) {
830 - $parsed_src = parse_url($source_key);
1059 + $parsed_src = wp_parse_url($source_key);
831 1060 $normalized_source = isset($parsed_src['path']) ? $parsed_src['path'] : '';
832 1061 }
833 1062
834 1063 // Ensure leading slash for reliable path comparison
@@ -981,8 +1210,39 @@
981 1210 return $destination;
982 1211 }
983 1212
984 1213 /**
1214 + * Preserve query parameters from the incoming request when redirecting.
1215 + *
1216 + * Redirect matching intentionally ignores the query string, but the
1217 + * browser-visible redirect must not silently discard it. Keep the stored
1218 + * destination query intact and append the incoming query verbatim so
1219 + * encoded values and repeated parameters survive unchanged.
1220 + *
1221 + * @param string $destination Destination URL or path.
1222 + * @param string $query Incoming query without the leading '?'.
1223 + * @return string Destination with the incoming query appended.
1224 + */
1225 + private function append_query_string($destination, $query)
1226 + {
1227 + $destination = (string) $destination;
1228 + $query = ltrim((string) $query, '?');
1229 + if ($query === '') {
1230 + return $destination;
1231 + }
1232 +
1233 + $fragment = '';
1234 + $fragment_pos = strpos($destination, '#');
1235 + if ($fragment_pos !== false) {
1236 + $fragment = substr($destination, $fragment_pos);
1237 + $destination = substr($destination, 0, $fragment_pos);
1238 + }
1239 +
1240 + $separator = strpos($destination, '?') === false ? '?' : '&';
1241 + return $destination . $separator . $query . $fragment;
1242 + }
1243 +
1244 + /**
985 1245 * Handle template redirect for frontend redirections
986 1246 */
987 1247 public function handle_template_redirect()
988 1248 {
@@ -990,14 +1250,28 @@
990 1250 if (is_admin()) {
991 1251 return;
992 1252 }
993 1253
994 - // Get current URI
995 - $uri = $_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']) : '/';
996 1257
1258 + // Keep the raw query for the redirect destination. It is deliberately
1259 + // excluded from matching below, but must survive the browser redirect.
1260 + $query_pos = strpos($request_uri, '?');
1261 + $incoming_query = $query_pos === false ? '' : substr($request_uri, $query_pos + 1);
1262 +
997 1263 // Remove query string for matching
998 - $uri = strtok($uri, '?');
1264 + $uri = $query_pos === false ? $request_uri : substr($request_uri, 0, $query_pos);
999 1265
1266 + // REQUEST_URI arrives percent-encoded while stored sources are raw
1267 + // UTF-8, so a stored '/日本' could never match '/%E6%97%A5%E6%9C%AC'.
1268 + // Decode before matching; the stored destination still drives output.
1269 + $decoded_uri = rawurldecode((string) $uri);
1270 + if ($decoded_uri !== '') {
1271 + $uri = $decoded_uri;
1272 + }
1273 +
1000 1274 // Build the lookup index (lazy, once per request)
1001 1275 $this->ensure_redirect_index();
1002 1276
1003 1277 // O(1) exact-match lookup first
@@ -1007,9 +1281,9 @@
1007 1281 }
1008 1282 $normalized_uri = rtrim($normalized_uri, '/') ?: '/';
1009 1283
1010 1284 if (isset($this->exact_index[$normalized_uri])) {
1011 - if ($this->source_url_redirection($this->exact_index[$normalized_uri], $uri)) {
1285 + if ($this->source_url_redirection($this->exact_index[$normalized_uri], $uri, $incoming_query)) {
1012 1286 return;
1013 1287 }
1014 1288 }
1015 1289
@@ -1014,9 +1288,9 @@
1014 1288 }
1015 1289
1016 1290 // Fallback: scan only pattern-based rows (wildcard, regex, contain, start, end)
1017 1291 foreach ($this->pattern_index as $redirection) {
1018 - if ($this->source_url_redirection($redirection, $uri)) {
1292 + if ($this->source_url_redirection($redirection, $uri, $incoming_query)) {
1019 1293 return;
1020 1294 }
1021 1295 }
1022 1296 }
@@ -1166,9 +1440,9 @@
1166 1440 # return $test_result !== false;
1167 1441
1168 1442 // Use error handler to catch warnings from malformed patterns
1169 1443 $error_occurred = false;
1170 - 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
1171 1445 $error_occurred = true;
1172 1446 return true; // Suppress the error
1173 1447 }, E_WARNING);
1174 1448