| @@ -125,17 +125,147 @@ | ||
| 125 | 125 | return; |
| 126 | 126 | } |
| 127 | 127 | |
| 128 | 128 | // Build HTTPS URL |
| 129 | - $redirect_url = 'https://' . ( isset( $_SERVER['HTTP_HOST'] ) ? sanitize_text_field( wp_unslash( $_SERVER['HTTP_HOST'] ) ) : '' ); | |
| 130 | - $redirect_url .= isset( $_SERVER['REQUEST_URI'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REQUEST_URI'] ) ) : ''; | |
| 129 | + $redirect_url = $this->build_https_url(); | |
| 131 | 130 | |
| 131 | + if ( '' === $redirect_url ) { | |
| 132 | + return; | |
| 133 | + } | |
| 134 | + | |
| 132 | 135 | // Redirect with 301 (permanent) |
| 133 | - wp_safe_redirect( $redirect_url, 301 ); | |
| 136 | + $this->send_redirect( $redirect_url ); | |
| 137 | + } | |
| 138 | + | |
| 139 | + /** | |
| 140 | + * Send the permanent redirect to the HTTPS address. | |
| 141 | + * | |
| 142 | + * wp_safe_redirect() only knows the home host, and answers any other with | |
| 143 | + * a 301 to admin_url(). The admin area of a site whose WordPress lives on a | |
| 144 | + * host of its own is built on the siteurl host, so that host is added to | |
| 145 | + * the allowed ones for this one call. It is the site's own declaration, read | |
| 146 | + * from the siteurl option; the host of the URL being sent is deliberately | |
| 147 | + * not the one that is allowed, so a URL that was not built from the two | |
| 148 | + * options still falls back to admin_url() instead of being let through. | |
| 149 | + * | |
| 150 | + * @param string $url Absolute https URL, built by build_https_url(). | |
| 151 | + */ | |
| 152 | + private function send_redirect( $url ) { | |
| 153 | + $siteurl_host = $this->host_of( (string) get_option( 'siteurl' ) ); | |
| 154 | + | |
| 155 | + if ( '' !== $siteurl_host ) { | |
| 156 | + add_filter( | |
| 157 | + 'allowed_redirect_hosts', | |
| 158 | + static function ( $hosts ) use ( $siteurl_host ) { | |
| 159 | + $hosts = (array) $hosts; | |
| 160 | + $hosts[] = $siteurl_host; | |
| 161 | + | |
| 162 | + return $hosts; | |
| 163 | + } | |
| 164 | + ); | |
| 165 | + } | |
| 166 | + | |
| 167 | + wp_safe_redirect( $url, 301 ); | |
| 134 | 168 | exit; |
| 135 | 169 | } |
| 136 | 170 | |
| 137 | 171 | /** |
| 172 | + * The host of a URL, cut by hand as wp_parse_url() damages raw UTF-8. | |
| 173 | + * | |
| 174 | + * Like parse_url() for an ordinary URL: no port, an IPv6 literal keeps its | |
| 175 | + * brackets. A user in the option is not taken off, so a value such as | |
| 176 | + * https://user@host gives "user@host", which matches no host and fails | |
| 177 | + * safe. | |
| 178 | + * | |
| 179 | + * @param string $url URL, as stored in an option. | |
| 180 | + * @return string Host, or '' when the URL has none. | |
| 181 | + */ | |
| 182 | + private function host_of( $url ) { | |
| 183 | + return preg_match( '~^https?://(\[[^\]]+\]|[^/:?#]+)~i', $url, $m ) ? $m[1] : ''; | |
| 184 | + } | |
| 185 | + | |
| 186 | + /** | |
| 187 | + * The HTTPS address of the request being redirected. | |
| 188 | + * | |
| 189 | + * Made of two things and nothing else: the host the site declares | |
| 190 | + * (declared_authority()) and the target the client asked for, as it was | |
| 191 | + * sent (Vigilante_IP_Utils::request_uri(), whose docblock lists the few | |
| 192 | + * things it does not keep). | |
| 193 | + * | |
| 194 | + * It used to be the Host header plus sanitize_text_field() over the target, | |
| 195 | + * and both were wrong. sanitize_text_field() deletes every %XX octet, so | |
| 196 | + * http://site/categor%C3%ADa/ was sent to https://site/categora/, a 404 | |
| 197 | + * (measured on 1 oct 2026 against 3.0.1; a Cyrillic path became //, and | |
| 198 | + * every parameter lost its encoded characters). And the Host is picked by | |
| 199 | + * whoever makes the request: wp_safe_redirect() refuses a host that is not | |
| 200 | + * the site's, but what it does then is not refuse, it is send a 301 to the | |
| 201 | + * admin URL. A visit to the www alias of the site, or to the bare domain | |
| 202 | + * when home has the www, or to the server by its IP, was sent permanently | |
| 203 | + * to /wp-admin/ before WordPress' own canonical redirect, which runs later, | |
| 204 | + * could send it to the same page. The declared host is the one core | |
| 205 | + * canonicalizes to as well. | |
| 206 | + * | |
| 207 | + * A site that served other hostnames of its own through | |
| 208 | + * allowed_redirect_hosts now gets the declared host for all of them, which | |
| 209 | + * is what a canonical redirect is. | |
| 210 | + * | |
| 211 | + * @return string Absolute https URL, or '' when the site declares no host to send it to. | |
| 212 | + */ | |
| 213 | + private function build_https_url() { | |
| 214 | + $authority = $this->declared_authority(); | |
| 215 | + | |
| 216 | + if ( '' === $authority ) { | |
| 217 | + return ''; | |
| 218 | + } | |
| 219 | + | |
| 220 | + $uri = Vigilante_IP_Utils::request_uri(); | |
| 221 | + | |
| 222 | + // A target in origin form starts with a slash. Anything else (OPTIONS *, | |
| 223 | + // an absolute-form request line, a bare @host) must not be glued to the | |
| 224 | + // authority, where it could be read as something other than a path. | |
| 225 | + if ( '' === $uri || '/' !== $uri[0] ) { | |
| 226 | + $uri = '/'; | |
| 227 | + } | |
| 228 | + | |
| 229 | + return 'https://' . $authority . $uri; | |
| 230 | + } | |
| 231 | + | |
| 232 | + /** | |
| 233 | + * The host, with its port if it has one, the site declares for this request. | |
| 234 | + * | |
| 235 | + * The admin area is built on the siteurl option, which is where core itself | |
| 236 | + * sends admin_url(), and everything else on the home option, the one the | |
| 237 | + * guard in redirect_to_https() reads. They are the same host on almost | |
| 238 | + * every site. They differ when WordPress is served from a host of its own, | |
| 239 | + * as in a decoupled site, and then an http visit to the admin area has to | |
| 240 | + * stay on the admin host and a visit to the public site on the public one. | |
| 241 | + * Cut by hand, as wp_parse_url() damages raw UTF-8. | |
| 242 | + * | |
| 243 | + * Known limits, found by the cross review, none of them a configuration a | |
| 244 | + * real site has. A siteurl declared as http:// still gives its authority, | |
| 245 | + * port included, to the https address: with home https and siteurl | |
| 246 | + * http://host:8080 the admin goes to https://host:8080, where it used to go | |
| 247 | + * to the home port. And a siteurl on a host of its own that is an IPv6 | |
| 248 | + * literal or a raw UTF-8 IDN is not matched by wp_validate_redirect(), so | |
| 249 | + * the visit falls back to admin_url() and, without FORCE_SSL_ADMIN, can | |
| 250 | + * loop. If a site ever has one of them, take the siteurl authority only | |
| 251 | + * when it is https, and encode the host. | |
| 252 | + * | |
| 253 | + * @return string Authority, or '' when the site declares none. | |
| 254 | + */ | |
| 255 | + private function declared_authority() { | |
| 256 | + if ( is_admin() && preg_match( '~^https?://([^/?#]+)~i', (string) get_option( 'siteurl' ), $m ) ) { | |
| 257 | + return $m[1]; | |
| 258 | + } | |
| 259 | + | |
| 260 | + if ( preg_match( '~^https://([^/?#]+)~i', (string) get_option( 'home' ), $m ) ) { | |
| 261 | + return $m[1]; | |
| 262 | + } | |
| 263 | + | |
| 264 | + return ''; | |
| 265 | + } | |
| 266 | + | |
| 267 | + /** | |
| 138 | 268 | * Send the CSP upgrade-insecure-requests directive on the front end. |
| 139 | 269 | * |
| 140 | 270 | * The mixed-content rewriter only fixes same-domain URLs (its patterns |
| 141 | 271 | * are anchored to home_url()), so references to EXTERNAL http:// |
| @@ -194,16 +324,20 @@ | ||
| 194 | 324 | if ( defined( 'REST_REQUEST' ) && REST_REQUEST ) { |
| 195 | 325 | return true; |
| 196 | 326 | } |
| 197 | 327 | |
| 198 | - if ( ! isset( $_SERVER['REQUEST_URI'] ) ) { | |
| 328 | + $uri = Vigilante_IP_Utils::request_uri(); | |
| 329 | + | |
| 330 | + if ( '' === $uri ) { | |
| 199 | 331 | return false; |
| 200 | 332 | } |
| 201 | 333 | |
| 202 | - $path = wp_parse_url( sanitize_text_field( wp_unslash( $_SERVER['REQUEST_URI'] ) ), PHP_URL_PATH ); | |
| 334 | + // The path is cut by hand: wp_parse_url() turns the raw UTF-8 bytes | |
| 335 | + // 0x80-0x9F and 0xAD into underscores. | |
| 336 | + $path = (string) substr( $uri, 0, strcspn( $uri, '?#' ) ); | |
| 203 | 337 | $prefix = '/' . trim( rest_get_url_prefix(), '/' ) . '/'; |
| 204 | 338 | |
| 205 | - return is_string( $path ) && 0 === strpos( $path, $prefix ); | |
| 339 | + return 0 === strpos( $path, $prefix ); | |
| 206 | 340 | } |
| 207 | 341 | |
| 208 | 342 | /** |
| 209 | 343 | * Explicitly close the output buffer on shutdown. |