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
vigilante / includes / class-https-enforcer.php

class-https-enforcer.php in Vigilant – 100% Free Security Suite: Firewall, 2FA, Login, Headers, Scanner… 3.0.3, at includes/class-https-enforcer.php

536 lines 17.9 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * HTTPS Enforcer Class
4 *
5 * Forces HTTPS across the site
6 *
7 * @package Vigilante
8 */
9
10 // Prevent direct access
11 if ( ! defined( 'ABSPATH' ) ) {
12 exit;
13 }
14
15 /**
16 * Class Vigilante_Https_Enforcer
17 *
18 * Enforces HTTPS connections and fixes mixed content
19 */
20 class Vigilante_Https_Enforcer {
21
22 /**
23 * Settings instance
24 *
25 * @var Vigilante_Settings
26 */
27 private $settings;
28
29 /**
30 * HTTPS options
31 *
32 * @var array
33 */
34 private $options;
35
36 /**
37 * Whether the output buffer was started by this class
38 *
39 * @var bool
40 */
41 private $ob_started = false;
42
43 /**
44 * Nesting level of the buffer opened by this class.
45 *
46 * Recorded so shutdown can tell whether the buffer on top is still ours.
47 *
48 * @var int
49 */
50 private $ob_level = 0;
51
52 /**
53 * Constructor
54 *
55 * @param Vigilante_Settings $settings Settings instance.
56 */
57 public function __construct( $settings ) {
58 $this->settings = $settings;
59 $this->options = $settings->get_section( 'security_headers' );
60
61 if ( empty( $this->options['enabled'] ) ) {
62 return;
63 }
64
65 // Redirect HTTP to HTTPS
66 if ( ! empty( $this->options['redirect_http_to_https'] ) ) {
67 add_action( 'template_redirect', array( $this, 'redirect_to_https' ), 1 );
68 add_action( 'admin_init', array( $this, 'redirect_to_https' ), 1 );
69 }
70
71 // Fix mixed content
72 if ( ! empty( $this->options['fix_mixed_content'] ) ) {
73 add_action( 'wp_loaded', array( $this, 'start_output_buffer' ) );
74 add_action( 'shutdown', array( $this, 'end_output_buffer' ), 0 );
75 add_filter( 'script_loader_src', array( $this, 'fix_url_scheme' ), 10, 1 );
76 add_filter( 'style_loader_src', array( $this, 'fix_url_scheme' ), 10, 1 );
77 add_filter( 'wp_get_attachment_url', array( $this, 'fix_url_scheme' ), 10, 1 );
78 add_filter( 'the_content', array( $this, 'fix_content_urls' ), 999 );
79 add_filter( 'widget_text', array( $this, 'fix_content_urls' ), 999 );
80
81 }
82
83 // Its own setting since 2.9.8: the rewriters above are same-domain and
84 // harmless, this one governs everybody else's resources too.
85 if ( ! empty( $this->options['upgrade_insecure_requests'] ) ) {
86 add_action( 'send_headers', array( $this, 'emit_upgrade_insecure_requests' ) );
87 }
88 }
89
90 /**
91 * Redirect HTTP requests to HTTPS
92 */
93 public function redirect_to_https() {
94 // Skip if already HTTPS
95 if ( is_ssl() ) {
96 return;
97 }
98
99 /*
100 * Only redirect when the site itself declares HTTPS. A site whose home
101 * URL is still http:// has not moved to HTTPS, and sending every request
102 * to an address that may not answer takes it offline outright. Read from
103 * the home option (which honours the WP_HOME constant through the
104 * option_home filter) rather than home_url(), so the answer is the
105 * address the site declares and not one derived from the current
106 * request. A site already on HTTPS has an https home URL and keeps
107 * redirecting exactly as before.
108 */
109 if ( 0 !== strpos( (string) get_option( 'home' ), 'https://' ) ) {
110 return;
111 }
112
113 // Skip CLI
114 if ( defined( 'WP_CLI' ) && WP_CLI ) {
115 return;
116 }
117
118 // Skip AJAX requests
119 if ( wp_doing_ajax() ) {
120 return;
121 }
122
123 // Skip cron
124 if ( wp_doing_cron() ) {
125 return;
126 }
127
128 // Build HTTPS URL
129 $redirect_url = $this->build_https_url();
130
131 if ( '' === $redirect_url ) {
132 return;
133 }
134
135 // Redirect with 301 (permanent)
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 );
168 exit;
169 }
170
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 /**
268 * Send the CSP upgrade-insecure-requests directive on the front end.
269 *
270 * The mixed-content rewriter only fixes same-domain URLs (its patterns
271 * are anchored to home_url()), so references to EXTERNAL http://
272 * resources survived and the Security Check kept flagging them, which
273 * read as "Fix mixed content does nothing". This directive makes the
274 * browser upgrade every subrequest, external ones included.
275 *
276 * Always emitted, without checking the CSP module settings: Vigilant's
277 * own CSP travels via .htaccess, so "csp enabled" in the options does
278 * not guarantee the header is actually served (Nginx, unwritable
279 * .htaccess, mod_headers missing). Multiple CSP headers stack in the
280 * browser (every policy applies) and this directive alone restricts
281 * nothing, so a duplicate is harmless while a missed emission is not.
282 *
283 * @since 2.9.3
284 */
285 public function emit_upgrade_insecure_requests() {
286 if ( ! is_ssl() || headers_sent() ) {
287 return;
288 }
289
290 header( 'Content-Security-Policy: upgrade-insecure-requests', false );
291 }
292
293 /**
294 * Start output buffering to fix mixed content
295 */
296 public function start_output_buffer() {
297 if ( ! is_ssl() ) {
298 return;
299 }
300
301 // The rewriter only touches complete HTML documents (fix_output_buffer()
302 // bails on anything without <html or <!DOCTYPE), so buffering the admin,
303 // AJAX and REST responses pays for a buffer and a callback that can never
304 // do any work. On a WooCommerce site the cart-fragments endpoint alone is
305 // dozens of those per visitor.
306 if ( is_admin() || wp_doing_ajax() || $this->is_rest_request() ) {
307 return;
308 }
309
310 ob_start( array( $this, 'fix_output_buffer' ) );
311 $this->ob_started = true;
312 $this->ob_level = ob_get_level();
313 }
314
315 /**
316 * Whether the current request is a REST API request.
317 *
318 * REST_REQUEST is only defined once the request is being served, which is
319 * after wp_loaded, so the REST route prefix is checked as well.
320 *
321 * @return bool
322 */
323 private function is_rest_request() {
324 if ( defined( 'REST_REQUEST' ) && REST_REQUEST ) {
325 return true;
326 }
327
328 $uri = Vigilante_IP_Utils::request_uri();
329
330 if ( '' === $uri ) {
331 return false;
332 }
333
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, '?#' ) );
337 $prefix = '/' . trim( rest_get_url_prefix(), '/' ) . '/';
338
339 return 0 === strpos( $path, $prefix );
340 }
341
342 /**
343 * Explicitly close the output buffer on shutdown.
344 *
345 * Ensures the buffer opened by start_output_buffer() is always
346 * properly closed within the same logical flow.
347 */
348 public function end_output_buffer() {
349 // Only flush when the buffer on top is exactly the one we opened. Testing
350 // for "is there any buffer at all" would close somebody else's buffer when
351 // another plugin opened one after ours and had not closed it yet, leaving
352 // ours open on top of that. When the levels do not match, doing nothing is
353 // the safe move: PHP flushes what is left at the end of the request.
354 if ( $this->ob_started && ob_get_level() === $this->ob_level ) {
355 ob_end_flush();
356 $this->ob_started = false;
357 }
358 }
359
360 /**
361 * Fix URLs in output buffer
362 *
363 * @param string $content Buffer content.
364 * @return string
365 */
366 public function fix_output_buffer( $content ) {
367 if ( empty( $content ) ) {
368 return $content;
369 }
370
371 // Only process HTML content
372 if ( strpos( $content, '<html' ) === false && strpos( $content, '<!DOCTYPE' ) === false ) {
373 return $content;
374 }
375
376 return $this->replace_http_with_https( $content );
377 }
378
379 /**
380 * Replace HTTP URLs with HTTPS
381 *
382 * @param string $content Content to process.
383 * @return string
384 */
385 private function replace_http_with_https( $content ) {
386 // Get site URL without protocol
387 $site_url = preg_replace( '/^https?:\/\//', '', home_url() );
388 $site_url = preg_quote( $site_url, '/' );
389
390 // Replace HTTP with HTTPS for same domain
391 $patterns = array(
392 // Standard URLs
393 '/http:\/\/' . $site_url . '/i' => 'https://' . str_replace( '\\', '', $site_url ),
394
395 // srcset attributes
396 '/http:\/\/(' . $site_url . '[^"\'\s]*)/i' => 'https://$1',
397 );
398
399 foreach ( $patterns as $pattern => $replacement ) {
400 $content = preg_replace( $pattern, $replacement, $content );
401 }
402
403 // Fix protocol-relative URLs that should be HTTPS
404 $content = preg_replace(
405 '/(<(script|link|img|iframe|source|video|audio)[^>]*(?:src|href|srcset)=["\'])\/\//i',
406 '$1https://',
407 $content
408 );
409
410 return $content;
411 }
412
413 /**
414 * Fix URL scheme for enqueued scripts/styles
415 *
416 * @param string $url URL to fix.
417 * @return string
418 */
419 public function fix_url_scheme( $url ) {
420 if ( empty( $url ) || ! is_ssl() ) {
421 return $url;
422 }
423
424 // Only fix URLs from the same domain
425 $site_host = wp_parse_url( home_url(), PHP_URL_HOST );
426 $url_host = wp_parse_url( $url, PHP_URL_HOST );
427
428 if ( $site_host === $url_host ) {
429 $url = set_url_scheme( $url, 'https' );
430 }
431
432 return $url;
433 }
434
435 /**
436 * Fix URLs in content
437 *
438 * @param string $content Content to process.
439 * @return string
440 */
441 public function fix_content_urls( $content ) {
442 if ( empty( $content ) || ! is_ssl() ) {
443 return $content;
444 }
445
446 return $this->replace_http_with_https( $content );
447 }
448
449 /**
450 * Check if site is properly configured for HTTPS
451 *
452 * @return array Status information.
453 */
454 public function get_https_status() {
455 $status = array(
456 'ssl_available' => is_ssl(),
457 'siteurl_https' => strpos( get_option( 'siteurl' ), 'https://' ) === 0,
458 'home_https' => strpos( get_option( 'home' ), 'https://' ) === 0,
459 'force_ssl_admin' => defined( 'FORCE_SSL_ADMIN' ) && FORCE_SSL_ADMIN,
460 'force_ssl_login' => defined( 'FORCE_SSL_LOGIN' ) && FORCE_SSL_LOGIN,
461 'certificate_valid' => $this->check_ssl_certificate(),
462 );
463
464 $status['fully_configured'] = $status['ssl_available']
465 && $status['siteurl_https']
466 && $status['home_https']
467 && $status['certificate_valid'];
468
469 return $status;
470 }
471
472 /**
473 * Check if SSL certificate is valid
474 *
475 * @return bool
476 */
477 private function check_ssl_certificate() {
478 $url = str_replace( 'http://', 'https://', home_url() );
479
480 $response = wp_remote_get( $url, array(
481 'sslverify' => true,
482 'timeout' => 10,
483 ));
484
485 return ! is_wp_error( $response );
486 }
487
488 /**
489 * Get list of mixed content issues (for diagnostics)
490 *
491 * @return array
492 */
493 public function scan_for_mixed_content() {
494 $issues = array();
495
496 // Check common options that might contain HTTP URLs
497 $options_to_check = array(
498 'siteurl',
499 'home',
500 'stylesheet_url',
501 'template_url',
502 );
503
504 foreach ( $options_to_check as $option ) {
505 $value = get_option( $option );
506 if ( $value && strpos( $value, 'http://' ) === 0 ) {
507 $issues[] = array(
508 'type' => 'option',
509 'name' => $option,
510 'value' => $value,
511 );
512 }
513 }
514
515 // Check for HTTP URLs in recent posts content
516 global $wpdb;
517
518 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
519 $posts_with_http = $wpdb->get_results(
520 "SELECT ID, post_title FROM {$wpdb->posts}
521 WHERE post_status = 'publish'
522 AND (post_content LIKE '%http://%' OR post_content LIKE '%src=\"http://%')
523 LIMIT 10"
524 );
525
526 foreach ( $posts_with_http as $post ) {
527 $issues[] = array(
528 'type' => 'post',
529 'id' => $post->ID,
530 'title' => $post->post_title,
531 );
532 }
533
534 return $issues;
535 }
536 }