PluginProbe
Vigilant – 100% Free Security Suite: Firewall, 2FA, Login, Headers, Scanner… / 2.9.7
Vigilant – 100% Free Security Suite: Firewall, 2FA, Login, Headers, Scanner… v2.9.7
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 2.9.9 2.9.8 2.9.6 2.9.7 2.9.5 All 88 releases
vigilante / includes / class-https-enforcer.php

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

422 lines 13.0 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 // The rewriters above only cover same-domain URLs; external
82 // http:// references need the browser-side CSP directive.
83 add_action( 'send_headers', array( $this, 'emit_upgrade_insecure_requests' ) );
84 }
85 }
86
87 /**
88 * Redirect HTTP requests to HTTPS
89 */
90 public function redirect_to_https() {
91 // Skip if already HTTPS
92 if ( is_ssl() ) {
93 return;
94 }
95
96 /*
97 * Only redirect when the site itself declares HTTPS. A site whose home
98 * URL is still http:// has not moved to HTTPS, and sending every request
99 * to an address that may not answer takes it offline outright. Read from
100 * the home option (which honours the WP_HOME constant through the
101 * option_home filter) rather than home_url(), so the answer is the
102 * address the site declares and not one derived from the current
103 * request. A site already on HTTPS has an https home URL and keeps
104 * redirecting exactly as before.
105 */
106 if ( 0 !== strpos( (string) get_option( 'home' ), 'https://' ) ) {
107 return;
108 }
109
110 // Skip CLI
111 if ( defined( 'WP_CLI' ) && WP_CLI ) {
112 return;
113 }
114
115 // Skip AJAX requests
116 if ( wp_doing_ajax() ) {
117 return;
118 }
119
120 // Skip cron
121 if ( wp_doing_cron() ) {
122 return;
123 }
124
125 // Build HTTPS URL
126 $redirect_url = 'https://' . ( isset( $_SERVER['HTTP_HOST'] ) ? sanitize_text_field( wp_unslash( $_SERVER['HTTP_HOST'] ) ) : '' );
127 $redirect_url .= isset( $_SERVER['REQUEST_URI'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REQUEST_URI'] ) ) : '';
128
129 // Redirect with 301 (permanent)
130 wp_safe_redirect( $redirect_url, 301 );
131 exit;
132 }
133
134 /**
135 * Send the CSP upgrade-insecure-requests directive on the front end.
136 *
137 * The mixed-content rewriter only fixes same-domain URLs (its patterns
138 * are anchored to home_url()), so references to EXTERNAL http://
139 * resources survived and the Security Check kept flagging them, which
140 * read as "Fix mixed content does nothing". This directive makes the
141 * browser upgrade every subrequest, external ones included.
142 *
143 * Always emitted, without checking the CSP module settings: Vigilant's
144 * own CSP travels via .htaccess, so "csp enabled" in the options does
145 * not guarantee the header is actually served (Nginx, unwritable
146 * .htaccess, mod_headers missing). Multiple CSP headers stack in the
147 * browser (every policy applies) and this directive alone restricts
148 * nothing, so a duplicate is harmless while a missed emission is not.
149 *
150 * @since 2.9.3
151 */
152 public function emit_upgrade_insecure_requests() {
153 if ( ! is_ssl() || headers_sent() ) {
154 return;
155 }
156
157 header( 'Content-Security-Policy: upgrade-insecure-requests', false );
158 }
159
160 /**
161 * Start output buffering to fix mixed content
162 */
163 public function start_output_buffer() {
164 if ( ! is_ssl() ) {
165 return;
166 }
167
168 // The rewriter only touches complete HTML documents (fix_output_buffer()
169 // bails on anything without <html or <!DOCTYPE), so buffering the admin,
170 // AJAX and REST responses pays for a buffer and a callback that can never
171 // do any work. On a WooCommerce site the cart-fragments endpoint alone is
172 // dozens of those per visitor.
173 if ( is_admin() || wp_doing_ajax() || $this->is_rest_request() ) {
174 return;
175 }
176
177 ob_start( array( $this, 'fix_output_buffer' ) );
178 $this->ob_started = true;
179 $this->ob_level = ob_get_level();
180 }
181
182 /**
183 * Whether the current request is a REST API request.
184 *
185 * REST_REQUEST is only defined once the request is being served, which is
186 * after wp_loaded, so the REST route prefix is checked as well.
187 *
188 * @return bool
189 */
190 private function is_rest_request() {
191 if ( defined( 'REST_REQUEST' ) && REST_REQUEST ) {
192 return true;
193 }
194
195 if ( ! isset( $_SERVER['REQUEST_URI'] ) ) {
196 return false;
197 }
198
199 $path = wp_parse_url( sanitize_text_field( wp_unslash( $_SERVER['REQUEST_URI'] ) ), PHP_URL_PATH );
200 $prefix = '/' . trim( rest_get_url_prefix(), '/' ) . '/';
201
202 return is_string( $path ) && 0 === strpos( $path, $prefix );
203 }
204
205 /**
206 * Explicitly close the output buffer on shutdown.
207 *
208 * Ensures the buffer opened by start_output_buffer() is always
209 * properly closed within the same logical flow.
210 */
211 public function end_output_buffer() {
212 // Only flush when the buffer on top is exactly the one we opened. Testing
213 // for "is there any buffer at all" would close somebody else's buffer when
214 // another plugin opened one after ours and had not closed it yet, leaving
215 // ours open on top of that. When the levels do not match, doing nothing is
216 // the safe move: PHP flushes what is left at the end of the request.
217 if ( $this->ob_started && ob_get_level() === $this->ob_level ) {
218 ob_end_flush();
219 $this->ob_started = false;
220 }
221 }
222
223 /**
224 * Fix URLs in output buffer
225 *
226 * @param string $content Buffer content.
227 * @return string
228 */
229 public function fix_output_buffer( $content ) {
230 if ( empty( $content ) ) {
231 return $content;
232 }
233
234 // Only process HTML content
235 if ( strpos( $content, '<html' ) === false && strpos( $content, '<!DOCTYPE' ) === false ) {
236 return $content;
237 }
238
239 return $this->replace_http_with_https( $content );
240 }
241
242 /**
243 * Replace HTTP URLs with HTTPS
244 *
245 * @param string $content Content to process.
246 * @return string
247 */
248 private function replace_http_with_https( $content ) {
249 // Get site URL without protocol
250 $site_url = preg_replace( '/^https?:\/\//', '', home_url() );
251 $site_url = preg_quote( $site_url, '/' );
252
253 // Replace HTTP with HTTPS for same domain
254 $patterns = array(
255 // Standard URLs
256 '/http:\/\/' . $site_url . '/i' => 'https://' . str_replace( '\\', '', $site_url ),
257
258 // srcset attributes
259 '/http:\/\/(' . $site_url . '[^"\'\s]*)/i' => 'https://$1',
260 );
261
262 foreach ( $patterns as $pattern => $replacement ) {
263 $content = preg_replace( $pattern, $replacement, $content );
264 }
265
266 // Fix protocol-relative URLs that should be HTTPS
267 $content = preg_replace(
268 '/(<(script|link|img|iframe|source|video|audio)[^>]*(?:src|href|srcset)=["\'])\/\//i',
269 '$1https://',
270 $content
271 );
272
273 return $content;
274 }
275
276 /**
277 * Fix URL scheme for enqueued scripts/styles
278 *
279 * @param string $url URL to fix.
280 * @return string
281 */
282 public function fix_url_scheme( $url ) {
283 if ( empty( $url ) || ! is_ssl() ) {
284 return $url;
285 }
286
287 // Only fix URLs from the same domain
288 $site_host = wp_parse_url( home_url(), PHP_URL_HOST );
289 $url_host = wp_parse_url( $url, PHP_URL_HOST );
290
291 if ( $site_host === $url_host ) {
292 $url = set_url_scheme( $url, 'https' );
293 }
294
295 return $url;
296 }
297
298 /**
299 * Fix URLs in content
300 *
301 * @param string $content Content to process.
302 * @return string
303 */
304 public function fix_content_urls( $content ) {
305 if ( empty( $content ) || ! is_ssl() ) {
306 return $content;
307 }
308
309 return $this->replace_http_with_https( $content );
310 }
311
312 /**
313 * Update WordPress site URLs to HTTPS
314 *
315 * @return bool
316 */
317 public function update_site_urls() {
318 $siteurl = get_option( 'siteurl' );
319 $home = get_option( 'home' );
320 $updated = false;
321
322 if ( strpos( $siteurl, 'http://' ) === 0 ) {
323 update_option( 'siteurl', str_replace( 'http://', 'https://', $siteurl ) );
324 $updated = true;
325 }
326
327 if ( strpos( $home, 'http://' ) === 0 ) {
328 update_option( 'home', str_replace( 'http://', 'https://', $home ) );
329 $updated = true;
330 }
331
332 return $updated;
333 }
334
335 /**
336 * Check if site is properly configured for HTTPS
337 *
338 * @return array Status information.
339 */
340 public function get_https_status() {
341 $status = array(
342 'ssl_available' => is_ssl(),
343 'siteurl_https' => strpos( get_option( 'siteurl' ), 'https://' ) === 0,
344 'home_https' => strpos( get_option( 'home' ), 'https://' ) === 0,
345 'force_ssl_admin' => defined( 'FORCE_SSL_ADMIN' ) && FORCE_SSL_ADMIN,
346 'force_ssl_login' => defined( 'FORCE_SSL_LOGIN' ) && FORCE_SSL_LOGIN,
347 'certificate_valid' => $this->check_ssl_certificate(),
348 );
349
350 $status['fully_configured'] = $status['ssl_available']
351 && $status['siteurl_https']
352 && $status['home_https']
353 && $status['certificate_valid'];
354
355 return $status;
356 }
357
358 /**
359 * Check if SSL certificate is valid
360 *
361 * @return bool
362 */
363 private function check_ssl_certificate() {
364 $url = str_replace( 'http://', 'https://', home_url() );
365
366 $response = wp_remote_get( $url, array(
367 'sslverify' => true,
368 'timeout' => 10,
369 ));
370
371 return ! is_wp_error( $response );
372 }
373
374 /**
375 * Get list of mixed content issues (for diagnostics)
376 *
377 * @return array
378 */
379 public function scan_for_mixed_content() {
380 $issues = array();
381
382 // Check common options that might contain HTTP URLs
383 $options_to_check = array(
384 'siteurl',
385 'home',
386 'stylesheet_url',
387 'template_url',
388 );
389
390 foreach ( $options_to_check as $option ) {
391 $value = get_option( $option );
392 if ( $value && strpos( $value, 'http://' ) === 0 ) {
393 $issues[] = array(
394 'type' => 'option',
395 'name' => $option,
396 'value' => $value,
397 );
398 }
399 }
400
401 // Check for HTTP URLs in recent posts content
402 global $wpdb;
403
404 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
405 $posts_with_http = $wpdb->get_results(
406 "SELECT ID, post_title FROM {$wpdb->posts}
407 WHERE post_status = 'publish'
408 AND (post_content LIKE '%http://%' OR post_content LIKE '%src=\"http://%')
409 LIMIT 10"
410 );
411
412 foreach ( $posts_with_http as $post ) {
413 $issues[] = array(
414 'type' => 'post',
415 'id' => $post->ID,
416 'title' => $post->post_title,
417 );
418 }
419
420 return $issues;
421 }
422 }