PluginProbe
Vigilant – 100% Free Security Suite: Firewall, 2FA, Login, Headers, Scanner… / 3.0.3
Vigilant – 100% Free Security Suite: Firewall, 2FA, Login, Headers, Scanner… v3.0.3
3.0.5 3.0.4 3.0.3 3.0.2 3.0.1 3.0.0 2.11.12 2.11.11 2.11.10 2.11.9 2.11.7 2.11.8 2.11.6 2.11.5 2.11.4 2.11.3 2.11.1 2.11.2 2.11.0 2.10.5 2.10.4 2.10.3 2.10.2 2.10.1 2.10.0 All 93 releases
← All changes | includes/class-https-enforcer.php +140 -6 2.11.5 → 3.0.3 View file →
@@ -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.