PluginProbe
Vigilant – 100% Free Security Suite: Firewall, 2FA, Login, Headers, Scanner… / 2.11.9
Vigilant – 100% Free Security Suite: Firewall, 2FA, Login, Headers, Scanner… v2.11.9
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 2.9.4 2.9.3 All 86 releases
vigilante / includes / class-https-enforcer.php

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

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