PluginProbe
Vigilant – 100% Free Security Suite: Firewall, 2FA, Login, Headers, Scanner… / 2.11.8
Vigilant – 100% Free Security Suite: Firewall, 2FA, Login, Headers, Scanner… v2.11.8
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 2.9.4 All 87 releases
vigilante / includes / security-analyzer / class-sa-category-headers.php

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

401 lines 17.0 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Security Analyzer — HTTP headers category (18 pts).
4 *
5 * Reads the actual headers served from the homepage and cross-checks them
6 * with the Vigilante Security Headers settings. When a setting is enabled
7 * but the header is missing in the response the result is a WARN with the
8 * useful "configured but not applied" diagnostic.
9 *
10 * @package Vigilante
11 * @since 2.1.0
12 */
13
14 // Prevent direct access.
15 if ( ! defined( 'ABSPATH' ) ) {
16 exit;
17 }
18
19 /**
20 * HTTP security headers checks.
21 */
22 class Vigilante_SA_Category_Headers {
23
24 const SLUG = 'headers';
25
26 /**
27 * @var Vigilante_Settings
28 */
29 private $settings;
30
31 public function __construct( Vigilante_Settings $settings ) {
32 $this->settings = $settings;
33 }
34
35 /**
36 * Run the category. All checks need the home probe, so they're all in slow phase.
37 *
38 * @param string $phase 'fast' | 'slow' | 'all'.
39 * @return Vigilante_SA_Check_Result[]
40 */
41 public function run( $phase = 'all' ) {
42 if ( 'fast' === $phase ) {
43 return array();
44 }
45
46 $probe = Vigilante_SA_Helpers::probe_home();
47 $headers = ( $probe && isset( $probe['headers'] ) ) ? $probe['headers'] : array();
48 $sec_hdrs = $this->settings->get_section( 'security_headers' );
49
50 $results = array();
51 $results[] = $this->check_csp( $headers, $sec_hdrs );
52 $results[] = $this->check_hsts( $headers, $sec_hdrs );
53 $results[] = $this->check_x_frame( $headers );
54 $results[] = $this->check_x_content_type( $headers );
55 $results[] = $this->check_referrer_policy( $headers );
56 $results[] = $this->check_permissions_policy( $headers );
57 $results[] = $this->check_coop( $headers, $sec_hdrs );
58 $results[] = $this->check_corp( $headers, $sec_hdrs );
59 $results[] = $this->check_server_signature( $headers );
60
61 // If the probe failed entirely, mark all as SKIP.
62 if ( null === $probe ) {
63 foreach ( $results as $r ) {
64 $r->state = Vigilante_SA_Check_Result::STATE_SKIP;
65 $r->score = 0;
66 $r->detail = __( 'Could not fetch the homepage to read response headers.', 'vigilante' );
67 }
68 }
69
70 return $results;
71 }
72
73 private function check_csp( $headers, $sec_hdrs ) {
74 $args = array(
75 'id' => 'csp',
76 'category' => self::SLUG,
77 'max' => 4,
78 'label' => __( 'Content-Security-Policy', 'vigilante' ),
79 'fix_link' => Vigilante_SA_Helpers::build_fix_url( 'headers', 'vigilante-section-headers-csp' ),
80 );
81
82 $setting_on = ! empty( $sec_hdrs['csp']['enabled'] );
83 $header = $this->header_value( $headers, array( 'content-security-policy', 'content-security-policy-report-only' ) );
84
85 if ( $header ) {
86 // A policy is arriving that Vigilant is not sending. Usually another
87 // plugin or the server itself, which is fine. But it is also what a
88 // site in a subfolder gets from the block a Vigilant installed one
89 // directory up left behind, and then the settings screen and the
90 // browser disagree with no way to tell why from here.
91 if ( ! $setting_on ) {
92 $ghosts = $this->vigilant_blocks_above();
93
94 if ( ! empty( $ghosts ) ) {
95 $args['detail'] = sprintf(
96 /* translators: 1: CSP header value (truncated), 2: absolute path of the .htaccess file. */
97 __( 'A Content-Security-Policy is reaching the browser (%1$s) while CSP is off in Vigilant. It comes from a Vigilant block in %2$s, a directory above this site: Apache applies it here too, and this installation neither sees nor controls it. Remove that block, or manage the policy from the site that owns it.', 'vigilante' ),
98 $this->truncate( $header, 60 ),
99 implode( ', ', array_keys( $ghosts ) )
100 );
101 return Vigilante_SA_Check_Result::warn( $args );
102 }
103
104 $args['detail'] = sprintf(
105 /* translators: %s: header value (truncated) */
106 __( 'CSP delivered by something other than Vigilant: %s', 'vigilante' ),
107 $this->truncate( $header, 80 )
108 );
109 return Vigilante_SA_Check_Result::pass( $args );
110 }
111
112 $args['detail'] = sprintf(
113 /* translators: %s: header value (truncated) */
114 __( 'CSP delivered: %s', 'vigilante' ),
115 $this->truncate( $header, 80 )
116 );
117 return Vigilante_SA_Check_Result::pass( $args );
118 }
119
120 if ( $setting_on ) {
121 $args['detail'] = __( 'CSP is enabled in Vigilant but the Content-Security-Policy header is not reaching the browser. A server rule or CDN is likely stripping it.', 'vigilante' );
122 return Vigilante_SA_Check_Result::warn( $args );
123 }
124
125 $args['detail'] = __( 'Content-Security-Policy is not enabled. Turn it on under Security Headers → CSP.', 'vigilante' );
126 return Vigilante_SA_Check_Result::fail( $args );
127 }
128
129 private function check_hsts( $headers, $sec_hdrs ) {
130 $args = array(
131 'id' => 'hsts',
132 'category' => self::SLUG,
133 'max' => 3,
134 'label' => __( 'Strict-Transport-Security', 'vigilante' ),
135 'fix_link' => Vigilante_SA_Helpers::build_fix_url( 'headers', 'vigilante-section-headers-hsts' ),
136 );
137
138 $setting_on = ! empty( $sec_hdrs['hsts']['enabled'] );
139 $header = $this->header_value( $headers, array( 'strict-transport-security' ) );
140
141 if ( $header ) {
142 $args['detail'] = sprintf(
143 /* translators: %s: HSTS header value */
144 __( 'HSTS header value: %s', 'vigilante' ),
145 $header
146 );
147 return Vigilante_SA_Check_Result::pass( $args );
148 }
149
150 if ( $setting_on ) {
151 $args['detail'] = __( 'HSTS is enabled in Vigilant but the header is not being served (often caused by caching layers or HTTP-level servers).', 'vigilante' );
152 return Vigilante_SA_Check_Result::warn( $args );
153 }
154
155 $args['detail'] = __( 'HSTS is disabled. Enable it after confirming HTTPS works correctly to prevent downgrade attacks.', 'vigilante' );
156 return Vigilante_SA_Check_Result::fail( $args );
157 }
158
159 private function check_x_frame( $headers ) {
160 $args = array(
161 'id' => 'x_frame',
162 'category' => self::SLUG,
163 'max' => 2,
164 'label' => __( 'X-Frame-Options', 'vigilante' ),
165 'fix_link' => Vigilante_SA_Helpers::build_fix_url( 'headers', 'vigilante-section-headers-main' ),
166 );
167
168 $header = $this->header_value( $headers, array( 'x-frame-options' ) );
169 if ( $header ) {
170 $args['detail'] = sprintf(
171 /* translators: %s: x-frame-options value */
172 __( 'X-Frame-Options header value: %s', 'vigilante' ),
173 $header
174 );
175 return Vigilante_SA_Check_Result::pass( $args );
176 }
177
178 $args['detail'] = __( 'X-Frame-Options header is missing. Attackers could embed your site in an iframe for click-jacking.', 'vigilante' );
179 return Vigilante_SA_Check_Result::fail( $args );
180 }
181
182 private function check_x_content_type( $headers ) {
183 $args = array(
184 'id' => 'x_content_type',
185 'category' => self::SLUG,
186 'max' => 2,
187 'label' => __( 'X-Content-Type-Options', 'vigilante' ),
188 'fix_link' => Vigilante_SA_Helpers::build_fix_url( 'headers', 'vigilante-section-headers-main' ),
189 );
190
191 $header = $this->header_value( $headers, array( 'x-content-type-options' ) );
192 if ( $header && stripos( $header, 'nosniff' ) !== false ) {
193 $args['detail'] = __( 'X-Content-Type-Options header set to nosniff.', 'vigilante' );
194 return Vigilante_SA_Check_Result::pass( $args );
195 }
196
197 $args['detail'] = __( 'X-Content-Type-Options: nosniff is missing. The browser may interpret files as a type other than declared.', 'vigilante' );
198 return Vigilante_SA_Check_Result::fail( $args );
199 }
200
201 private function check_referrer_policy( $headers ) {
202 $args = array(
203 'id' => 'referrer_policy',
204 'category' => self::SLUG,
205 'max' => 2,
206 'label' => __( 'Referrer-Policy', 'vigilante' ),
207 'fix_link' => Vigilante_SA_Helpers::build_fix_url( 'headers', 'vigilante-section-headers-main' ),
208 );
209
210 $header = $this->header_value( $headers, array( 'referrer-policy' ) );
211 if ( $header ) {
212 $args['detail'] = sprintf(
213 /* translators: %s: referrer policy value */
214 __( 'Referrer-Policy: %s', 'vigilante' ),
215 $header
216 );
217 return Vigilante_SA_Check_Result::pass( $args );
218 }
219
220 $args['detail'] = __( 'Referrer-Policy header is missing. The browser decides what to leak in the Referer by default.', 'vigilante' );
221 return Vigilante_SA_Check_Result::fail( $args );
222 }
223
224 private function check_permissions_policy( $headers ) {
225 $args = array(
226 'id' => 'permissions_policy',
227 'category' => self::SLUG,
228 'max' => 2,
229 'label' => __( 'Permissions-Policy', 'vigilante' ),
230 'fix_link' => Vigilante_SA_Helpers::build_fix_url( 'headers', 'vigilante-section-headers-main' ),
231 );
232
233 $header = $this->header_value( $headers, array( 'permissions-policy', 'feature-policy' ) );
234 if ( $header ) {
235 $args['detail'] = sprintf(
236 /* translators: %s: header value (truncated) */
237 __( 'Permissions-Policy header value: %s', 'vigilante' ),
238 $this->truncate( $header, 80 )
239 );
240 return Vigilante_SA_Check_Result::pass( $args );
241 }
242
243 $args['detail'] = __( 'Permissions-Policy is missing. Browsers allow all capabilities (geolocation, camera, etc.) by default.', 'vigilante' );
244 return Vigilante_SA_Check_Result::fail( $args );
245 }
246
247 /**
248 * Cross-Origin-Opener-Policy.
249 *
250 * Switched off on purpose from the Headers tab is a legitimate configuration,
251 * not a finding: COOP severs the window.opener link when another origin opens
252 * this site, which is exactly what external tools such as Google Tag Assistant
253 * rely on. Those runs report INFO so the check stays visible without dragging
254 * the score down. WARN could not do that job: with a max of 1 it scores
255 * floor( 1 / 2 ) = 0, the very same zero as a FAIL, and the grade scale keeps
256 * "A" for a perfect 100/100.
257 *
258 * @param array $headers Response headers from the home page probe.
259 * @param array $sec_hdrs The security_headers settings section.
260 * @return Vigilante_SA_Check_Result
261 */
262 private function check_coop( $headers, $sec_hdrs ) {
263 $args = array(
264 'id' => 'coop',
265 'category' => self::SLUG,
266 'max' => 1,
267 'label' => __( 'Cross-Origin-Opener-Policy', 'vigilante' ),
268 'fix_link' => Vigilante_SA_Helpers::build_fix_url( 'headers', 'vigilante-section-headers-cross-origin' ),
269 );
270
271 $header = $this->header_value( $headers, array( 'cross-origin-opener-policy' ) );
272
273 if ( $header && 'unsafe-none' !== strtolower( trim( $header ) ) ) {
274 /* translators: %s: value of the Cross-Origin-Opener-Policy HTTP header */
275 $args['detail'] = sprintf( __( 'COOP: %s', 'vigilante' ), $header );
276 return Vigilante_SA_Check_Result::pass( $args );
277 }
278
279 // No header at all, or an explicit unsafe-none. Was that this site's choice?
280 $policies = ( isset( $sec_hdrs['cross_origin_policies'] ) && is_array( $sec_hdrs['cross_origin_policies'] ) ) ? $sec_hdrs['cross_origin_policies'] : array();
281 $configured = isset( $policies['opener_policy'] ) ? (string) $policies['opener_policy'] : '';
282
283 if ( '' === $configured || 'unsafe-none' === $configured ) {
284 $args['detail'] = __( 'Cross-Origin-Opener-Policy is switched off in Vigilant. Windows opened by other sites keep their link to yours, which is what external tools such as Google Tag Assistant need. Not scored.', 'vigilante' );
285 return Vigilante_SA_Check_Result::info( $args );
286 }
287
288 $args['detail'] = __( 'Cross-Origin-Opener-Policy is missing. Recommended to isolate the browsing context.', 'vigilante' );
289 return Vigilante_SA_Check_Result::fail( $args );
290 }
291
292 /**
293 * Cross-Origin-Resource-Policy. Same reasoning as check_coop() for the INFO state.
294 *
295 * @param array $headers Response headers from the home page probe.
296 * @param array $sec_hdrs The security_headers settings section.
297 * @return Vigilante_SA_Check_Result
298 */
299 private function check_corp( $headers, $sec_hdrs ) {
300 $args = array(
301 'id' => 'corp',
302 'category' => self::SLUG,
303 'max' => 1,
304 'label' => __( 'Cross-Origin-Resource-Policy', 'vigilante' ),
305 'fix_link' => Vigilante_SA_Helpers::build_fix_url( 'headers', 'vigilante-section-headers-cross-origin' ),
306 );
307
308 $header = $this->header_value( $headers, array( 'cross-origin-resource-policy' ) );
309 if ( $header ) {
310 /* translators: %s: value of the Cross-Origin-Resource-Policy HTTP header */
311 $args['detail'] = sprintf( __( 'CORP: %s', 'vigilante' ), $header );
312 return Vigilante_SA_Check_Result::pass( $args );
313 }
314
315 $policies = ( isset( $sec_hdrs['cross_origin_policies'] ) && is_array( $sec_hdrs['cross_origin_policies'] ) ) ? $sec_hdrs['cross_origin_policies'] : array();
316 $configured = isset( $policies['resource_policy'] ) ? (string) $policies['resource_policy'] : '';
317
318 if ( '' === $configured ) {
319 $args['detail'] = __( 'Cross-Origin-Resource-Policy is switched off in Vigilant, so other sites may load your images and files as usual. Not scored.', 'vigilante' );
320 return Vigilante_SA_Check_Result::info( $args );
321 }
322
323 $args['detail'] = __( 'Cross-Origin-Resource-Policy is missing. Cross-origin fetches are unrestricted.', 'vigilante' );
324 return Vigilante_SA_Check_Result::fail( $args );
325 }
326
327 private function check_server_signature( $headers ) {
328 $args = array(
329 'id' => 'server_signature',
330 'category' => self::SLUG,
331 'max' => 1,
332 'label' => __( 'Server fingerprint exposure', 'vigilante' ),
333 'fix_link' => Vigilante_SA_Helpers::build_fix_url( 'headers', 'vigilante-section-headers-fingerprint' ),
334 );
335
336 $server = $this->header_value( $headers, array( 'server' ) );
337 $x_powered = $this->header_value( $headers, array( 'x-powered-by' ) );
338 $leaks = array();
339
340 if ( $server && preg_match( '#[\d\.]+#', $server ) ) {
341 $leaks[] = 'Server: ' . $server;
342 }
343 if ( $x_powered ) {
344 $leaks[] = 'X-Powered-By: ' . $x_powered;
345 }
346
347 if ( empty( $leaks ) ) {
348 $args['detail'] = __( 'No server or PHP version information found in response headers.', 'vigilante' );
349 return Vigilante_SA_Check_Result::pass( $args );
350 }
351
352 $args['detail'] = sprintf(
353 /* translators: %s: comma-separated leaked headers */
354 __( 'Version information leaked through response headers: %s', 'vigilante' ),
355 implode( ' | ', $leaks )
356 );
357 return Vigilante_SA_Check_Result::fail( $args );
358 }
359
360 /**
361 * Read a header value from the normalized map, trying multiple key variants.
362 *
363 * @param array<string,string> $headers Normalized lowercase headers.
364 * @param string[] $keys Candidate keys.
365 * @return string Value or empty string.
366 */
367 private function header_value( $headers, $keys ) {
368 foreach ( $keys as $key ) {
369 $k = strtolower( $key );
370 if ( isset( $headers[ $k ] ) && '' !== trim( $headers[ $k ] ) ) {
371 return $headers[ $k ];
372 }
373 }
374 return '';
375 }
376
377 private function truncate( $text, $len ) {
378 $text = (string) $text;
379 if ( strlen( $text ) <= $len ) {
380 return $text;
381 }
382 return substr( $text, 0, $len ) . '';
383 }
384
385 /**
386 * Vigilant blocks found in .htaccess files above this installation
387 *
388 * @since 2.9.9
389 *
390 * @return array<string,string[]> Absolute file path => markers found inside.
391 */
392 private function vigilant_blocks_above() {
393 if ( ! class_exists( 'Vigilante_Htaccess_Manager' ) ) {
394 require_once VIGILANTE_INCLUDES_DIR . 'class-htaccess-manager.php';
395 }
396
397 return Vigilante_Htaccess_Manager::get_instance()->find_blocks_above();
398 }
399
400 }
401