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

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

360 lines 14.6 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 $fix_setting = (int) $this->settings->get_option( 'security_headers', 'fix_mixed_content', 0 );
250 $probe = Vigilante_SA_Helpers::probe_home();
251
252 if ( null === $probe ) {
253 $args['detail'] = __( 'Could not fetch the homepage to scan for insecure resources.', 'vigilante' );
254 return Vigilante_SA_Check_Result::skip( $args );
255 }
256
257 // Count http:// occurrences in src/href attributes on HTTPS pages only.
258 if ( ! is_ssl() ) {
259 $args['detail'] = __( 'HTTPS is not active yet, so mixed content cannot be evaluated.', 'vigilante' );
260 return Vigilante_SA_Check_Result::skip( $args );
261 }
262
263 // Only count references the browser actually loads as subresources.
264 // A plain <a href="http://..."> is a navigation, not mixed content: the
265 // browser does not flag it and nothing insecure is pulled into the page.
266 // Counting every href made ordinary outbound links to http:// sites read
267 // as insecure content, which is a false positive users cannot act on.
268 $matches = array();
269 preg_match_all( '#src=["\']http://[^"\']+["\']#i', $probe['body'], $matches );
270 $count = isset( $matches[0] ) ? count( $matches[0] ) : 0;
271
272 // <link> only counts when its rel actually fetches something. Values like
273 // canonical, alternate, pingback or EditURI are metadata, not subresources.
274 $link_tags = array();
275 preg_match_all( '#<link\b[^>]*>#i', $probe['body'], $link_tags );
276 foreach ( $link_tags[0] as $link_tag ) {
277 if ( ! preg_match( '#href=["\']http://#i', $link_tag ) ) {
278 continue;
279 }
280 if ( preg_match( '#rel=["\'][^"\']*\b(stylesheet|preload|modulepreload|prefetch|preconnect|icon|manifest)\b#i', $link_tag ) ) {
281 $count++;
282 }
283 }
284
285 // An enforced CSP with upgrade-insecure-requests makes the browser
286 // load every subrequest over HTTPS (external references included),
287 // so remaining http:// strings in the HTML are not mixed content in
288 // practice. Measured on the live response, not on settings: a CDN
289 // that strips the header must not count as protected. Report-Only
290 // policies do not upgrade, hence only the enforced header counts.
291 $csp_header = isset( $probe['headers']['content-security-policy'] ) ? $probe['headers']['content-security-policy'] : '';
292 if ( is_array( $csp_header ) ) {
293 $csp_header = implode( ', ', $csp_header );
294 }
295 $upgrades = ( false !== stripos( (string) $csp_header, 'upgrade-insecure-requests' ) );
296
297 $args['data'] = array(
298 'insecure_refs' => $count,
299 'fix_setting_active' => (bool) $fix_setting,
300 'csp_upgrades' => $upgrades,
301 );
302
303 if ( 0 === $count ) {
304 $args['detail'] = $fix_setting
305 ? __( 'No insecure references in the homepage HTML; mixed-content fix is enabled in settings.', 'vigilante' )
306 : __( 'No insecure references found in the homepage HTML.', 'vigilante' );
307 return Vigilante_SA_Check_Result::pass( $args );
308 }
309
310 if ( $upgrades ) {
311 $args['detail'] = sprintf(
312 /* translators: %d: number of insecure references */
313 __( '%d http:// references in the HTML, but the response sends a CSP upgrade-insecure-requests directive, so browsers load them over HTTPS.', 'vigilante' ),
314 $count
315 );
316 return Vigilante_SA_Check_Result::pass( $args );
317 }
318
319 if ( $fix_setting ) {
320 $args['detail'] = sprintf(
321 /* translators: %d: number of insecure references */
322 __( '%d http:// references still leaked despite the mixed-content fix being enabled. Inspect the theme or a plugin output.', 'vigilante' ),
323 $count
324 );
325 return Vigilante_SA_Check_Result::warn( $args );
326 }
327
328 $args['detail'] = sprintf(
329 /* translators: %d: number of insecure references */
330 __( '%d insecure http:// references found. Enable the mixed-content fix under Security Headers.', 'vigilante' ),
331 $count
332 );
333 return Vigilante_SA_Check_Result::fail( $args );
334 }
335
336 private function check_force_ssl_admin() {
337 $args = array(
338 'id' => 'force_ssl_admin',
339 'category' => self::SLUG,
340 'max' => 2,
341 'label' => __( 'FORCE_SSL_ADMIN constant', 'vigilante' ),
342 'fix_link' => Vigilante_SA_Helpers::build_fix_url( 'wp-hardening', 'field-force-ssl-admin' ),
343 );
344
345 $active = defined( 'FORCE_SSL_ADMIN' ) && FORCE_SSL_ADMIN;
346 if ( $active ) {
347 $args['detail'] = __( 'FORCE_SSL_ADMIN is defined as true; admin and login traffic requires HTTPS.', 'vigilante' );
348 return Vigilante_SA_Check_Result::pass( $args );
349 }
350
351 if ( ! is_ssl() ) {
352 $args['detail'] = __( 'HTTPS is not active yet, so enforcing SSL for the admin area is not applicable.', 'vigilante' );
353 return Vigilante_SA_Check_Result::skip( $args );
354 }
355
356 $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' );
357 return Vigilante_SA_Check_Result::fail( $args );
358 }
359 }
360