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 +308 -30 2.6.23 → 2.7.2 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,12 +169,21 @@
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
184 + Metasync_PostHog::import_batch($plugin, 'redirections', $result);
185 +
162 186 if ($result['success']) {
163 187 wp_send_json_success($result);
164 188 } else {
165 189 wp_send_json_error($result);
@@ -172,8 +196,46 @@
172 196 ]);
173 197 }
174 198 }
175 199
200 + /**
201 + * Handle the AJAX CSV import: validate the upload, then hand the temp
202 + * file to the importer. The readme has advertised CSV import since 2.5.x;
203 + * this is the first implementation of it.
204 + */
205 + private function handle_csv_import_ajax()
206 + {
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
208 +
209 + // wp_send_json_error() ends the request, so no return is needed (or
210 + // reachable) after any of these guards.
211 + if (empty($file['tmp_name']) || (isset($file['error']) && (int) $file['error'] !== UPLOAD_ERR_OK)) {
212 + wp_send_json_error(['message' => 'Upload failed. Please choose a .csv file and try again.']);
213 + }
214 +
215 + if (!is_uploaded_file($file['tmp_name'])) {
216 + wp_send_json_error(['message' => 'Invalid upload.']);
217 + }
218 +
219 + if (!preg_match('/\.csv$/i', (string) $file['name'])) {
220 + wp_send_json_error(['message' => 'Only .csv files are supported.']);
221 + }
222 +
223 + if ((int) $file['size'] > 2 * MB_IN_BYTES) {
224 + wp_send_json_error(['message' => 'The CSV file is too large. Maximum size is 2 MB.']);
225 + }
226 +
227 + $result = $this->importer->import_csv_file($file['tmp_name']);
228 +
229 + Metasync_PostHog::import_batch('csv', 'redirections', $result);
230 +
231 + if (!empty($result['success'])) {
232 + wp_send_json_success($result);
233 + } else {
234 + wp_send_json_error($result);
235 + }
236 + }
237 +
176 238 public function get_current_page_url()
177 239 {
178 240 $server_data = metasync_sanitize_input_array($_SERVER);
179 241 $link = '://' . $server_data['HTTP_HOST'] . $server_data['REQUEST_URI'];
@@ -180,10 +242,20 @@
180 242 $link = (is_ssl() ? 'https' : 'http') . $link;
181 243 return sanitize_url($link);
182 244 }
183 245
184 - public function source_url_redirection(object $row, string $uri)
246 + public function source_url_redirection(object $row, string $uri, string $incoming_query = '')
185 247 {
248 + // Redirection feature switched off: rows owned by the per-post Redirection
249 + // meta box must not fire. Returning false rather than aborting means the
250 + // caller carries on evaluating the remaining rules, so rules added by hand
251 + // on the Redirections screen keep working exactly as before.
252 + if (Metasync_Feature_Flags::is_disabled(Metasync_Feature_Flags::REDIRECTION)
253 + && class_exists('Metasync_Post_Meta_Settings')
254 + && Metasync_Post_Meta_Settings::owns_redirect_row($row)) {
255 + return false;
256 + }
257 +
186 258 // Optimize: unserialize only once
187 259 $sources_from = unserialize($row->sources_from, ['allowed_classes' => false]); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.serialize_unserialize
188 260 $source_urls = is_array($sources_from) ? $sources_from : [];
189 261 $global_pattern_type = isset($row->pattern_type) ? $row->pattern_type : null;
@@ -195,8 +267,18 @@
195 267
196 268 // Ensure source_key is always a string (handles numeric array indexes)
197 269 $source_key = (string) $source_key;
198 270
271 + // Legacy list-format rows store the URL as the VALUE under a
272 + // numeric key ('0' => '/old'); remap so matching uses the URL.
273 + if (ctype_digit($source_key) && is_string($source_value) && $source_value !== '') {
274 + $source_key = $source_value;
275 + }
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 +
199 281 // Determine pattern type: use source value if it's a valid pattern, otherwise use global pattern_type
200 282 $pattern_type = in_array($source_value, ['exact', 'contain', 'start', 'end', 'wildcard', 'regex'])
201 283 ? $source_value
202 284 : ($global_pattern_type ? $global_pattern_type : 'exact');
@@ -206,9 +288,9 @@
206 288 $normalized_source = $source_key;
207 289
208 290 // If source is a full URL, extract just the path part
209 291 if (strpos($source_key, 'http') === 0) {
210 - $parsed_url = parse_url($source_key);
292 + $parsed_url = wp_parse_url($source_key);
211 293 $normalized_source = $parsed_url['path'] ?? '';
212 294 }
213 295
214 296 # Ensure both have leading slashes for proper comparison
@@ -337,8 +419,9 @@
337 419 }
338 420 if ($row->url_redirect_to) {
339 421 // Replace wildcards or $1 placeholders in destination URL
340 422 $destination = $this->process_destination_url($row->url_redirect_to, $captured_path);
423 + $destination = $this->append_query_string($destination, $incoming_query);
341 424 $is_exact = isset($row->pattern_type) && $row->pattern_type === 'exact';
342 425 if (get_option('metasync_allow_external_redirects', 0) && $is_exact) {
343 426 $destination = esc_url_raw($destination);
344 427 if (empty($destination)) {
@@ -343,13 +426,29 @@
343 426 $destination = esc_url_raw($destination);
344 427 if (empty($destination)) {
345 428 $destination = home_url();
346 429 }
347 - 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);
348 438 } else {
349 - wp_redirect(wp_validate_redirect($destination, home_url()), $row->http_code);
439 + // Stored destinations can carry backslashes (imports,
440 + // older rows). Browsers read them as path separators, so
441 + // '/\evil.com' would slip past wp_validate_redirect and
442 + // still navigate off-site. Normalize first, and bounce
443 + // anything still syntactically evasive to the home page.
444 + $checked = Metasync_Redirection_Validator::normalize_destination($destination);
445 + if (!Metasync_Redirection_Validator::is_safe_destination_syntax($checked)) {
446 + $checked = home_url();
447 + }
448 + $this->safe_redirect_to_destination(wp_validate_redirect($checked, home_url()), $row->http_code);
350 449 }
351 - die;
450 + // Both branches terminate inside safe_redirect_to_destination().
352 451 }
353 452 // Match found and processed, return true to stop checking other rules
354 453 return true;
355 454 }
@@ -359,8 +458,49 @@
359 458 return false;
360 459 }
361 460
362 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 + /**
363 503 * Resolve a URL through the redirect table to its final destination (follows redirect chains).
364 504 * Used by OTTO and other backend processing so the final canonical URL is used before 404 checks.
365 505 *
366 506 * @param string $url Full URL (e.g. https://example.com/old-page)
@@ -371,19 +511,21 @@
371 511 {
372 512 if (empty($url) || !is_string($url)) {
373 513 return $url;
374 514 }
375 - $parsed = parse_url($url);
515 + $parsed = wp_parse_url($url);
376 516 $scheme = isset($parsed['scheme']) ? $parsed['scheme'] : 'https';
377 517 $host = isset($parsed['host']) ? $parsed['host'] : '';
378 518 $base = $scheme . '://' . $host;
519 + // Track the query separately from the path: sources match on the path
520 + // only, and each destination may carry its own query. Folding the
521 + // query into $uri (as this used to) made '/old?x=1' miss an '/old'
522 + // rule and swallowed destination queries along the chain.
379 523 $uri = isset($parsed['path']) ? $parsed['path'] : '/';
380 - if (!empty($parsed['query'])) {
381 - $uri .= '?' . $parsed['query'];
382 - }
524 + $query = isset($parsed['query']) ? $parsed['query'] : '';
383 525 $seen = array();
384 526 for ($i = 0; $i < $max_hops; $i++) {
385 - $uri_key = $uri;
527 + $uri_key = $query === '' ? $uri : $uri . '?' . $query;
386 528 if (isset($seen[$uri_key])) {
387 529 break; // cycle detected
388 530 }
389 531 $seen[$uri_key] = true;
@@ -390,21 +532,22 @@
390 532 $dest = $this->get_redirect_destination_for_uri($uri);
391 533 if ($dest === null) {
392 534 break;
393 535 }
536 + $dest = $this->append_query_string($dest, $query);
394 537 if (strpos($dest, 'http') === 0) {
395 538 $url = $dest;
396 - $parsed = parse_url($url);
539 + $parsed = wp_parse_url($url);
397 540 $scheme = isset($parsed['scheme']) ? $parsed['scheme'] : 'https';
398 541 $host = isset($parsed['host']) ? $parsed['host'] : '';
399 542 $base = $scheme . '://' . $host;
400 543 $uri = isset($parsed['path']) ? $parsed['path'] : '/';
401 - if (!empty($parsed['query'])) {
402 - $uri .= '?' . $parsed['query'];
403 - }
544 + $query = isset($parsed['query']) ? $parsed['query'] : '';
404 545 } else {
405 - $uri = (isset($dest[0]) && $dest[0] === '/') ? $dest : '/' . $dest;
406 - $url = $base . $uri;
546 + $dest_parts = explode('?', $dest, 2);
547 + $uri = (isset($dest_parts[0][0]) && $dest_parts[0][0] === '/') ? $dest_parts[0] : '/' . $dest_parts[0];
548 + $query = isset($dest_parts[1]) ? $dest_parts[1] : '';
549 + $url = $base . $uri . ($query === '' ? '' : '?' . $query);
407 550 }
408 551 }
409 552 return $url;
410 553 }
@@ -418,8 +561,15 @@
418 561 * @return string|null Destination URL or path, or null if no match
419 562 */
420 563 private function get_redirect_destination_for_uri($uri)
421 564 {
565 + // Defensive: callers may pass a path?query form; sources match on the
566 + // path only, exactly like the live template-redirect path.
567 + $qpos = strpos((string) $uri, '?');
568 + if ($qpos !== false) {
569 + $uri = substr($uri, 0, $qpos);
570 + }
571 +
422 572 $redirections = $this->db_redirection->getAllActiveRecords();
423 573 if (empty($redirections)) {
424 574 return null;
425 575 }
@@ -550,12 +700,12 @@
550 700 // Determine if the destination is external by checking for a host component
551 701 // that differs from the local site. Relative paths (e.g. /about) have no host
552 702 // and are always internal.
553 703 $is_external = false;
554 - $parsed_dest = parse_url($destination);
704 + $parsed_dest = wp_parse_url($destination);
555 705 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);
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;
558 708 }
559 709
560 710 if ($is_external) {
561 711 // Only probe external targets when off-site redirects are explicitly enabled,
@@ -600,9 +750,9 @@
600 750 if (!is_string($url) || $url === '') {
601 751 return '/';
602 752 }
603 753 if (strpos($url, 'http') === 0) {
604 - $parsed = parse_url($url);
754 + $parsed = wp_parse_url($url);
605 755 $path = isset($parsed['path']) ? $parsed['path'] : '/';
606 756 } else {
607 757 $path = $url;
608 758 }
@@ -644,8 +794,14 @@
644 794 $source_urls = is_array($sources_from) ? $sources_from : array();
645 795 $global_pattern_type = isset($row->pattern_type) ? $row->pattern_type : null;
646 796
647 797 foreach ($source_urls as $source_key => $source_value) {
798 + // Legacy list-format rows store the URL as the VALUE under a
799 + // numeric key ('0' => '/old'); remap so health checks match
800 + // the URL rather than a bogus '/0'.
801 + if ((is_int($source_key) || ctype_digit((string) $source_key)) && is_string($source_value) && $source_value !== '') {
802 + $source_key = $source_value;
803 + }
648 804 $pattern_type = in_array($source_value, array('exact', 'contain', 'start', 'end', 'wildcard', 'regex'))
649 805 ? $source_value
650 806 : ($global_pattern_type ? $global_pattern_type : 'exact');
651 807
@@ -758,8 +914,14 @@
758 914 if ($post_id === 0) {
759 915 $post_id = url_to_postid(site_url($current . '/'));
760 916 }
761 917 $is_dead = ($post_id === 0);
918 + // url_to_postid() knows only posts and pages — a taxonomy
919 + // archive (category/tag/custom term URL) returns 0 and a
920 + // healthy destination was flagged dead. Resolve those too.
921 + if ($is_dead && $this->uri_resolves_to_term_archive($current)) {
922 + $is_dead = false;
923 + }
762 924 }
763 925 $status = $is_dead ? 'dead_end' : 'ok';
764 926 }
765 927
@@ -777,8 +939,68 @@
777 939 return $results;
778 940 }
779 941
780 942 /**
943 + * Whether a URI path resolves to a taxonomy archive (category, tag, or a
944 + * custom taxonomy term). url_to_postid() returns 0 for these, so without
945 + * this check the health check reports them as dead ends.
946 + *
947 + * @param string $path URI path, e.g. /category/news/.
948 + * @return bool True when the path maps to an existing term archive.
949 + */
950 + private function uri_resolves_to_term_archive($path)
951 + {
952 + if (!function_exists('get_term_by') || !function_exists('get_taxonomies')) {
953 + return false;
954 + }
955 +
956 + $path = trim((string) $path, '/');
957 + if ($path === '') {
958 + return false;
959 + }
960 + $segments = explode('/', $path);
961 + $first = array_shift($segments);
962 + if ($first === '' || empty($segments)) {
963 + return false;
964 + }
965 +
966 + // Built-in taxonomies honor the category_base/tag_base options; the
967 + // defaults are 'category' and 'tag'.
968 + $category_base = trim((string) get_option('category_base'), '/');
969 + $tag_base = trim((string) get_option('tag_base'), '/');
970 + $bases = array(
971 + $category_base !== '' ? $category_base : 'category' => 'category',
972 + $tag_base !== '' ? $tag_base : 'tag' => 'post_tag',
973 + );
974 + $taxonomy = isset($bases[$first]) ? $bases[$first] : null;
975 +
976 + // Custom taxonomies carry their own rewrite base (defaults to the
977 + // taxonomy name).
978 + if ($taxonomy === null) {
979 + foreach (get_taxonomies(array('publicly_queryable' => true, '_builtin' => false), 'objects') as $tax_object) {
980 + $base = isset($tax_object->rewrite['slug']) ? trim((string) $tax_object->rewrite['slug'], '/') : $tax_object->name;
981 + if ($base === $first) {
982 + $taxonomy = $tax_object->name;
983 + break;
984 + }
985 + }
986 + }
987 + if ($taxonomy === null) {
988 + return false;
989 + }
990 +
991 + // get_term_by() returns WP_Term|false for a registered taxonomy — never
992 + // a WP_Error — so a false check is the whole story here.
993 + $term = get_term_by('slug', implode('/', $segments), $taxonomy);
994 + if ($term === false) {
995 + // Hierarchical terms live at parent/child paths, and the child's
996 + // own slug is only the last segment.
997 + $term = get_term_by('slug', end($segments), $taxonomy);
998 + }
999 + return ($term !== false);
1000 + }
1001 +
1002 + /**
781 1003 * Handle AJAX health check request from admin UI.
782 1004 */
783 1005 public function handle_health_check_ajax()
784 1006 {
@@ -816,8 +1038,19 @@
816 1038 foreach ($source_urls as $source_key => $source_value) {
817 1039 $match_found = false;
818 1040 $captured_path = ''; // Used for wildcard/regex replacement in destination (e.g. * or $1)
819 1041
1042 + // Legacy list-format rows store the URL as the VALUE under a
1043 + // numeric key ('0' => '/old'); remap so matching uses the URL.
1044 + if ((is_int($source_key) || ctype_digit((string) $source_key)) && is_string($source_value) && $source_value !== '') {
1045 + $source_key = $source_value;
1046 + }
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 +
820 1053 // Resolve pattern type: per-source value (exact, contain, start, end, wildcard, regex) or row-level default
821 1054 $pattern_type = in_array($source_value, array('exact', 'contain', 'start', 'end', 'wildcard', 'regex'))
822 1055 ? $source_value
823 1056 : ($global_pattern_type ? $global_pattern_type : 'exact');
@@ -826,9 +1059,9 @@
826 1059 $normalized_source = $source_key;
827 1060
828 1061 // If source is a full URL, use only the path for matching (consistent with front-end redirect behavior)
829 1062 if (strpos($source_key, 'http') === 0) {
830 - $parsed_src = parse_url($source_key);
1063 + $parsed_src = wp_parse_url($source_key);
831 1064 $normalized_source = isset($parsed_src['path']) ? $parsed_src['path'] : '';
832 1065 }
833 1066
834 1067 // Ensure leading slash for reliable path comparison
@@ -981,8 +1214,39 @@
981 1214 return $destination;
982 1215 }
983 1216
984 1217 /**
1218 + * Preserve query parameters from the incoming request when redirecting.
1219 + *
1220 + * Redirect matching intentionally ignores the query string, but the
1221 + * browser-visible redirect must not silently discard it. Keep the stored
1222 + * destination query intact and append the incoming query verbatim so
1223 + * encoded values and repeated parameters survive unchanged.
1224 + *
1225 + * @param string $destination Destination URL or path.
1226 + * @param string $query Incoming query without the leading '?'.
1227 + * @return string Destination with the incoming query appended.
1228 + */
1229 + private function append_query_string($destination, $query)
1230 + {
1231 + $destination = (string) $destination;
1232 + $query = ltrim((string) $query, '?');
1233 + if ($query === '') {
1234 + return $destination;
1235 + }
1236 +
1237 + $fragment = '';
1238 + $fragment_pos = strpos($destination, '#');
1239 + if ($fragment_pos !== false) {
1240 + $fragment = substr($destination, $fragment_pos);
1241 + $destination = substr($destination, 0, $fragment_pos);
1242 + }
1243 +
1244 + $separator = strpos($destination, '?') === false ? '?' : '&';
1245 + return $destination . $separator . $query . $fragment;
1246 + }
1247 +
1248 + /**
985 1249 * Handle template redirect for frontend redirections
986 1250 */
987 1251 public function handle_template_redirect()
988 1252 {
@@ -990,14 +1254,28 @@
990 1254 if (is_admin()) {
991 1255 return;
992 1256 }
993 1257
994 - // Get current URI
995 - $uri = $_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']) : '/';
996 1261
1262 + // Keep the raw query for the redirect destination. It is deliberately
1263 + // excluded from matching below, but must survive the browser redirect.
1264 + $query_pos = strpos($request_uri, '?');
1265 + $incoming_query = $query_pos === false ? '' : substr($request_uri, $query_pos + 1);
1266 +
997 1267 // Remove query string for matching
998 - $uri = strtok($uri, '?');
1268 + $uri = $query_pos === false ? $request_uri : substr($request_uri, 0, $query_pos);
999 1269
1270 + // REQUEST_URI arrives percent-encoded while stored sources are raw
1271 + // UTF-8, so a stored '/日本' could never match '/%E6%97%A5%E6%9C%AC'.
1272 + // Decode before matching; the stored destination still drives output.
1273 + $decoded_uri = rawurldecode((string) $uri);
1274 + if ($decoded_uri !== '') {
1275 + $uri = $decoded_uri;
1276 + }
1277 +
1000 1278 // Build the lookup index (lazy, once per request)
1001 1279 $this->ensure_redirect_index();
1002 1280
1003 1281 // O(1) exact-match lookup first
@@ -1007,9 +1285,9 @@
1007 1285 }
1008 1286 $normalized_uri = rtrim($normalized_uri, '/') ?: '/';
1009 1287
1010 1288 if (isset($this->exact_index[$normalized_uri])) {
1011 - if ($this->source_url_redirection($this->exact_index[$normalized_uri], $uri)) {
1289 + if ($this->source_url_redirection($this->exact_index[$normalized_uri], $uri, $incoming_query)) {
1012 1290 return;
1013 1291 }
1014 1292 }
1015 1293
@@ -1014,9 +1292,9 @@
1014 1292 }
1015 1293
1016 1294 // Fallback: scan only pattern-based rows (wildcard, regex, contain, start, end)
1017 1295 foreach ($this->pattern_index as $redirection) {
1018 - if ($this->source_url_redirection($redirection, $uri)) {
1296 + if ($this->source_url_redirection($redirection, $uri, $incoming_query)) {
1019 1297 return;
1020 1298 }
1021 1299 }
1022 1300 }
@@ -1166,9 +1444,9 @@
1166 1444 # return $test_result !== false;
1167 1445
1168 1446 // Use error handler to catch warnings from malformed patterns
1169 1447 $error_occurred = false;
1170 - 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
1171 1449 $error_occurred = true;
1172 1450 return true; // Suppress the error
1173 1451 }, E_WARNING);
1174 1452