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

361 lines 10.4 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 * Constructor
45 *
46 * @param Vigilante_Settings $settings Settings instance.
47 */
48 public function __construct( $settings ) {
49 $this->settings = $settings;
50 $this->options = $settings->get_section( 'security_headers' );
51
52 if ( empty( $this->options['enabled'] ) ) {
53 return;
54 }
55
56 // Redirect HTTP to HTTPS
57 if ( ! empty( $this->options['redirect_http_to_https'] ) ) {
58 add_action( 'template_redirect', array( $this, 'redirect_to_https' ), 1 );
59 add_action( 'admin_init', array( $this, 'redirect_to_https' ), 1 );
60 }
61
62 // Fix mixed content
63 if ( ! empty( $this->options['fix_mixed_content'] ) ) {
64 add_action( 'wp_loaded', array( $this, 'start_output_buffer' ) );
65 add_action( 'shutdown', array( $this, 'end_output_buffer' ), 0 );
66 add_filter( 'script_loader_src', array( $this, 'fix_url_scheme' ), 10, 1 );
67 add_filter( 'style_loader_src', array( $this, 'fix_url_scheme' ), 10, 1 );
68 add_filter( 'wp_get_attachment_url', array( $this, 'fix_url_scheme' ), 10, 1 );
69 add_filter( 'the_content', array( $this, 'fix_content_urls' ), 999 );
70 add_filter( 'widget_text', array( $this, 'fix_content_urls' ), 999 );
71
72 // The rewriters above only cover same-domain URLs; external
73 // http:// references need the browser-side CSP directive.
74 add_action( 'send_headers', array( $this, 'emit_upgrade_insecure_requests' ) );
75 }
76 }
77
78 /**
79 * Redirect HTTP requests to HTTPS
80 */
81 public function redirect_to_https() {
82 // Skip if already HTTPS
83 if ( is_ssl() ) {
84 return;
85 }
86
87 // Skip CLI
88 if ( defined( 'WP_CLI' ) && WP_CLI ) {
89 return;
90 }
91
92 // Skip AJAX requests
93 if ( wp_doing_ajax() ) {
94 return;
95 }
96
97 // Skip cron
98 if ( wp_doing_cron() ) {
99 return;
100 }
101
102 // Build HTTPS URL
103 $redirect_url = 'https://' . ( isset( $_SERVER['HTTP_HOST'] ) ? sanitize_text_field( wp_unslash( $_SERVER['HTTP_HOST'] ) ) : '' );
104 $redirect_url .= isset( $_SERVER['REQUEST_URI'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REQUEST_URI'] ) ) : '';
105
106 // Redirect with 301 (permanent)
107 wp_safe_redirect( $redirect_url, 301 );
108 exit;
109 }
110
111 /**
112 * Send the CSP upgrade-insecure-requests directive on the front end.
113 *
114 * The mixed-content rewriter only fixes same-domain URLs (its patterns
115 * are anchored to home_url()), so references to EXTERNAL http://
116 * resources survived and the Security Check kept flagging them, which
117 * read as "Fix mixed content does nothing". This directive makes the
118 * browser upgrade every subrequest, external ones included.
119 *
120 * Always emitted, without checking the CSP module settings: Vigilant's
121 * own CSP travels via .htaccess, so "csp enabled" in the options does
122 * not guarantee the header is actually served (Nginx, unwritable
123 * .htaccess, mod_headers missing). Multiple CSP headers stack in the
124 * browser (every policy applies) and this directive alone restricts
125 * nothing, so a duplicate is harmless while a missed emission is not.
126 *
127 * @since 2.9.3
128 */
129 public function emit_upgrade_insecure_requests() {
130 if ( ! is_ssl() || headers_sent() ) {
131 return;
132 }
133
134 header( 'Content-Security-Policy: upgrade-insecure-requests', false );
135 }
136
137 /**
138 * Start output buffering to fix mixed content
139 */
140 public function start_output_buffer() {
141 if ( ! is_ssl() ) {
142 return;
143 }
144
145 ob_start( array( $this, 'fix_output_buffer' ) );
146 $this->ob_started = true;
147 }
148
149 /**
150 * Explicitly close the output buffer on shutdown.
151 *
152 * Ensures the buffer opened by start_output_buffer() is always
153 * properly closed within the same logical flow.
154 */
155 public function end_output_buffer() {
156 if ( $this->ob_started && ob_get_level() > 0 ) {
157 ob_end_flush();
158 $this->ob_started = false;
159 }
160 }
161
162 /**
163 * Fix URLs in output buffer
164 *
165 * @param string $content Buffer content.
166 * @return string
167 */
168 public function fix_output_buffer( $content ) {
169 if ( empty( $content ) ) {
170 return $content;
171 }
172
173 // Only process HTML content
174 if ( strpos( $content, '<html' ) === false && strpos( $content, '<!DOCTYPE' ) === false ) {
175 return $content;
176 }
177
178 return $this->replace_http_with_https( $content );
179 }
180
181 /**
182 * Replace HTTP URLs with HTTPS
183 *
184 * @param string $content Content to process.
185 * @return string
186 */
187 private function replace_http_with_https( $content ) {
188 // Get site URL without protocol
189 $site_url = preg_replace( '/^https?:\/\//', '', home_url() );
190 $site_url = preg_quote( $site_url, '/' );
191
192 // Replace HTTP with HTTPS for same domain
193 $patterns = array(
194 // Standard URLs
195 '/http:\/\/' . $site_url . '/i' => 'https://' . str_replace( '\\', '', $site_url ),
196
197 // srcset attributes
198 '/http:\/\/(' . $site_url . '[^"\'\s]*)/i' => 'https://$1',
199 );
200
201 foreach ( $patterns as $pattern => $replacement ) {
202 $content = preg_replace( $pattern, $replacement, $content );
203 }
204
205 // Fix protocol-relative URLs that should be HTTPS
206 $content = preg_replace(
207 '/(<(script|link|img|iframe|source|video|audio)[^>]*(?:src|href|srcset)=["\'])\/\//i',
208 '$1https://',
209 $content
210 );
211
212 return $content;
213 }
214
215 /**
216 * Fix URL scheme for enqueued scripts/styles
217 *
218 * @param string $url URL to fix.
219 * @return string
220 */
221 public function fix_url_scheme( $url ) {
222 if ( empty( $url ) || ! is_ssl() ) {
223 return $url;
224 }
225
226 // Only fix URLs from the same domain
227 $site_host = wp_parse_url( home_url(), PHP_URL_HOST );
228 $url_host = wp_parse_url( $url, PHP_URL_HOST );
229
230 if ( $site_host === $url_host ) {
231 $url = set_url_scheme( $url, 'https' );
232 }
233
234 return $url;
235 }
236
237 /**
238 * Fix URLs in content
239 *
240 * @param string $content Content to process.
241 * @return string
242 */
243 public function fix_content_urls( $content ) {
244 if ( empty( $content ) || ! is_ssl() ) {
245 return $content;
246 }
247
248 return $this->replace_http_with_https( $content );
249 }
250
251 /**
252 * Update WordPress site URLs to HTTPS
253 *
254 * @return bool
255 */
256 public function update_site_urls() {
257 $siteurl = get_option( 'siteurl' );
258 $home = get_option( 'home' );
259 $updated = false;
260
261 if ( strpos( $siteurl, 'http://' ) === 0 ) {
262 update_option( 'siteurl', str_replace( 'http://', 'https://', $siteurl ) );
263 $updated = true;
264 }
265
266 if ( strpos( $home, 'http://' ) === 0 ) {
267 update_option( 'home', str_replace( 'http://', 'https://', $home ) );
268 $updated = true;
269 }
270
271 return $updated;
272 }
273
274 /**
275 * Check if site is properly configured for HTTPS
276 *
277 * @return array Status information.
278 */
279 public function get_https_status() {
280 $status = array(
281 'ssl_available' => is_ssl(),
282 'siteurl_https' => strpos( get_option( 'siteurl' ), 'https://' ) === 0,
283 'home_https' => strpos( get_option( 'home' ), 'https://' ) === 0,
284 'force_ssl_admin' => defined( 'FORCE_SSL_ADMIN' ) && FORCE_SSL_ADMIN,
285 'force_ssl_login' => defined( 'FORCE_SSL_LOGIN' ) && FORCE_SSL_LOGIN,
286 'certificate_valid' => $this->check_ssl_certificate(),
287 );
288
289 $status['fully_configured'] = $status['ssl_available']
290 && $status['siteurl_https']
291 && $status['home_https']
292 && $status['certificate_valid'];
293
294 return $status;
295 }
296
297 /**
298 * Check if SSL certificate is valid
299 *
300 * @return bool
301 */
302 private function check_ssl_certificate() {
303 $url = str_replace( 'http://', 'https://', home_url() );
304
305 $response = wp_remote_get( $url, array(
306 'sslverify' => true,
307 'timeout' => 10,
308 ));
309
310 return ! is_wp_error( $response );
311 }
312
313 /**
314 * Get list of mixed content issues (for diagnostics)
315 *
316 * @return array
317 */
318 public function scan_for_mixed_content() {
319 $issues = array();
320
321 // Check common options that might contain HTTP URLs
322 $options_to_check = array(
323 'siteurl',
324 'home',
325 'stylesheet_url',
326 'template_url',
327 );
328
329 foreach ( $options_to_check as $option ) {
330 $value = get_option( $option );
331 if ( $value && strpos( $value, 'http://' ) === 0 ) {
332 $issues[] = array(
333 'type' => 'option',
334 'name' => $option,
335 'value' => $value,
336 );
337 }
338 }
339
340 // Check for HTTP URLs in recent posts content
341 global $wpdb;
342
343 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
344 $posts_with_http = $wpdb->get_results(
345 "SELECT ID, post_title FROM {$wpdb->posts}
346 WHERE post_status = 'publish'
347 AND (post_content LIKE '%http://%' OR post_content LIKE '%src=\"http://%')
348 LIMIT 10"
349 );
350
351 foreach ( $posts_with_http as $post ) {
352 $issues[] = array(
353 'type' => 'post',
354 'id' => $post->ID,
355 'title' => $post->post_title,
356 );
357 }
358
359 return $issues;
360 }
361 }