| @@ -1086,8 +1086,26 @@ | ||
| 1086 | 1086 | if ( self::is_secret_field( $key, $spec ) && array_key_exists( $key, $settings ) ) { |
| 1087 | 1087 | $settings[ $key ] = self::mask_secret_value( (string) $settings[ $key ] ); |
| 1088 | 1088 | } |
| 1089 | 1089 | } |
| 1090 | + | |
| 1091 | + /* | |
| 1092 | + * Preserved keys are carried through by `get()` and have no schema, so | |
| 1093 | + * this loop never saw them — a module preserving a credential would | |
| 1094 | + * print it here in full. The MCP module preserves its pairing token, | |
| 1095 | + * which is what made that concrete. | |
| 1096 | + * | |
| 1097 | + * Matched by NAME, since a key with no spec has no declared type. A | |
| 1098 | + * scalar only: `mask_secret_value` takes a string, and a preserved key | |
| 1099 | + * can hold an array (the REST-cache `rules`), which is not a secret | |
| 1100 | + * and must not be flattened into one. | |
| 1101 | + */ | |
| 1102 | + foreach ( $module->preserved_keys() as $key ) { | |
| 1103 | + if ( array_key_exists( $key, $settings ) && is_scalar( $settings[ $key ] ) && self::is_secret_key( $key ) ) { | |
| 1104 | + $settings[ $key ] = self::mask_secret_value( (string) $settings[ $key ] ); | |
| 1105 | + } | |
| 1106 | + } | |
| 1107 | + | |
| 1090 | 1108 | return $settings; |
| 1091 | 1109 | } |
| 1092 | 1110 | |
| 1093 | 1111 | /** |
| @@ -1746,8 +1764,10 @@ | ||
| 1746 | 1764 | $u = esc_url_raw( (string) $item ); |
| 1747 | 1765 | if ( $u ) { |
| 1748 | 1766 | $out[] = $u; |
| 1749 | 1767 | } |
| 1768 | + } elseif ( 'path' === $item_type ) { | |
| 1769 | + $out[] = self::sanitize_path_pattern( (string) $item ); | |
| 1750 | 1770 | } else { |
| 1751 | 1771 | $out[] = sanitize_text_field( (string) $item ); |
| 1752 | 1772 | } |
| 1753 | 1773 | } |
| @@ -1820,8 +1840,37 @@ | ||
| 1820 | 1840 | if ( isset( $parts['user'] ) || isset( $parts['pass'] ) ) { |
| 1821 | 1841 | return false; |
| 1822 | 1842 | } |
| 1823 | 1843 | return true; |
| 1844 | + } | |
| 1845 | + | |
| 1846 | + /** | |
| 1847 | + * sanitize_text_field() for a URL path pattern, minus one step: it keeps | |
| 1848 | + * percent-encoded octets. | |
| 1849 | + * | |
| 1850 | + * sanitize_text_field() deletes every `%XX`, so an excluded URL pasted as | |
| 1851 | + * `/%e7%ac%ac%e5%8d%81/` (how WordPress spells a Chinese slug) was saved | |
| 1852 | + * as `//`, and `/%e8%b3%bc%e7%89%a9%e8%bb%8a` as `/`, which "contains"- | |
| 1853 | + * matches every page and turned the page cache off for the whole site. | |
| 1854 | + * The `%` is swapped for a private-use character while the rest of | |
| 1855 | + * sanitize_text_field() runs (tags, line breaks, invalid UTF-8), then | |
| 1856 | + * swapped back. | |
| 1857 | + * | |
| 1858 | + * Escapes are stored in lower case, the spelling the page cache matches | |
| 1859 | + * paths in. An entry copied from Chrome's address bar arrives as | |
| 1860 | + * `/%E8%81%AF…/`; stored as typed, it never matched. | |
| 1861 | + */ | |
| 1862 | + private static function sanitize_path_pattern( string $value ): string { | |
| 1863 | + $mark = "\u{E000}"; | |
| 1864 | + $value = str_replace( $mark, '', $value ); | |
| 1865 | + $value = str_replace( $mark, '%', sanitize_text_field( str_replace( '%', $mark, $value ) ) ); | |
| 1866 | + return (string) preg_replace_callback( | |
| 1867 | + '/%[0-9a-fA-F]{2}/', | |
| 1868 | + static function ( array $m ): string { | |
| 1869 | + return strtolower( $m[0] ); | |
| 1870 | + }, | |
| 1871 | + $value | |
| 1872 | + ); | |
| 1824 | 1873 | } |
| 1825 | 1874 | |
| 1826 | 1875 | /** |
| 1827 | 1876 | * Whether a URL looks like an image — used to gate `media` fields so a |