PluginProbe
Vigilant – 100% Free Security Suite: Firewall, 2FA, Login, Headers, Scanner… / 2.9.5
Vigilant – 100% Free Security Suite: Firewall, 2FA, Login, Headers, Scanner… v2.9.5
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.5, at includes/class-https-enforcer.php

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