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-headers.php

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

716 lines 29.5 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 what Vigilant writes to .htaccess. A header that Vigilant sends and
7 * that does not reach the browser fails, saying so and why: the points are
8 * for what arrives.
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 /**
32 * Whether the Security Headers module is on, set by run()
33 *
34 * @var bool
35 */
36 private $module_on = true;
37
38 /**
39 * Vigilant blocks found in the .htaccess files above this site, once read
40 *
41 * @var array|null
42 */
43 private $blocks_above = null;
44
45 /**
46 * Names of the headers the block of Vigilant sets, in lowercase, set by run()
47 *
48 * @var string[]
49 */
50 private $written = array();
51
52 /**
53 * Why a header that Vigilant sends is not arriving, as a sentence
54 *
55 * @var string
56 */
57 private $cause = '';
58
59 /**
60 * Whether that cause is the whole block not being applied (and not one
61 * header being removed on the way)
62 *
63 * @var bool
64 */
65 private $block_not_applied = false;
66
67 /**
68 * Whether some check found a header that Vigilant sends and does not
69 * arrive, so the cause has to be said
70 *
71 * @var bool
72 */
73 private $cause_needed = false;
74
75 public function __construct( Vigilante_Settings $settings ) {
76 $this->settings = $settings;
77 }
78
79 /**
80 * Run the category. All checks need the home probe, so they're all in slow phase.
81 *
82 * @param string $phase 'fast' | 'slow' | 'all'.
83 * @return Vigilante_SA_Check_Result[]
84 */
85 public function run( $phase = 'all' ) {
86 if ( 'fast' === $phase ) {
87 return array();
88 }
89
90 $probe = Vigilante_SA_Helpers::probe_home();
91 $headers = ( $probe && isset( $probe['headers'] ) ) ? $probe['headers'] : array();
92 $sec_hdrs = $this->settings->get_section( 'security_headers' );
93
94 // The headers are written to .htaccess only while the Security Headers
95 // module is on, and taken out when it is switched off. What is set in
96 // its tab is then saved but not sent, and until 3.0.4 these checks read
97 // "enabled in Vigilant but not reaching the browser" and gave half the
98 // points for it.
99 $this->module_on = $this->settings->is_module_enabled( 'security_headers' );
100 $this->note_what_vigilant_sends( $headers, $probe ? (int) $probe['code'] : 0 );
101
102 $results = array();
103 $results[] = $this->check_csp( $headers, $sec_hdrs );
104 $results[] = $this->check_hsts( $headers, $sec_hdrs );
105 $results[] = $this->check_x_frame( $headers );
106 $results[] = $this->check_x_content_type( $headers );
107 $results[] = $this->check_referrer_policy( $headers );
108 $results[] = $this->check_permissions_policy( $headers );
109 $results[] = $this->check_coop( $headers, $sec_hdrs );
110 $results[] = $this->check_corp( $headers, $sec_hdrs );
111 $results[] = $this->check_server_signature( $headers );
112
113 // If the probe failed entirely, mark all as SKIP.
114 if ( null === $probe ) {
115 foreach ( $results as $r ) {
116 $r->state = Vigilante_SA_Check_Result::STATE_SKIP;
117 $r->score = 0;
118 $r->detail = __( 'Could not fetch the homepage to read response headers.', 'vigilante' );
119 }
120
121 return $results;
122 }
123
124 $this->note_headers_from_above( $results );
125
126 // The reason is the same for every header of the block, so it is said
127 // once, in a row of its own that scores nothing, and not after each
128 // of them: nine rows ending in the same three lines were not read.
129 if ( $this->cause_needed && '' !== $this->cause ) {
130 array_unshift(
131 $results,
132 Vigilante_SA_Check_Result::info(
133 array(
134 'id' => 'headers_not_arriving',
135 'category' => self::SLUG,
136 'label' => __( 'Why these headers do not arrive', 'vigilante' ),
137 'detail' => $this->cause,
138 )
139 )
140 );
141 }
142
143 return $results;
144 }
145
146 /**
147 * Say where a header comes from when this site is not the one sending it
148 *
149 * A WordPress in a subfolder also receives the headers of the block that
150 * the Vigilant of the installation above keeps in its own .htaccess:
151 * Apache applies every .htaccess on the way down. With the module off here
152 * the header still arrives, the check passes, and nothing on this screen
153 * explained why switching the module off changed nothing. The CSP check
154 * has said so since 2.9.9; this does it for the rest.
155 *
156 * @since 3.0.5
157 *
158 * @param Vigilante_SA_Check_Result[] $results Results of this category.
159 */
160 private function note_headers_from_above( array $results ) {
161 if ( $this->module_on ) {
162 return;
163 }
164
165 $files = array();
166 foreach ( $this->vigilant_blocks_above() as $file => $markers ) {
167 foreach ( (array) $markers as $marker ) {
168 if ( false !== strpos( (string) $marker, 'Security Headers' ) ) {
169 $files[] = $file;
170 break;
171 }
172 }
173 }
174
175 if ( empty( $files ) ) {
176 return;
177 }
178
179 $note = sprintf(
180 /* translators: %s: absolute path of one or more .htaccess files */
181 __( 'This site is not sending it: the Security Headers module is off here. A Vigilant block in %s, a directory above this site, applies here too, and this installation neither sees nor controls it.', 'vigilante' ),
182 implode( ', ', $files )
183 );
184
185 $from_above = array( 'hsts', 'x_frame', 'x_content_type', 'referrer_policy', 'permissions_policy', 'coop', 'corp' );
186
187 foreach ( $results as $r ) {
188 if ( Vigilante_SA_Check_Result::STATE_PASS === $r->state && in_array( $r->id, $from_above, true ) ) {
189 $r->detail .= ' ' . $note;
190 }
191 }
192 }
193
194 /**
195 * What the block of Vigilant sets, and why it may not be arriving
196 *
197 * Read from the rules Vigilant writes and not from the settings one by
198 * one, because the rules are what the server gets. Until 3.0.4 only the
199 * CSP and HSTS checks said "switched on but not arriving", with half the
200 * points; the other six said that the header was missing, next to a link
201 * to a setting that was already on.
202 *
203 * The cause is the likely one and is worded as such. It is looked for in
204 * this order, each step being what the next one takes for granted: the
205 * rules are in .htaccess, the home page answered with a normal page, and
206 * only then whether other headers of the same block arrive. A header
207 * that arrives may come from the hosting and not from Vigilant, so with
208 * the rules missing from .htaccess nothing is said about them being
209 * applied, and an error page from a layer in between is not read as a
210 * server that lacks a module.
211 *
212 * @since 3.0.5
213 *
214 * @param array $headers Response headers from the home page probe.
215 * @param int $code HTTP status of that answer, 0 when there was none.
216 */
217 private function note_what_vigilant_sends( $headers, $code = 0 ) {
218 $this->written = array();
219 $this->cause = '';
220 $this->block_not_applied = false;
221 $this->cause_needed = false;
222
223 if ( ! $this->module_on || ! class_exists( 'Vigilante_Security_Headers' ) ) {
224 return;
225 }
226
227 $sender = new Vigilante_Security_Headers( $this->settings );
228 if ( preg_match_all( '/^\s*Header\s+(?:always\s+)?set\s+([A-Za-z-]+)\s/mi', (string) $sender->generate_rules_content(), $found ) ) {
229 $this->written = array_values( array_unique( array_map( 'strtolower', $found[1] ) ) );
230 }
231 if ( empty( $this->written ) ) {
232 return;
233 }
234
235 $arriving = 0;
236 foreach ( $this->written as $name ) {
237 if ( '' !== $this->header_value( $headers, array( $name ) ) ) {
238 ++$arriving;
239 }
240 }
241
242 $this->block_not_applied = ( 0 === $arriving );
243
244 if ( ! $sender->are_rules_active() ) {
245 $this->block_not_applied = true;
246 $this->cause = __( 'The rules of Vigilant are not in .htaccess: this server may not read that file, as nginx does not, or the file could not be written.', 'vigilante' );
247 return;
248 }
249
250 if ( $code > 0 && ( $code < 200 || $code > 299 ) ) {
251 $this->cause = sprintf(
252 /* translators: %d: HTTP status code, such as 503 */
253 __( 'The home page answered with HTTP %d instead of a normal page, and that answer may come from a layer that does not add these headers: look into that first.', 'vigilante' ),
254 $code
255 );
256 return;
257 }
258
259 if ( $arriving > 0 ) {
260 // Counted by name: whether they come from this block or from
261 // the hosting cannot be told from the answer, hence "likely".
262 $this->cause = __( 'Headers that Vigilant also sets do arrive, so the likely cause is something removing the others on the way: a server rule, a caching layer or a CDN.', 'vigilante' );
263 return;
264 }
265
266 $this->cause = __( 'None of the headers Vigilant sends is arriving, although its rules are in .htaccess, so the server is not applying them. On Apache that usually means that its mod_headers module is not enabled: ask your hosting.', 'vigilante' );
267 }
268
269 /**
270 * The result for a header that Vigilant sends and that does not arrive
271 *
272 * It says only that, and run() adds the cause once for all of them. It
273 * carries no link: the setting it would lead to is already on.
274 *
275 * @since 3.0.5
276 *
277 * @param array $args Arguments of the check, with its label.
278 * @param string[] $names Names the header can travel under.
279 * @return Vigilante_SA_Check_Result|null Null when Vigilant is not sending it.
280 */
281 private function sent_but_missing( $args, $names ) {
282 foreach ( (array) $names as $name ) {
283 if ( in_array( strtolower( (string) $name ), $this->written, true ) ) {
284 $this->cause_needed = true;
285 unset( $args['fix_link'] );
286 $args['detail'] = sprintf(
287 /* translators: %s: name of an HTTP header, such as X-Frame-Options */
288 __( '%s is switched on in Vigilant, but the header does not reach the browser.', 'vigilante' ),
289 $args['label']
290 );
291 return Vigilante_SA_Check_Result::fail( $args );
292 }
293 }
294 return null;
295 }
296
297 private function check_csp( $headers, $sec_hdrs ) {
298 $args = array(
299 'id' => 'csp',
300 'category' => self::SLUG,
301 'max' => 4,
302 'label' => __( 'Content-Security-Policy', 'vigilante' ),
303 'fix_link' => Vigilante_SA_Helpers::build_fix_url( 'headers', 'vigilante-section-headers-csp' ),
304 );
305
306 $saved_on = ! empty( $sec_hdrs['csp']['enabled'] );
307 $setting_on = $saved_on && $this->module_on;
308 $header = $this->header_value( $headers, array( 'content-security-policy', 'content-security-policy-report-only' ) );
309
310 if ( $header ) {
311 // A policy is arriving that Vigilant is not sending. Usually another
312 // plugin or the server itself, which is fine. But it is also what a
313 // site in a subfolder gets from the block a Vigilant installed one
314 // directory up left behind, and then the settings screen and the
315 // browser disagree with no way to tell why from here.
316 if ( ! $setting_on ) {
317 $ghosts = $this->vigilant_blocks_above();
318
319 if ( ! empty( $ghosts ) ) {
320 $args['detail'] = sprintf(
321 /* translators: 1: CSP header value (truncated), 2: absolute path of the .htaccess file. */
322 __( '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' ),
323 $this->truncate( $header, 60 ),
324 implode( ', ', array_keys( $ghosts ) )
325 );
326 return Vigilante_SA_Check_Result::warn( $args );
327 }
328
329 $args['detail'] = sprintf(
330 /* translators: %s: header value (truncated) */
331 __( 'CSP delivered by something other than Vigilant: %s', 'vigilante' ),
332 $this->truncate( $header, 80 )
333 );
334 return Vigilante_SA_Check_Result::pass( $args );
335 }
336
337 $args['detail'] = sprintf(
338 /* translators: %s: header value (truncated) */
339 __( 'CSP delivered: %s', 'vigilante' ),
340 $this->truncate( $header, 80 )
341 );
342 return Vigilante_SA_Check_Result::pass( $args );
343 }
344
345 $missing = $this->sent_but_missing( $args, array( 'content-security-policy', 'content-security-policy-report-only' ) );
346 if ( $missing ) {
347 return $missing;
348 }
349
350 if ( $setting_on ) {
351 // Switched on with a policy that has nothing in it, and with no
352 // X-Frame-Options to turn into frame-ancestors: no line is written
353 // (Vigilante_Security_Headers::generate_rules_content()). Until
354 // 3.0.4 this was a warning with half the points that blamed a
355 // server rule or a CDN.
356 $args['detail'] = __( 'CSP is switched on in Vigilant, but its policy has no directives, so no header is written for it.', 'vigilante' );
357 return Vigilante_SA_Check_Result::fail( $args );
358 }
359
360 if ( $saved_on ) {
361 $args['detail'] = Vigilante_SA_Helpers::module_off_detail( $this->settings, 'security_headers' );
362 return Vigilante_SA_Check_Result::fail( $args );
363 }
364
365 $args['detail'] = __( 'Content-Security-Policy is not enabled. Turn it on under Security Headers → CSP.', 'vigilante' );
366 return Vigilante_SA_Check_Result::fail( $args );
367 }
368
369 private function check_hsts( $headers, $sec_hdrs ) {
370 $args = array(
371 'id' => 'hsts',
372 'category' => self::SLUG,
373 'max' => 3,
374 'label' => __( 'Strict-Transport-Security', 'vigilante' ),
375 'fix_link' => Vigilante_SA_Helpers::build_fix_url( 'headers', 'vigilante-section-headers-hsts' ),
376 );
377
378 $saved_on = ! empty( $sec_hdrs['hsts']['enabled'] );
379 $setting_on = $saved_on && $this->module_on;
380 $header = $this->header_value( $headers, array( 'strict-transport-security' ) );
381
382 if ( $header ) {
383 $args['detail'] = sprintf(
384 /* translators: %s: HSTS header value */
385 __( 'HSTS header value: %s', 'vigilante' ),
386 $header
387 );
388 return Vigilante_SA_Check_Result::pass( $args );
389 }
390
391 $missing = $this->sent_but_missing( $args, array( 'strict-transport-security' ) );
392 if ( $missing ) {
393 return $missing;
394 }
395
396 if ( $setting_on ) {
397 // Not reached while the rules are what they are today: with HSTS
398 // on a line is always written, and sent_but_missing() answered
399 // above. Kept so that a header that is on and does not arrive
400 // never scores, whatever the rules do tomorrow.
401 $args['detail'] = sprintf(
402 /* translators: %s: name of an HTTP header, such as X-Frame-Options */
403 __( '%s is switched on in Vigilant, but the header does not reach the browser.', 'vigilante' ),
404 $args['label']
405 );
406 return Vigilante_SA_Check_Result::fail( $args );
407 }
408
409 if ( $saved_on ) {
410 $args['detail'] = Vigilante_SA_Helpers::module_off_detail( $this->settings, 'security_headers' );
411 return Vigilante_SA_Check_Result::fail( $args );
412 }
413
414 $args['detail'] = __( 'HSTS is disabled. Enable it after confirming HTTPS works correctly to prevent downgrade attacks.', 'vigilante' );
415 return Vigilante_SA_Check_Result::fail( $args );
416 }
417
418 private function check_x_frame( $headers ) {
419 $args = array(
420 'id' => 'x_frame',
421 'category' => self::SLUG,
422 'max' => 2,
423 'label' => __( 'X-Frame-Options', 'vigilante' ),
424 'fix_link' => Vigilante_SA_Helpers::build_fix_url( 'headers', 'vigilante-section-headers-main' ),
425 );
426
427 $header = $this->header_value( $headers, array( 'x-frame-options' ) );
428 if ( $header ) {
429 $args['detail'] = sprintf(
430 /* translators: %s: x-frame-options value */
431 __( 'X-Frame-Options header value: %s', 'vigilante' ),
432 $header
433 );
434 return Vigilante_SA_Check_Result::pass( $args );
435 }
436
437 $missing = $this->sent_but_missing( $args, array( 'x-frame-options' ) );
438 if ( $missing ) {
439 return $missing;
440 }
441
442 $args['detail'] = __( 'X-Frame-Options header is missing. Attackers could embed your site in an iframe for click-jacking.', 'vigilante' );
443 return Vigilante_SA_Check_Result::fail( $args );
444 }
445
446 private function check_x_content_type( $headers ) {
447 $args = array(
448 'id' => 'x_content_type',
449 'category' => self::SLUG,
450 'max' => 2,
451 'label' => __( 'X-Content-Type-Options', 'vigilante' ),
452 'fix_link' => Vigilante_SA_Helpers::build_fix_url( 'headers', 'vigilante-section-headers-main' ),
453 );
454
455 $header = $this->header_value( $headers, array( 'x-content-type-options' ) );
456 if ( $header && stripos( $header, 'nosniff' ) !== false ) {
457 $args['detail'] = __( 'X-Content-Type-Options header set to nosniff.', 'vigilante' );
458 return Vigilante_SA_Check_Result::pass( $args );
459 }
460
461 $missing = $header ? null : $this->sent_but_missing( $args, array( 'x-content-type-options' ) );
462 if ( $missing ) {
463 return $missing;
464 }
465
466 $args['detail'] = __( 'X-Content-Type-Options: nosniff is missing. The browser may interpret files as a type other than declared.', 'vigilante' );
467 return Vigilante_SA_Check_Result::fail( $args );
468 }
469
470 private function check_referrer_policy( $headers ) {
471 $args = array(
472 'id' => 'referrer_policy',
473 'category' => self::SLUG,
474 'max' => 2,
475 'label' => __( 'Referrer-Policy', 'vigilante' ),
476 'fix_link' => Vigilante_SA_Helpers::build_fix_url( 'headers', 'vigilante-section-headers-main' ),
477 );
478
479 $header = $this->header_value( $headers, array( 'referrer-policy' ) );
480 if ( $header ) {
481 $args['detail'] = sprintf(
482 /* translators: %s: referrer policy value */
483 __( 'Referrer-Policy: %s', 'vigilante' ),
484 $header
485 );
486 return Vigilante_SA_Check_Result::pass( $args );
487 }
488
489 $missing = $this->sent_but_missing( $args, array( 'referrer-policy' ) );
490 if ( $missing ) {
491 return $missing;
492 }
493
494 $args['detail'] = __( 'Referrer-Policy header is missing. The browser decides what to leak in the Referer by default.', 'vigilante' );
495 return Vigilante_SA_Check_Result::fail( $args );
496 }
497
498 private function check_permissions_policy( $headers ) {
499 $args = array(
500 'id' => 'permissions_policy',
501 'category' => self::SLUG,
502 'max' => 2,
503 'label' => __( 'Permissions-Policy', 'vigilante' ),
504 'fix_link' => Vigilante_SA_Helpers::build_fix_url( 'headers', 'vigilante-section-headers-main' ),
505 );
506
507 $header = $this->header_value( $headers, array( 'permissions-policy', 'feature-policy' ) );
508 if ( $header ) {
509 $args['detail'] = sprintf(
510 /* translators: %s: header value (truncated) */
511 __( 'Permissions-Policy header value: %s', 'vigilante' ),
512 $this->truncate( $header, 80 )
513 );
514 return Vigilante_SA_Check_Result::pass( $args );
515 }
516
517 $missing = $this->sent_but_missing( $args, array( 'permissions-policy' ) );
518 if ( $missing ) {
519 return $missing;
520 }
521
522 $args['detail'] = __( 'Permissions-Policy is missing. Browsers allow all capabilities (geolocation, camera, etc.) by default.', 'vigilante' );
523 return Vigilante_SA_Check_Result::fail( $args );
524 }
525
526 /**
527 * Cross-Origin-Opener-Policy.
528 *
529 * Switched off on purpose from the Headers tab is a legitimate configuration,
530 * not a finding: COOP severs the window.opener link when another origin opens
531 * this site, which is exactly what external tools such as Google Tag Assistant
532 * rely on. Those runs report INFO so the check stays visible without dragging
533 * the score down. WARN could not do that job: with a max of 1 it scores
534 * floor( 1 / 2 ) = 0, the very same zero as a FAIL, and the grade scale keeps
535 * "A" for a perfect 100/100.
536 *
537 * @param array $headers Response headers from the home page probe.
538 * @param array $sec_hdrs The security_headers settings section.
539 * @return Vigilante_SA_Check_Result
540 */
541 private function check_coop( $headers, $sec_hdrs ) {
542 $args = array(
543 'id' => 'coop',
544 'category' => self::SLUG,
545 'max' => 1,
546 'label' => __( 'Cross-Origin-Opener-Policy', 'vigilante' ),
547 'fix_link' => Vigilante_SA_Helpers::build_fix_url( 'headers', 'vigilante-section-headers-cross-origin' ),
548 );
549
550 $header = $this->header_value( $headers, array( 'cross-origin-opener-policy' ) );
551
552 if ( $header && 'unsafe-none' !== strtolower( trim( $header ) ) ) {
553 /* translators: %s: value of the Cross-Origin-Opener-Policy HTTP header */
554 $args['detail'] = sprintf( __( 'COOP: %s', 'vigilante' ), $header );
555 return Vigilante_SA_Check_Result::pass( $args );
556 }
557
558 // No header at all, or an explicit unsafe-none. Was that this site's choice?
559 $policies = ( isset( $sec_hdrs['cross_origin_policies'] ) && is_array( $sec_hdrs['cross_origin_policies'] ) ) ? $sec_hdrs['cross_origin_policies'] : array();
560 $configured = isset( $policies['opener_policy'] ) ? (string) $policies['opener_policy'] : '';
561
562 if ( '' === $configured || 'unsafe-none' === $configured ) {
563 $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' );
564 return Vigilante_SA_Check_Result::info( $args );
565 }
566
567 if ( ! $this->module_on ) {
568 $args['detail'] = Vigilante_SA_Helpers::module_off_detail( $this->settings, 'security_headers' );
569 return Vigilante_SA_Check_Result::fail( $args );
570 }
571
572 $missing = $header ? null : $this->sent_but_missing( $args, array( 'cross-origin-opener-policy' ) );
573 if ( $missing ) {
574 return $missing;
575 }
576
577 $args['detail'] = __( 'Cross-Origin-Opener-Policy is missing. Recommended to isolate the browsing context.', 'vigilante' );
578 return Vigilante_SA_Check_Result::fail( $args );
579 }
580
581 /**
582 * Cross-Origin-Resource-Policy. Same reasoning as check_coop() for the INFO state.
583 *
584 * @param array $headers Response headers from the home page probe.
585 * @param array $sec_hdrs The security_headers settings section.
586 * @return Vigilante_SA_Check_Result
587 */
588 private function check_corp( $headers, $sec_hdrs ) {
589 $args = array(
590 'id' => 'corp',
591 'category' => self::SLUG,
592 'max' => 1,
593 'label' => __( 'Cross-Origin-Resource-Policy', 'vigilante' ),
594 'fix_link' => Vigilante_SA_Helpers::build_fix_url( 'headers', 'vigilante-section-headers-cross-origin' ),
595 );
596
597 $header = $this->header_value( $headers, array( 'cross-origin-resource-policy' ) );
598 if ( $header ) {
599 /* translators: %s: value of the Cross-Origin-Resource-Policy HTTP header */
600 $args['detail'] = sprintf( __( 'CORP: %s', 'vigilante' ), $header );
601 return Vigilante_SA_Check_Result::pass( $args );
602 }
603
604 $policies = ( isset( $sec_hdrs['cross_origin_policies'] ) && is_array( $sec_hdrs['cross_origin_policies'] ) ) ? $sec_hdrs['cross_origin_policies'] : array();
605 $configured = isset( $policies['resource_policy'] ) ? (string) $policies['resource_policy'] : '';
606
607 if ( '' === $configured ) {
608 $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' );
609 return Vigilante_SA_Check_Result::info( $args );
610 }
611
612 if ( ! $this->module_on ) {
613 $args['detail'] = Vigilante_SA_Helpers::module_off_detail( $this->settings, 'security_headers' );
614 return Vigilante_SA_Check_Result::fail( $args );
615 }
616
617 $missing = $this->sent_but_missing( $args, array( 'cross-origin-resource-policy' ) );
618 if ( $missing ) {
619 return $missing;
620 }
621
622 $args['detail'] = __( 'Cross-Origin-Resource-Policy is missing. Cross-origin fetches are unrestricted.', 'vigilante' );
623 return Vigilante_SA_Check_Result::fail( $args );
624 }
625
626 private function check_server_signature( $headers ) {
627 $args = array(
628 'id' => 'server_signature',
629 'category' => self::SLUG,
630 'max' => 1,
631 'label' => __( 'Server fingerprint exposure', 'vigilante' ),
632 'fix_link' => Vigilante_SA_Helpers::build_fix_url( 'headers', 'vigilante-section-headers-fingerprint' ),
633 );
634
635 $server = $this->header_value( $headers, array( 'server' ) );
636 $x_powered = $this->header_value( $headers, array( 'x-powered-by' ) );
637 $leaks = array();
638
639 if ( $server && preg_match( '#[\d\.]+#', $server ) ) {
640 $leaks[] = 'Server: ' . $server;
641 }
642 if ( $x_powered ) {
643 $leaks[] = 'X-Powered-By: ' . $x_powered;
644 }
645
646 if ( empty( $leaks ) ) {
647 $args['detail'] = __( 'No server or PHP version information found in response headers.', 'vigilante' );
648 return Vigilante_SA_Check_Result::pass( $args );
649 }
650
651 $args['detail'] = sprintf(
652 /* translators: %s: comma-separated leaked headers */
653 __( 'Version information leaked through response headers: %s', 'vigilante' ),
654 implode( ' | ', $leaks )
655 );
656 // The same block that sets the headers removes X-Powered-By: while it
657 // is not being applied, that one stays too. Why it is not applied is
658 // in the row run() adds for all of them.
659 if ( $x_powered && $this->block_not_applied ) {
660 $this->cause_needed = true;
661 // The sentence above ends in the value of a header, not in a full stop.
662 $args['detail'] = rtrim( $args['detail'], " .\t\n" ) . '. ' . __( 'Vigilant is set to remove X-Powered-By.', 'vigilante' );
663 }
664 return Vigilante_SA_Check_Result::fail( $args );
665 }
666
667 /**
668 * Read a header value from the normalized map, trying multiple key variants.
669 *
670 * @param array<string,string> $headers Normalized lowercase headers.
671 * @param string[] $keys Candidate keys.
672 * @return string Value or empty string.
673 */
674 private function header_value( $headers, $keys ) {
675 foreach ( $keys as $key ) {
676 $k = strtolower( $key );
677 if ( isset( $headers[ $k ] ) && '' !== trim( $headers[ $k ] ) ) {
678 return $headers[ $k ];
679 }
680 }
681 return '';
682 }
683
684 private function truncate( $text, $len ) {
685 $text = (string) $text;
686 if ( strlen( $text ) <= $len ) {
687 return $text;
688 }
689 return substr( $text, 0, $len ) . '…';
690 }
691
692 /**
693 * Vigilant blocks found in .htaccess files above this installation
694 *
695 * @since 2.9.9
696 *
697 * @return array<string,string[]> Absolute file path => markers found inside.
698 */
699 private function vigilant_blocks_above() {
700 // Asked by the CSP check and by the note for the other headers: the
701 // walk up the directories is done once per scan.
702 if ( null !== $this->blocks_above ) {
703 return $this->blocks_above;
704 }
705
706 if ( ! class_exists( 'Vigilante_Htaccess_Manager' ) ) {
707 require_once VIGILANTE_INCLUDES_DIR . 'class-htaccess-manager.php';
708 }
709
710 $this->blocks_above = (array) Vigilante_Htaccess_Manager::get_instance()->find_blocks_above();
711
712 return $this->blocks_above;
713 }
714
715 }
716