PluginProbe
Vigilant – 100% Free Security Suite: Firewall, 2FA, Login, Headers, Scanner… / 3.0.5
Vigilant – 100% Free Security Suite: Firewall, 2FA, Login, Headers, Scanner… v3.0.5
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 / security-analyzer / class-sa-category-ssl.php

class-sa-category-ssl.php in Vigilant – 100% Free Security Suite: Firewall, 2FA, Login, Headers, Scanner… 3.0.5, at includes/security-analyzer/class-sa-category-ssl.php

363 lines 14.8 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Security Analyzer — SSL/TLS category (12 pts).
4 *
5 * Checks: ssl_present (2), ssl_cert_valid (2), ssl_cert_expiry (2),
6 * https_redirect (1), tls_version (1), mixed_content (2), force_ssl_admin (2).
7 *
8 * @package Vigilante
9 * @since 2.1.0
10 */
11
12 // Prevent direct access.
13 if ( ! defined( 'ABSPATH' ) ) {
14 exit;
15 }
16
17 /**
18 * SSL/TLS checks.
19 *
20 * Fast checks: is_ssl(), constants, settings.
21 * Slow checks: cert parsing, HTTP redirect probe, mixed content regex.
22 */
23 class Vigilante_SA_Category_SSL {
24
25 const SLUG = 'ssl';
26
27 /**
28 * @var Vigilante_Settings
29 */
30 private $settings;
31
32 public function __construct( Vigilante_Settings $settings ) {
33 $this->settings = $settings;
34 }
35
36 /**
37 * Run the category.
38 *
39 * @param string $phase 'fast' | 'slow' | 'all'.
40 * @return Vigilante_SA_Check_Result[]
41 */
42 public function run( $phase = 'all' ) {
43 $results = array();
44
45 if ( 'fast' === $phase || 'all' === $phase ) {
46 $results[] = $this->check_ssl_present();
47 $results[] = $this->check_force_ssl_admin();
48 }
49
50 if ( 'slow' === $phase || 'all' === $phase ) {
51 $cert = Vigilante_SA_Helpers::probe_tls();
52 $results[] = $this->check_ssl_cert_valid( $cert );
53 $results[] = $this->check_ssl_cert_expiry( $cert );
54 $results[] = $this->check_https_redirect();
55 $results[] = $this->check_tls_version( $cert );
56 $results[] = $this->check_mixed_content();
57 }
58
59 return $results;
60 }
61
62 private function check_ssl_present() {
63 $has_ssl = is_ssl();
64 $home_url = (string) get_option( 'home' );
65 $site_url = (string) get_option( 'siteurl' );
66 $urls_ok = 0 === strpos( $home_url, 'https://' ) && 0 === strpos( $site_url, 'https://' );
67
68 $args = array(
69 'id' => 'ssl_present',
70 'category' => self::SLUG,
71 'max' => 2,
72 'label' => __( 'HTTPS status', 'vigilante' ),
73 'fix_link' => Vigilante_SA_Helpers::build_fix_url( 'headers', 'vigilante-section-headers-force-https' ),
74 );
75
76 if ( $has_ssl && $urls_ok ) {
77 $args['detail'] = __( 'Site uses HTTPS; both home and siteurl URLs start with https://.', 'vigilante' );
78 return Vigilante_SA_Check_Result::pass( $args );
79 }
80 if ( $has_ssl && ! $urls_ok ) {
81 $args['detail'] = __( 'HTTPS is active but one of the WordPress URLs (home/siteurl) still uses http://. Update them under Settings → General.', 'vigilante' );
82 return Vigilante_SA_Check_Result::warn( $args );
83 }
84 $args['detail'] = __( 'HTTPS is not active. Enable a TLS certificate in your host and force HTTPS redirection.', 'vigilante' );
85 return Vigilante_SA_Check_Result::fail( $args );
86 }
87
88 private function check_ssl_cert_valid( $cert ) {
89 $args = array(
90 'id' => 'ssl_cert_valid',
91 'category' => self::SLUG,
92 'max' => 2,
93 'label' => __( 'TLS certificate', 'vigilante' ),
94 'fix_link' => '',
95 );
96
97 if ( empty( $cert ) || empty( $cert['valid'] ) ) {
98 $args['detail'] = __( 'Could not read a valid TLS certificate for the public domain. Check your hosting or CDN.', 'vigilante' );
99 return Vigilante_SA_Check_Result::fail( $args );
100 }
101
102 $now = time();
103 if ( $cert['valid_from'] > 0 && $now < $cert['valid_from'] ) {
104 $args['detail'] = __( 'The certificate is not yet valid (notBefore in the future). Check the server clock.', 'vigilante' );
105 return Vigilante_SA_Check_Result::fail( $args );
106 }
107 if ( $cert['valid_to'] > 0 && $now > $cert['valid_to'] ) {
108 $args['detail'] = __( 'The certificate has expired. Renew it immediately in your host.', 'vigilante' );
109 return Vigilante_SA_Check_Result::fail( $args );
110 }
111
112 $args['detail'] = sprintf(
113 /* translators: 1: issuer CN, 2: subject CN */
114 __( 'Issued by %1$s for %2$s.', 'vigilante' ),
115 $cert['issuer'] ? $cert['issuer'] : __( '(unknown)', 'vigilante' ),
116 $cert['subject'] ? $cert['subject'] : __( '(unknown)', 'vigilante' )
117 );
118 $args['data'] = array(
119 'issuer' => $cert['issuer'],
120 'subject' => $cert['subject'],
121 );
122 return Vigilante_SA_Check_Result::pass( $args );
123 }
124
125 private function check_ssl_cert_expiry( $cert ) {
126 $args = array(
127 'id' => 'ssl_cert_expiry',
128 'category' => self::SLUG,
129 'max' => 2,
130 'label' => __( 'Certificate expiry', 'vigilante' ),
131 'fix_link' => '',
132 );
133
134 if ( empty( $cert ) || empty( $cert['valid'] ) || null === $cert['days_left'] ) {
135 $args['detail'] = __( 'Could not read the certificate expiry.', 'vigilante' );
136 return Vigilante_SA_Check_Result::skip( $args );
137 }
138
139 $days = (int) $cert['days_left'];
140 $args['data'] = array( 'days_left' => $days );
141
142 if ( $days < 0 ) {
143 $args['detail'] = sprintf(
144 /* translators: %d: days since expiry */
145 __( 'The certificate expired %d days ago.', 'vigilante' ),
146 abs( $days )
147 );
148 return Vigilante_SA_Check_Result::fail( $args );
149 }
150
151 if ( $days < 14 ) {
152 $args['detail'] = sprintf(
153 /* translators: %d: days remaining */
154 __( 'The certificate expires in %d days. Schedule renewal now.', 'vigilante' ),
155 $days
156 );
157 return Vigilante_SA_Check_Result::fail( $args );
158 }
159
160 if ( $days < 30 ) {
161 $args['detail'] = sprintf(
162 /* translators: %d: days remaining */
163 __( 'The certificate expires in %d days. Plan renewal soon.', 'vigilante' ),
164 $days
165 );
166 return Vigilante_SA_Check_Result::warn( $args );
167 }
168
169 $args['detail'] = sprintf(
170 /* translators: %d: days remaining */
171 __( '%d days until the certificate expires.', 'vigilante' ),
172 $days
173 );
174 return Vigilante_SA_Check_Result::pass( $args );
175 }
176
177 private function check_https_redirect() {
178 $args = array(
179 'id' => 'https_redirect',
180 'category' => self::SLUG,
181 'max' => 1,
182 'label' => __( 'HTTP → HTTPS redirect', 'vigilante' ),
183 'fix_link' => Vigilante_SA_Helpers::build_fix_url( 'headers', 'vigilante-section-headers-force-https' ),
184 );
185
186 $http_url = set_url_scheme( home_url( '/' ), 'http' );
187 $response = Vigilante_SA_Helpers::get( $http_url, array( 'redirection' => 0 ) );
188
189 if ( is_wp_error( $response ) ) {
190 $args['detail'] = __( 'Could not probe the HTTP version of the site (firewall may be blocking self-requests).', 'vigilante' );
191 return Vigilante_SA_Check_Result::skip( $args );
192 }
193
194 $code = (int) wp_remote_retrieve_response_code( $response );
195 $location = (string) wp_remote_retrieve_header( $response, 'location' );
196
197 if ( $code >= 300 && $code < 400 && 0 === strpos( $location, 'https://' ) ) {
198 $args['detail'] = __( 'HTTP requests received a 3xx redirect to an https:// URL.', 'vigilante' );
199 return Vigilante_SA_Check_Result::pass( $args );
200 }
201
202 $args['detail'] = __( 'The site is reachable over plain HTTP without a redirect to HTTPS.', 'vigilante' );
203 return Vigilante_SA_Check_Result::fail( $args );
204 }
205
206 private function check_tls_version( $cert ) {
207 $args = array(
208 'id' => 'tls_version',
209 'category' => self::SLUG,
210 'max' => 1,
211 'label' => __( 'TLS version', 'vigilante' ),
212 'fix_link' => '',
213 );
214
215 if ( empty( $cert ) || empty( $cert['tls_version'] ) ) {
216 $args['detail'] = __( 'Could not determine the negotiated TLS version.', 'vigilante' );
217 return Vigilante_SA_Check_Result::skip( $args );
218 }
219
220 $version = $cert['tls_version'];
221 $args['data'] = array( 'tls_version' => $version );
222
223 if ( stripos( $version, 'TLSv1.3' ) !== false || stripos( $version, 'TLSv1.2' ) !== false ) {
224 $args['detail'] = sprintf(
225 /* translators: %s: TLS version like TLSv1.3 */
226 __( 'The server negotiated %s.', 'vigilante' ),
227 $version
228 );
229 return Vigilante_SA_Check_Result::pass( $args );
230 }
231
232 $args['detail'] = sprintf(
233 /* translators: %s: TLS version */
234 __( 'The server negotiated %s. Ask your host to disable TLS 1.0/1.1.', 'vigilante' ),
235 $version
236 );
237 return Vigilante_SA_Check_Result::fail( $args );
238 }
239
240 private function check_mixed_content() {
241 $args = array(
242 'id' => 'mixed_content',
243 'category' => self::SLUG,
244 'max' => 2,
245 'label' => __( 'Mixed content on the homepage', 'vigilante' ),
246 'fix_link' => Vigilante_SA_Helpers::build_fix_url( 'headers', 'vigilante-section-headers-main' ),
247 );
248
249 // The fix is applied by the HTTPS enforcer, which only loads with the
250 // Security Headers module on.
251 $fix_setting = (int) ( $this->settings->is_module_enabled( 'security_headers' )
252 && $this->settings->get_option( 'security_headers', 'fix_mixed_content', 0 ) );
253 $probe = Vigilante_SA_Helpers::probe_home();
254
255 if ( null === $probe ) {
256 $args['detail'] = __( 'Could not fetch the homepage to scan for insecure resources.', 'vigilante' );
257 return Vigilante_SA_Check_Result::skip( $args );
258 }
259
260 // Count http:// occurrences in src/href attributes on HTTPS pages only.
261 if ( ! is_ssl() ) {
262 $args['detail'] = __( 'HTTPS is not active yet, so mixed content cannot be evaluated.', 'vigilante' );
263 return Vigilante_SA_Check_Result::skip( $args );
264 }
265
266 // Only count references the browser actually loads as subresources.
267 // A plain <a href="http://..."> is a navigation, not mixed content: the
268 // browser does not flag it and nothing insecure is pulled into the page.
269 // Counting every href made ordinary outbound links to http:// sites read
270 // as insecure content, which is a false positive users cannot act on.
271 $matches = array();
272 preg_match_all( '#src=["\']http://[^"\']+["\']#i', $probe['body'], $matches );
273 $count = isset( $matches[0] ) ? count( $matches[0] ) : 0;
274
275 // <link> only counts when its rel actually fetches something. Values like
276 // canonical, alternate, pingback or EditURI are metadata, not subresources.
277 $link_tags = array();
278 preg_match_all( '#<link\b[^>]*>#i', $probe['body'], $link_tags );
279 foreach ( $link_tags[0] as $link_tag ) {
280 if ( ! preg_match( '#href=["\']http://#i', $link_tag ) ) {
281 continue;
282 }
283 if ( preg_match( '#rel=["\'][^"\']*\b(stylesheet|preload|modulepreload|prefetch|preconnect|icon|manifest)\b#i', $link_tag ) ) {
284 $count++;
285 }
286 }
287
288 // An enforced CSP with upgrade-insecure-requests makes the browser
289 // load every subrequest over HTTPS (external references included),
290 // so remaining http:// strings in the HTML are not mixed content in
291 // practice. Measured on the live response, not on settings: a CDN
292 // that strips the header must not count as protected. Report-Only
293 // policies do not upgrade, hence only the enforced header counts.
294 $csp_header = isset( $probe['headers']['content-security-policy'] ) ? $probe['headers']['content-security-policy'] : '';
295 if ( is_array( $csp_header ) ) {
296 $csp_header = implode( ', ', $csp_header );
297 }
298 $upgrades = ( false !== stripos( (string) $csp_header, 'upgrade-insecure-requests' ) );
299
300 $args['data'] = array(
301 'insecure_refs' => $count,
302 'fix_setting_active' => (bool) $fix_setting,
303 'csp_upgrades' => $upgrades,
304 );
305
306 if ( 0 === $count ) {
307 $args['detail'] = $fix_setting
308 ? __( 'No insecure references in the homepage HTML; mixed-content fix is enabled in settings.', 'vigilante' )
309 : __( 'No insecure references found in the homepage HTML.', 'vigilante' );
310 return Vigilante_SA_Check_Result::pass( $args );
311 }
312
313 if ( $upgrades ) {
314 $args['detail'] = sprintf(
315 /* translators: %d: number of insecure references */
316 __( '%d http:// references in the HTML, but the response sends a CSP upgrade-insecure-requests directive, so browsers load them over HTTPS.', 'vigilante' ),
317 $count
318 );
319 return Vigilante_SA_Check_Result::pass( $args );
320 }
321
322 if ( $fix_setting ) {
323 $args['detail'] = sprintf(
324 /* translators: %d: number of insecure references */
325 __( '%d http:// references still leaked despite the mixed-content fix being enabled. Inspect the theme or a plugin output.', 'vigilante' ),
326 $count
327 );
328 return Vigilante_SA_Check_Result::warn( $args );
329 }
330
331 $args['detail'] = sprintf(
332 /* translators: %d: number of insecure references */
333 __( '%d insecure http:// references found. Enable the mixed-content fix under Security Headers.', 'vigilante' ),
334 $count
335 );
336 return Vigilante_SA_Check_Result::fail( $args );
337 }
338
339 private function check_force_ssl_admin() {
340 $args = array(
341 'id' => 'force_ssl_admin',
342 'category' => self::SLUG,
343 'max' => 2,
344 'label' => __( 'FORCE_SSL_ADMIN constant', 'vigilante' ),
345 'fix_link' => Vigilante_SA_Helpers::build_fix_url( 'wp-hardening', 'field-force-ssl-admin' ),
346 );
347
348 $active = defined( 'FORCE_SSL_ADMIN' ) && FORCE_SSL_ADMIN;
349 if ( $active ) {
350 $args['detail'] = __( 'FORCE_SSL_ADMIN is defined as true; admin and login traffic requires HTTPS.', 'vigilante' );
351 return Vigilante_SA_Check_Result::pass( $args );
352 }
353
354 if ( ! is_ssl() ) {
355 $args['detail'] = __( 'HTTPS is not active yet, so enforcing SSL for the admin area is not applicable.', 'vigilante' );
356 return Vigilante_SA_Check_Result::skip( $args );
357 }
358
359 $args['detail'] = __( 'FORCE_SSL_ADMIN is not set. Enable it from the WP Hardening tab so admin and login cookies only travel over HTTPS.', 'vigilante' );
360 return Vigilante_SA_Check_Result::fail( $args );
361 }
362 }
363