PluginProbe
Vigilant – 100% Free Security Suite: Firewall, 2FA, Login, Headers, Scanner… / 2.10.0
Vigilant – 100% Free Security Suite: Firewall, 2FA, Login, Headers, Scanner… v2.10.0
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-security-headers.php

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

623 lines 23.6 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Security Headers Class
4 *
5 * Manages HTTP security headers via .htaccess
6 *
7 * @package Vigilante
8 */
9
10 // Prevent direct access
11 if ( ! defined( 'ABSPATH' ) ) {
12 exit;
13 }
14
15 /**
16 * Class Vigilante_Security_Headers
17 *
18 * Applies HTTP security headers via .htaccess for Apache/LiteSpeed servers
19 */
20 class Vigilante_Security_Headers {
21
22 /**
23 * Settings instance
24 *
25 * @var Vigilante_Settings
26 */
27 private $settings;
28
29 /**
30 * Header options
31 *
32 * @var array
33 */
34 private $options;
35
36 /**
37 * Block markers
38 */
39 const MARKER_START = '# BEGIN Vigilante Security Headers';
40 const MARKER_END = '# END Vigilante Security Headers';
41
42 /**
43 * Constructor
44 *
45 * @param Vigilante_Settings $settings Settings instance.
46 */
47 public function __construct( $settings ) {
48 $this->settings = $settings;
49 $this->options = $settings->get_section( 'security_headers' );
50 }
51
52 /**
53 * Apply security headers to .htaccess
54 *
55 * @return bool|WP_Error
56 */
57 public function apply_rules() {
58 require_once VIGILANTE_INCLUDES_DIR . 'class-htaccess-manager.php';
59
60 $manager = Vigilante_Htaccess_Manager::get_instance();
61
62 if ( ! $manager->is_apache() ) {
63 return new WP_Error( 'not_apache', __( 'Server is not Apache/LiteSpeed', 'vigilante' ) );
64 }
65
66 if ( ! $manager->is_writable() ) {
67 return new WP_Error( 'not_writable', __( '.htaccess is not writable', 'vigilante' ) );
68 }
69
70 $rules = $this->generate_rules_content();
71
72 $result = $manager->add_block( self::MARKER_START, self::MARKER_END, $rules, 'top' );
73
74 if ( true === $result ) {
75 /** This action is documented in class-wpconfig-security.php */
76 do_action( 'vigilante_critical_file_written', '.htaccess' );
77 }
78
79 return $result;
80 }
81
82 /**
83 * Remove security headers from .htaccess
84 *
85 * @return bool|WP_Error
86 */
87 public function remove_rules() {
88 require_once VIGILANTE_INCLUDES_DIR . 'class-htaccess-manager.php';
89
90 $manager = Vigilante_Htaccess_Manager::get_instance();
91
92 $result = $manager->remove_block( self::MARKER_START, self::MARKER_END );
93
94 if ( true === $result ) {
95 /** This action is documented in class-wpconfig-security.php */
96 do_action( 'vigilante_critical_file_written', '.htaccess' );
97 }
98
99 return $result;
100 }
101
102 /**
103 * Check if rules are active
104 *
105 * @return bool
106 */
107 public function are_rules_active() {
108 require_once VIGILANTE_INCLUDES_DIR . 'class-htaccess-manager.php';
109
110 $manager = Vigilante_Htaccess_Manager::get_instance();
111
112 return $manager->block_exists( self::MARKER_START );
113 }
114
115 /**
116 * Check if CSP is restrictive (could break WordPress admin/editor)
117 *
118 * WordPress block editor (Gutenberg) requires:
119 * - script-src: 'unsafe-inline' 'unsafe-eval' (for React)
120 * - style-src: 'unsafe-inline' (for dynamic styles)
121 * - frame-src: blob: (for iframe previews)
122 * - worker-src: blob: (for web workers)
123 * - connect-src: blob: (for the client-side media processing of WP 7.1+)
124 *
125 * @return bool True if CSP would likely break the admin interface.
126 */
127 public function is_csp_restrictive() {
128 if ( empty( $this->options['csp']['enabled'] ) ) {
129 return false;
130 }
131
132 // If report-only mode, it won't actually block anything
133 if ( ! empty( $this->options['csp']['report_only'] ) ) {
134 return false;
135 }
136
137 $directives = $this->options['csp']['directives'] ?? array();
138
139 // Check script-src for required values
140 $script_src = $directives['script-src'] ?? '';
141 if ( ! empty( $script_src ) ) {
142 // Gutenberg needs 'unsafe-inline' and 'unsafe-eval'
143 $has_unsafe_inline = ( false !== strpos( $script_src, "'unsafe-inline'" ) );
144 $has_unsafe_eval = ( false !== strpos( $script_src, "'unsafe-eval'" ) );
145 $has_nonce = ( false !== strpos( $script_src, "'nonce-" ) );
146
147 // If no unsafe-inline and no nonce, it's restrictive
148 if ( ! $has_unsafe_inline && ! $has_nonce ) {
149 return true;
150 }
151
152 // Gutenberg specifically needs unsafe-eval for React
153 if ( ! $has_unsafe_eval && ! $has_nonce ) {
154 return true;
155 }
156 }
157
158 // Check style-src for required values
159 $style_src = $directives['style-src'] ?? '';
160 if ( ! empty( $style_src ) ) {
161 $has_unsafe_inline = ( false !== strpos( $style_src, "'unsafe-inline'" ) );
162 $has_nonce = ( false !== strpos( $style_src, "'nonce-" ) );
163
164 if ( ! $has_unsafe_inline && ! $has_nonce ) {
165 return true;
166 }
167 }
168
169 // Check frame-src for blob: (required by Gutenberg for iframe previews)
170 $frame_src = $directives['frame-src'] ?? '';
171 if ( ! empty( $frame_src ) && false === strpos( $frame_src, 'blob:' ) ) {
172 // Only restrictive if frame-src is set and doesn't include blob:
173 // Check if it's set to 'none' which would definitely block
174 if ( false !== strpos( $frame_src, "'none'" ) ) {
175 return true;
176 }
177 // If frame-src is explicitly set without blob:, it's restrictive
178 return true;
179 }
180
181 // Check worker-src for blob: (required for web workers)
182 $worker_src = $directives['worker-src'] ?? '';
183 if ( ! empty( $worker_src ) && false === strpos( $worker_src, 'blob:' ) ) {
184 if ( false !== strpos( $worker_src, "'none'" ) ) {
185 return true;
186 }
187 }
188
189 // Check connect-src for blob:. WordPress 7.1 processes images in the
190 // browser before uploading them, and @wordpress/vips fetches its
191 // WebAssembly binary from a blob: URL. fetch() answers to connect-src,
192 // and 'self' does not cover blob:, so without it the upload fails while
193 // WordPress still believes the feature is supported (its own detection
194 // only tests blob: workers, which worker-src above already allows).
195 $connect_src = $directives['connect-src'] ?? '';
196 if ( ! empty( $connect_src ) && false === strpos( $connect_src, 'blob:' ) ) {
197 return true;
198 }
199
200 return false;
201 }
202
203 /**
204 * Get specific CSP issues that could affect WordPress
205 *
206 * @return array List of issues with directives.
207 */
208 public function get_csp_compatibility_issues() {
209 $issues = array();
210
211 if ( empty( $this->options['csp']['enabled'] ) ) {
212 return $issues;
213 }
214
215 if ( ! empty( $this->options['csp']['report_only'] ) ) {
216 return $issues;
217 }
218
219 $directives = $this->options['csp']['directives'] ?? array();
220
221 // Check script-src
222 $script_src = $directives['script-src'] ?? '';
223 if ( ! empty( $script_src ) ) {
224 if ( false === strpos( $script_src, "'unsafe-inline'" ) && false === strpos( $script_src, "'nonce-" ) ) {
225 $issues[] = array(
226 'directive' => 'script-src',
227 'issue' => __( 'Missing \'unsafe-inline\' - may break admin scripts', 'vigilante' ),
228 'severity' => 'high',
229 );
230 }
231 if ( false === strpos( $script_src, "'unsafe-eval'" ) ) {
232 $issues[] = array(
233 'directive' => 'script-src',
234 'issue' => __( 'Missing \'unsafe-eval\' - will break the block editor (Gutenberg)', 'vigilante' ),
235 'severity' => 'high',
236 );
237 }
238 }
239
240 // Check style-src
241 $style_src = $directives['style-src'] ?? '';
242 if ( ! empty( $style_src ) && false === strpos( $style_src, "'unsafe-inline'" ) ) {
243 $issues[] = array(
244 'directive' => 'style-src',
245 'issue' => __( 'Missing \'unsafe-inline\' - may break admin styles', 'vigilante' ),
246 'severity' => 'medium',
247 );
248 }
249
250 // Check frame-src
251 $frame_src = $directives['frame-src'] ?? '';
252 if ( ! empty( $frame_src ) && false === strpos( $frame_src, 'blob:' ) ) {
253 $issues[] = array(
254 'directive' => 'frame-src',
255 'issue' => __( 'Missing \'blob:\' - will break the block editor previews', 'vigilante' ),
256 'severity' => 'high',
257 );
258 }
259
260 // Check worker-src
261 $worker_src = $directives['worker-src'] ?? '';
262 if ( ! empty( $worker_src ) && false === strpos( $worker_src, 'blob:' ) ) {
263 $issues[] = array(
264 'directive' => 'worker-src',
265 'issue' => __( 'Missing \'blob:\' - may break background processing', 'vigilante' ),
266 'severity' => 'low',
267 );
268 }
269
270 // Check connect-src
271 $connect_src = $directives['connect-src'] ?? '';
272 if ( ! empty( $connect_src ) && false === strpos( $connect_src, 'blob:' ) ) {
273 $issues[] = array(
274 'directive' => 'connect-src',
275 'issue' => __( 'Missing \'blob:\' - will break image uploads from the editor on WordPress 7.1 and later', 'vigilante' ),
276 'severity' => 'high',
277 );
278 }
279
280 return $issues;
281 }
282
283 /**
284 * Make a settings value safe to interpolate into an .htaccess directive.
285 *
286 * Every value below is written inside a double-quoted argument of a
287 * "Header always set" line. Two characters break out of that argument:
288 *
289 * - A line break ends the directive and turns whatever follows into a new
290 * Apache directive, which is arbitrary server configuration.
291 * - A double quote closes the argument early and leaves the remainder as
292 * stray arguments, which Apache rejects with a 500.
293 *
294 * No header value this plugin writes legitimately contains either: CSP
295 * source expressions use single quotes ('self', 'unsafe-inline'), and the
296 * rest are single-token values from fixed lists. Applied at generation time
297 * rather than at save time so the guard holds however the value reached the
298 * option (a crafted request, an imported settings file, a direct write).
299 *
300 * @param mixed $value Raw settings value.
301 * @return string
302 */
303 private function sanitize_header_value( $value ) {
304 return str_replace( array( '"', "\r", "\n" ), '', (string) $value );
305 }
306
307 /**
308 * Generate rules content (without markers)
309 *
310 * Public since 2.10.0: it is also "what this configuration would write right
311 * now", which is what Vigilante_Htaccess_Recovery compares the file against.
312 *
313 * @return string
314 */
315 public function generate_rules_content() {
316 $rules = array();
317
318 $rules[] = '# Vigilante - Security Headers';
319 $rules[] = '# Generated: ' . gmdate( 'Y-m-d H:i:s' ) . ' UTC';
320
321 // Check if CSP is restrictive (could break admin interface)
322 $is_csp_restrictive = $this->is_csp_restrictive();
323
324 // If CSP is restrictive, we need to skip it for wp-admin to prevent breaking the admin interface
325 if ( $is_csp_restrictive ) {
326 $rules[] = '';
327 $rules[] = '# Skip restrictive CSP for WordPress admin area to prevent breaking the editor';
328 $rules[] = '<IfModule mod_setenvif.c>';
329 $rules[] = ' SetEnvIf Request_URI "^/wp-admin" VIGILANTE_SKIP_CSP';
330 $rules[] = ' SetEnvIf Request_URI "admin-ajax\\.php$" VIGILANTE_SKIP_CSP';
331 $rules[] = ' SetEnvIf Request_URI "wp-login\\.php$" VIGILANTE_SKIP_CSP';
332 $rules[] = '</IfModule>';
333 }
334
335 $rules[] = '';
336 $rules[] = '<IfModule mod_headers.c>';
337
338 // X-Frame-Options
339 if ( ! empty( $this->options['x_frame_options'] ) ) {
340 $value = $this->options['x_frame_options'];
341 $rules[] = ' # Clickjacking protection';
342 $rules[] = ' Header always set X-Frame-Options "' . $this->sanitize_header_value( $value ) . '"';
343 }
344
345 // X-Content-Type-Options
346 if ( ! empty( $this->options['x_content_type_options'] ) ) {
347 $rules[] = ' # Prevent MIME type sniffing';
348 $rules[] = ' Header always set X-Content-Type-Options "nosniff"';
349 }
350
351 // Referrer-Policy
352 if ( ! empty( $this->options['referrer_policy'] ) ) {
353 $value = $this->options['referrer_policy'];
354 $rules[] = ' # Referrer Policy';
355 $rules[] = ' Header always set Referrer-Policy "' . $this->sanitize_header_value( $value ) . '"';
356 }
357
358 // Strict-Transport-Security (HSTS)
359 if ( ! empty( $this->options['hsts']['enabled'] ) ) {
360 $hsts = $this->options['hsts'];
361 $value = 'max-age=' . absint( $hsts['max_age'] );
362
363 if ( ! empty( $hsts['include_subdomains'] ) ) {
364 $value .= '; includeSubDomains';
365 }
366
367 if ( ! empty( $hsts['preload'] ) ) {
368 $value .= '; preload';
369 }
370
371 $rules[] = ' # HTTP Strict Transport Security';
372 $rules[] = ' Header always set Strict-Transport-Security "' . $value . '"';
373 }
374
375 // Permissions-Policy
376 if ( ! empty( $this->options['permissions_policy']['enabled'] ) ) {
377 $permissions = $this->options['permissions_policy'];
378 $directives = array();
379
380 $policy_items = array(
381 'geolocation', 'microphone', 'camera', 'payment',
382 'usb', 'magnetometer', 'gyroscope', 'accelerometer',
383 );
384
385 foreach ( $policy_items as $item ) {
386 if ( isset( $permissions[ $item ] ) ) {
387 $directives[] = $item . '=' . $permissions[ $item ];
388 }
389 }
390
391 if ( ! empty( $directives ) ) {
392 $rules[] = ' # Permissions Policy';
393 $rules[] = ' Header always set Permissions-Policy "' . $this->sanitize_header_value( implode( ', ', $directives ) ) . '"';
394 }
395 }
396
397 // Content-Security-Policy
398 if ( ! empty( $this->options['csp']['enabled'] ) ) {
399 $csp = $this->options['csp'];
400 $directives = array();
401
402 // Synchronize frame-ancestors with X-Frame-Options
403 $frame_ancestors_value = '';
404 if ( ! empty( $this->options['x_frame_options'] ) ) {
405 if ( 'DENY' === $this->options['x_frame_options'] ) {
406 $frame_ancestors_value = "'none'";
407 } elseif ( 'SAMEORIGIN' === $this->options['x_frame_options'] ) {
408 $frame_ancestors_value = "'self'";
409 }
410 }
411
412 if ( ! empty( $csp['directives'] ) && is_array( $csp['directives'] ) ) {
413 foreach ( $csp['directives'] as $directive => $value ) {
414 // Override frame-ancestors with synchronized value if X-Frame-Options is set
415 if ( 'frame-ancestors' === $directive && ! empty( $frame_ancestors_value ) ) {
416 $directives[] = $directive . ' ' . $frame_ancestors_value;
417 } elseif ( true === $value ) {
418 $directives[] = $directive;
419 } elseif ( false !== $value && ! empty( $value ) ) {
420 $directives[] = $directive . ' ' . $value;
421 }
422 }
423 }
424
425 // Add frame-ancestors if not already present but X-Frame-Options is set
426 if ( ! empty( $frame_ancestors_value ) ) {
427 $has_frame_ancestors = false;
428 foreach ( $directives as $dir ) {
429 if ( 0 === strpos( $dir, 'frame-ancestors' ) ) {
430 $has_frame_ancestors = true;
431 break;
432 }
433 }
434 if ( ! $has_frame_ancestors ) {
435 $directives[] = 'frame-ancestors ' . $frame_ancestors_value;
436 }
437 }
438
439 if ( ! empty( $directives ) ) {
440 $header_value = implode( '; ', $directives );
441
442 // The value travels inside a double-quoted argument of the
443 // "Header always set" line below. A double quote in a directive
444 // would close that argument early and leave the rest of the
445 // policy as stray arguments, which Apache rejects with a 500.
446 // CSP source expressions use single quotes ('self',
447 // 'unsafe-inline'), never double ones, so no legitimate policy
448 // can be affected. Done here rather than at save time so the
449 // guard holds however the value reached the option.
450 $header_value = $this->sanitize_header_value( $header_value );
451
452 if ( ! empty( $csp['report_uri'] ) ) {
453 $header_value .= '; report-uri ' . $this->sanitize_header_value( esc_url( $csp['report_uri'] ) );
454 }
455
456 $header_name = ! empty( $csp['report_only'] )
457 ? 'Content-Security-Policy-Report-Only'
458 : 'Content-Security-Policy';
459
460 $rules[] = ' # Content Security Policy';
461
462 // If CSP is restrictive, only apply it outside wp-admin
463 if ( $is_csp_restrictive ) {
464 $rules[] = ' # Note: Restrictive CSP skipped for wp-admin to prevent breaking the block editor';
465 $rules[] = ' Header always set ' . $header_name . ' "' . $header_value . '" env=!VIGILANTE_SKIP_CSP';
466 } else {
467 $rules[] = ' Header always set ' . $header_name . ' "' . $header_value . '"';
468 }
469 }
470 }
471
472 /*
473 * Cross-Origin policies.
474 *
475 * Allow-listed rather than escaped, for the same reason sanitize_header_value()
476 * runs at generation time: these three became editable in 2.10.0 and their value
477 * is written verbatim inside a Header directive. Only the tokens the specs define
478 * are ever emitted, so an unexpected value (a crafted request, an imported
479 * settings file, a direct write to the option) drops the header instead of
480 * reaching the .htaccess.
481 *
482 * COEP deliberately omits unsafe-none: it is the browser default, so emitting it
483 * adds nothing. That was the behaviour before this list existed and it is kept.
484 */
485 if ( ! empty( $this->options['cross_origin_policies'] ) ) {
486 $policies = $this->options['cross_origin_policies'];
487
488 $coep = isset( $policies['embedder_policy'] ) ? (string) $policies['embedder_policy'] : '';
489 if ( in_array( $coep, array( 'require-corp', 'credentialless' ), true ) ) {
490 $rules[] = ' Header always set Cross-Origin-Embedder-Policy "' . $coep . '"';
491 }
492
493 $coop = isset( $policies['opener_policy'] ) ? (string) $policies['opener_policy'] : '';
494 if ( in_array( $coop, array( 'unsafe-none', 'same-origin-allow-popups', 'same-origin' ), true ) ) {
495 $rules[] = ' Header always set Cross-Origin-Opener-Policy "' . $coop . '"';
496 }
497
498 $corp = isset( $policies['resource_policy'] ) ? (string) $policies['resource_policy'] : '';
499 if ( in_array( $corp, array( 'same-site', 'same-origin', 'cross-origin' ), true ) ) {
500 $rules[] = ' Header always set Cross-Origin-Resource-Policy "' . $corp . '"';
501 }
502 }
503
504 // Remove X-Powered-By
505 $rules[] = ' # Hide PHP version';
506 $rules[] = ' Header always unset X-Powered-By';
507
508 $rules[] = '</IfModule>';
509
510 return implode( "\n", $rules );
511 }
512
513 /**
514 * Get security grade based on enabled headers
515 *
516 * @return array
517 */
518 public function get_security_grade() {
519 $score = 0;
520 $enabled = array();
521 $missing = array();
522 $warnings = array();
523
524 // X-Frame-Options (15 points)
525 if ( ! empty( $this->options['x_frame_options'] ) ) {
526 $score += 15;
527 $enabled[] = 'X-Frame-Options: ' . $this->options['x_frame_options'];
528 } else {
529 $missing[] = 'X-Frame-Options';
530 }
531
532 // X-Content-Type-Options (15 points)
533 if ( ! empty( $this->options['x_content_type_options'] ) ) {
534 $score += 15;
535 $enabled[] = 'X-Content-Type-Options: nosniff';
536 } else {
537 $missing[] = 'X-Content-Type-Options';
538 }
539
540 // HSTS (20 points)
541 if ( ! empty( $this->options['hsts']['enabled'] ) ) {
542 $hsts = $this->options['hsts'];
543 if ( $hsts['max_age'] >= 31536000 ) {
544 $score += 20;
545 } else {
546 $score += 10;
547 $warnings[] = 'HSTS max-age should be at least 1 year (31536000 seconds)';
548 }
549 $enabled[] = 'Strict-Transport-Security';
550 } else {
551 $missing[] = 'Strict-Transport-Security (HSTS)';
552 }
553
554 // CSP (30 points; absorbs the 10 points freed by retiring the deprecated X-XSS-Protection)
555 if ( ! empty( $this->options['csp']['enabled'] ) ) {
556 if ( empty( $this->options['csp']['report_only'] ) ) {
557 $score += 30;
558 $enabled[] = 'Content-Security-Policy';
559
560 // Add warning if CSP is restrictive
561 if ( $this->is_csp_restrictive() ) {
562 $warnings[] = __( 'Restrictive CSP detected. Admin area is automatically excluded to prevent breaking the dashboard and block editor.', 'vigilante' );
563 }
564
565 // Check for specific compatibility issues
566 $csp_issues = $this->get_csp_compatibility_issues();
567 foreach ( $csp_issues as $issue ) {
568 if ( 'high' === $issue['severity'] ) {
569 $warnings[] = sprintf(
570 /* translators: 1: CSP directive name, 2: Issue description */
571 __( 'CSP %1$s: %2$s', 'vigilante' ),
572 $issue['directive'],
573 $issue['issue']
574 );
575 }
576 }
577 } else {
578 $score += 15;
579 $enabled[] = 'Content-Security-Policy-Report-Only';
580 $warnings[] = 'CSP is in report-only mode (recommended for testing)';
581 }
582 } else {
583 $missing[] = 'Content-Security-Policy';
584 }
585
586 // Referrer-Policy (10 points)
587 if ( ! empty( $this->options['referrer_policy'] ) ) {
588 $score += 10;
589 $enabled[] = 'Referrer-Policy: ' . $this->options['referrer_policy'];
590 } else {
591 $missing[] = 'Referrer-Policy';
592 }
593
594 // Permissions-Policy (10 points)
595 if ( ! empty( $this->options['permissions_policy']['enabled'] ) ) {
596 $score += 10;
597 $enabled[] = 'Permissions-Policy';
598 } else {
599 $missing[] = 'Permissions-Policy';
600 }
601
602 // Calculate grade
603 if ( $score >= 90 ) {
604 $grade = 'A';
605 } elseif ( $score >= 80 ) {
606 $grade = 'B';
607 } elseif ( $score >= 70 ) {
608 $grade = 'C';
609 } elseif ( $score >= 60 ) {
610 $grade = 'D';
611 } else {
612 $grade = 'F';
613 }
614
615 return array(
616 'grade' => $grade,
617 'score' => $score,
618 'headers' => $enabled,
619 'missing' => $missing,
620 'warnings' => $warnings,
621 );
622 }
623 }