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

640 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 * @return string
311 */
312 private function generate_rules_content() {
313 $rules = array();
314
315 $rules[] = '# Vigilante - Security Headers';
316 $rules[] = '# Generated: ' . gmdate( 'Y-m-d H:i:s' ) . ' UTC';
317
318 // Check if CSP is restrictive (could break admin interface)
319 $is_csp_restrictive = $this->is_csp_restrictive();
320
321 // If CSP is restrictive, we need to skip it for wp-admin to prevent breaking the admin interface
322 if ( $is_csp_restrictive ) {
323 $rules[] = '';
324 $rules[] = '# Skip restrictive CSP for WordPress admin area to prevent breaking the editor';
325 $rules[] = '<IfModule mod_setenvif.c>';
326 $rules[] = ' SetEnvIf Request_URI "^/wp-admin" VIGILANTE_SKIP_CSP';
327 $rules[] = ' SetEnvIf Request_URI "admin-ajax\\.php$" VIGILANTE_SKIP_CSP';
328 $rules[] = ' SetEnvIf Request_URI "wp-login\\.php$" VIGILANTE_SKIP_CSP';
329 $rules[] = '</IfModule>';
330 }
331
332 $rules[] = '';
333 $rules[] = '<IfModule mod_headers.c>';
334
335 // X-Frame-Options
336 if ( ! empty( $this->options['x_frame_options'] ) ) {
337 $value = $this->options['x_frame_options'];
338 $rules[] = ' # Clickjacking protection';
339 $rules[] = ' Header always set X-Frame-Options "' . $this->sanitize_header_value( $value ) . '"';
340 }
341
342 // X-Content-Type-Options
343 if ( ! empty( $this->options['x_content_type_options'] ) ) {
344 $rules[] = ' # Prevent MIME type sniffing';
345 $rules[] = ' Header always set X-Content-Type-Options "nosniff"';
346 }
347
348 // Referrer-Policy
349 if ( ! empty( $this->options['referrer_policy'] ) ) {
350 $value = $this->options['referrer_policy'];
351 $rules[] = ' # Referrer Policy';
352 $rules[] = ' Header always set Referrer-Policy "' . $this->sanitize_header_value( $value ) . '"';
353 }
354
355 // Strict-Transport-Security (HSTS)
356 if ( ! empty( $this->options['hsts']['enabled'] ) ) {
357 $hsts = $this->options['hsts'];
358 $value = 'max-age=' . absint( $hsts['max_age'] );
359
360 if ( ! empty( $hsts['include_subdomains'] ) ) {
361 $value .= '; includeSubDomains';
362 }
363
364 if ( ! empty( $hsts['preload'] ) ) {
365 $value .= '; preload';
366 }
367
368 $rules[] = ' # HTTP Strict Transport Security';
369 $rules[] = ' Header always set Strict-Transport-Security "' . $value . '"';
370 }
371
372 // Permissions-Policy
373 if ( ! empty( $this->options['permissions_policy']['enabled'] ) ) {
374 $permissions = $this->options['permissions_policy'];
375 $directives = array();
376
377 $policy_items = array(
378 'geolocation', 'microphone', 'camera', 'payment',
379 'usb', 'magnetometer', 'gyroscope', 'accelerometer',
380 );
381
382 foreach ( $policy_items as $item ) {
383 if ( isset( $permissions[ $item ] ) ) {
384 $directives[] = $item . '=' . $permissions[ $item ];
385 }
386 }
387
388 if ( ! empty( $directives ) ) {
389 $rules[] = ' # Permissions Policy';
390 $rules[] = ' Header always set Permissions-Policy "' . $this->sanitize_header_value( implode( ', ', $directives ) ) . '"';
391 }
392 }
393
394 // Content-Security-Policy
395 if ( ! empty( $this->options['csp']['enabled'] ) ) {
396 $csp = $this->options['csp'];
397 $directives = array();
398
399 // Synchronize frame-ancestors with X-Frame-Options
400 $frame_ancestors_value = '';
401 if ( ! empty( $this->options['x_frame_options'] ) ) {
402 if ( 'DENY' === $this->options['x_frame_options'] ) {
403 $frame_ancestors_value = "'none'";
404 } elseif ( 'SAMEORIGIN' === $this->options['x_frame_options'] ) {
405 $frame_ancestors_value = "'self'";
406 }
407 }
408
409 if ( ! empty( $csp['directives'] ) && is_array( $csp['directives'] ) ) {
410 foreach ( $csp['directives'] as $directive => $value ) {
411 // Override frame-ancestors with synchronized value if X-Frame-Options is set
412 if ( 'frame-ancestors' === $directive && ! empty( $frame_ancestors_value ) ) {
413 $directives[] = $directive . ' ' . $frame_ancestors_value;
414 } elseif ( true === $value ) {
415 $directives[] = $directive;
416 } elseif ( false !== $value && ! empty( $value ) ) {
417 $directives[] = $directive . ' ' . $value;
418 }
419 }
420 }
421
422 // Add frame-ancestors if not already present but X-Frame-Options is set
423 if ( ! empty( $frame_ancestors_value ) ) {
424 $has_frame_ancestors = false;
425 foreach ( $directives as $dir ) {
426 if ( 0 === strpos( $dir, 'frame-ancestors' ) ) {
427 $has_frame_ancestors = true;
428 break;
429 }
430 }
431 if ( ! $has_frame_ancestors ) {
432 $directives[] = 'frame-ancestors ' . $frame_ancestors_value;
433 }
434 }
435
436 if ( ! empty( $directives ) ) {
437 $header_value = implode( '; ', $directives );
438
439 // The value travels inside a double-quoted argument of the
440 // "Header always set" line below. A double quote in a directive
441 // would close that argument early and leave the rest of the
442 // policy as stray arguments, which Apache rejects with a 500.
443 // CSP source expressions use single quotes ('self',
444 // 'unsafe-inline'), never double ones, so no legitimate policy
445 // can be affected. Done here rather than at save time so the
446 // guard holds however the value reached the option.
447 $header_value = $this->sanitize_header_value( $header_value );
448
449 if ( ! empty( $csp['report_uri'] ) ) {
450 $header_value .= '; report-uri ' . $this->sanitize_header_value( esc_url( $csp['report_uri'] ) );
451 }
452
453 $header_name = ! empty( $csp['report_only'] )
454 ? 'Content-Security-Policy-Report-Only'
455 : 'Content-Security-Policy';
456
457 $rules[] = ' # Content Security Policy';
458
459 // If CSP is restrictive, only apply it outside wp-admin
460 if ( $is_csp_restrictive ) {
461 $rules[] = ' # Note: Restrictive CSP skipped for wp-admin to prevent breaking the block editor';
462 $rules[] = ' Header always set ' . $header_name . ' "' . $header_value . '" env=!VIGILANTE_SKIP_CSP';
463 } else {
464 $rules[] = ' Header always set ' . $header_name . ' "' . $header_value . '"';
465 }
466 }
467 }
468
469 // Cross-Origin policies
470 if ( ! empty( $this->options['cross_origin_policies'] ) ) {
471 $policies = $this->options['cross_origin_policies'];
472
473 if ( ! empty( $policies['embedder_policy'] ) && 'unsafe-none' !== $policies['embedder_policy'] ) {
474 $rules[] = ' Header always set Cross-Origin-Embedder-Policy "' . $this->sanitize_header_value( $policies['embedder_policy'] ) . '"';
475 }
476
477 if ( ! empty( $policies['opener_policy'] ) ) {
478 $rules[] = ' Header always set Cross-Origin-Opener-Policy "' . $this->sanitize_header_value( $policies['opener_policy'] ) . '"';
479 }
480
481 if ( ! empty( $policies['resource_policy'] ) ) {
482 $rules[] = ' Header always set Cross-Origin-Resource-Policy "' . $this->sanitize_header_value( $policies['resource_policy'] ) . '"';
483 }
484 }
485
486 // Remove X-Powered-By
487 $rules[] = ' # Hide PHP version';
488 $rules[] = ' Header always unset X-Powered-By';
489
490 $rules[] = '</IfModule>';
491
492 return implode( "\n", $rules );
493 }
494
495 /**
496 * Get headers preview
497 *
498 * @return array
499 */
500 public function get_headers_preview() {
501 $headers = array();
502
503 if ( ! empty( $this->options['x_frame_options'] ) ) {
504 $headers['X-Frame-Options'] = $this->options['x_frame_options'];
505 }
506
507 if ( ! empty( $this->options['x_content_type_options'] ) ) {
508 $headers['X-Content-Type-Options'] = 'nosniff';
509 }
510
511 if ( ! empty( $this->options['referrer_policy'] ) ) {
512 $headers['Referrer-Policy'] = $this->options['referrer_policy'];
513 }
514
515 if ( ! empty( $this->options['hsts']['enabled'] ) ) {
516 $hsts = $this->options['hsts'];
517 $value = 'max-age=' . absint( $hsts['max_age'] );
518 if ( ! empty( $hsts['include_subdomains'] ) ) {
519 $value .= '; includeSubDomains';
520 }
521 if ( ! empty( $hsts['preload'] ) ) {
522 $value .= '; preload';
523 }
524 $headers['Strict-Transport-Security'] = $value;
525 }
526
527 return $headers;
528 }
529
530 /**
531 * Get security grade based on enabled headers
532 *
533 * @return array
534 */
535 public function get_security_grade() {
536 $score = 0;
537 $enabled = array();
538 $missing = array();
539 $warnings = array();
540
541 // X-Frame-Options (15 points)
542 if ( ! empty( $this->options['x_frame_options'] ) ) {
543 $score += 15;
544 $enabled[] = 'X-Frame-Options: ' . $this->options['x_frame_options'];
545 } else {
546 $missing[] = 'X-Frame-Options';
547 }
548
549 // X-Content-Type-Options (15 points)
550 if ( ! empty( $this->options['x_content_type_options'] ) ) {
551 $score += 15;
552 $enabled[] = 'X-Content-Type-Options: nosniff';
553 } else {
554 $missing[] = 'X-Content-Type-Options';
555 }
556
557 // HSTS (20 points)
558 if ( ! empty( $this->options['hsts']['enabled'] ) ) {
559 $hsts = $this->options['hsts'];
560 if ( $hsts['max_age'] >= 31536000 ) {
561 $score += 20;
562 } else {
563 $score += 10;
564 $warnings[] = 'HSTS max-age should be at least 1 year (31536000 seconds)';
565 }
566 $enabled[] = 'Strict-Transport-Security';
567 } else {
568 $missing[] = 'Strict-Transport-Security (HSTS)';
569 }
570
571 // CSP (30 points; absorbs the 10 points freed by retiring the deprecated X-XSS-Protection)
572 if ( ! empty( $this->options['csp']['enabled'] ) ) {
573 if ( empty( $this->options['csp']['report_only'] ) ) {
574 $score += 30;
575 $enabled[] = 'Content-Security-Policy';
576
577 // Add warning if CSP is restrictive
578 if ( $this->is_csp_restrictive() ) {
579 $warnings[] = __( 'Restrictive CSP detected. Admin area is automatically excluded to prevent breaking the dashboard and block editor.', 'vigilante' );
580 }
581
582 // Check for specific compatibility issues
583 $csp_issues = $this->get_csp_compatibility_issues();
584 foreach ( $csp_issues as $issue ) {
585 if ( 'high' === $issue['severity'] ) {
586 $warnings[] = sprintf(
587 /* translators: 1: CSP directive name, 2: Issue description */
588 __( 'CSP %1$s: %2$s', 'vigilante' ),
589 $issue['directive'],
590 $issue['issue']
591 );
592 }
593 }
594 } else {
595 $score += 15;
596 $enabled[] = 'Content-Security-Policy-Report-Only';
597 $warnings[] = 'CSP is in report-only mode (recommended for testing)';
598 }
599 } else {
600 $missing[] = 'Content-Security-Policy';
601 }
602
603 // Referrer-Policy (10 points)
604 if ( ! empty( $this->options['referrer_policy'] ) ) {
605 $score += 10;
606 $enabled[] = 'Referrer-Policy: ' . $this->options['referrer_policy'];
607 } else {
608 $missing[] = 'Referrer-Policy';
609 }
610
611 // Permissions-Policy (10 points)
612 if ( ! empty( $this->options['permissions_policy']['enabled'] ) ) {
613 $score += 10;
614 $enabled[] = 'Permissions-Policy';
615 } else {
616 $missing[] = 'Permissions-Policy';
617 }
618
619 // Calculate grade
620 if ( $score >= 90 ) {
621 $grade = 'A';
622 } elseif ( $score >= 80 ) {
623 $grade = 'B';
624 } elseif ( $score >= 70 ) {
625 $grade = 'C';
626 } elseif ( $score >= 60 ) {
627 $grade = 'D';
628 } else {
629 $grade = 'F';
630 }
631
632 return array(
633 'grade' => $grade,
634 'score' => $score,
635 'headers' => $enabled,
636 'missing' => $missing,
637 'warnings' => $warnings,
638 );
639 }
640 }