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

584 lines 20.5 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 *
124 * @return bool True if CSP would likely break the admin interface.
125 */
126 public function is_csp_restrictive() {
127 if ( empty( $this->options['csp']['enabled'] ) ) {
128 return false;
129 }
130
131 // If report-only mode, it won't actually block anything
132 if ( ! empty( $this->options['csp']['report_only'] ) ) {
133 return false;
134 }
135
136 $directives = $this->options['csp']['directives'] ?? array();
137
138 // Check script-src for required values
139 $script_src = $directives['script-src'] ?? '';
140 if ( ! empty( $script_src ) ) {
141 // Gutenberg needs 'unsafe-inline' and 'unsafe-eval'
142 $has_unsafe_inline = ( false !== strpos( $script_src, "'unsafe-inline'" ) );
143 $has_unsafe_eval = ( false !== strpos( $script_src, "'unsafe-eval'" ) );
144 $has_nonce = ( false !== strpos( $script_src, "'nonce-" ) );
145
146 // If no unsafe-inline and no nonce, it's restrictive
147 if ( ! $has_unsafe_inline && ! $has_nonce ) {
148 return true;
149 }
150
151 // Gutenberg specifically needs unsafe-eval for React
152 if ( ! $has_unsafe_eval && ! $has_nonce ) {
153 return true;
154 }
155 }
156
157 // Check style-src for required values
158 $style_src = $directives['style-src'] ?? '';
159 if ( ! empty( $style_src ) ) {
160 $has_unsafe_inline = ( false !== strpos( $style_src, "'unsafe-inline'" ) );
161 $has_nonce = ( false !== strpos( $style_src, "'nonce-" ) );
162
163 if ( ! $has_unsafe_inline && ! $has_nonce ) {
164 return true;
165 }
166 }
167
168 // Check frame-src for blob: (required by Gutenberg for iframe previews)
169 $frame_src = $directives['frame-src'] ?? '';
170 if ( ! empty( $frame_src ) && false === strpos( $frame_src, 'blob:' ) ) {
171 // Only restrictive if frame-src is set and doesn't include blob:
172 // Check if it's set to 'none' which would definitely block
173 if ( false !== strpos( $frame_src, "'none'" ) ) {
174 return true;
175 }
176 // If frame-src is explicitly set without blob:, it's restrictive
177 return true;
178 }
179
180 // Check worker-src for blob: (required for web workers)
181 $worker_src = $directives['worker-src'] ?? '';
182 if ( ! empty( $worker_src ) && false === strpos( $worker_src, 'blob:' ) ) {
183 if ( false !== strpos( $worker_src, "'none'" ) ) {
184 return true;
185 }
186 }
187
188 return false;
189 }
190
191 /**
192 * Get specific CSP issues that could affect WordPress
193 *
194 * @return array List of issues with directives.
195 */
196 public function get_csp_compatibility_issues() {
197 $issues = array();
198
199 if ( empty( $this->options['csp']['enabled'] ) ) {
200 return $issues;
201 }
202
203 if ( ! empty( $this->options['csp']['report_only'] ) ) {
204 return $issues;
205 }
206
207 $directives = $this->options['csp']['directives'] ?? array();
208
209 // Check script-src
210 $script_src = $directives['script-src'] ?? '';
211 if ( ! empty( $script_src ) ) {
212 if ( false === strpos( $script_src, "'unsafe-inline'" ) && false === strpos( $script_src, "'nonce-" ) ) {
213 $issues[] = array(
214 'directive' => 'script-src',
215 'issue' => __( 'Missing \'unsafe-inline\' - may break admin scripts', 'vigilante' ),
216 'severity' => 'high',
217 );
218 }
219 if ( false === strpos( $script_src, "'unsafe-eval'" ) ) {
220 $issues[] = array(
221 'directive' => 'script-src',
222 'issue' => __( 'Missing \'unsafe-eval\' - will break the block editor (Gutenberg)', 'vigilante' ),
223 'severity' => 'high',
224 );
225 }
226 }
227
228 // Check style-src
229 $style_src = $directives['style-src'] ?? '';
230 if ( ! empty( $style_src ) && false === strpos( $style_src, "'unsafe-inline'" ) ) {
231 $issues[] = array(
232 'directive' => 'style-src',
233 'issue' => __( 'Missing \'unsafe-inline\' - may break admin styles', 'vigilante' ),
234 'severity' => 'medium',
235 );
236 }
237
238 // Check frame-src
239 $frame_src = $directives['frame-src'] ?? '';
240 if ( ! empty( $frame_src ) && false === strpos( $frame_src, 'blob:' ) ) {
241 $issues[] = array(
242 'directive' => 'frame-src',
243 'issue' => __( 'Missing \'blob:\' - will break the block editor previews', 'vigilante' ),
244 'severity' => 'high',
245 );
246 }
247
248 // Check worker-src
249 $worker_src = $directives['worker-src'] ?? '';
250 if ( ! empty( $worker_src ) && false === strpos( $worker_src, 'blob:' ) ) {
251 $issues[] = array(
252 'directive' => 'worker-src',
253 'issue' => __( 'Missing \'blob:\' - may break background processing', 'vigilante' ),
254 'severity' => 'low',
255 );
256 }
257
258 return $issues;
259 }
260
261 /**
262 * Generate rules content (without markers)
263 *
264 * @return string
265 */
266 private function generate_rules_content() {
267 $rules = array();
268
269 $rules[] = '# Vigilante - Security Headers';
270 $rules[] = '# Generated: ' . gmdate( 'Y-m-d H:i:s' ) . ' UTC';
271
272 // Check if CSP is restrictive (could break admin interface)
273 $is_csp_restrictive = $this->is_csp_restrictive();
274
275 // If CSP is restrictive, we need to skip it for wp-admin to prevent breaking the admin interface
276 if ( $is_csp_restrictive ) {
277 $rules[] = '';
278 $rules[] = '# Skip restrictive CSP for WordPress admin area to prevent breaking the editor';
279 $rules[] = '<IfModule mod_setenvif.c>';
280 $rules[] = ' SetEnvIf Request_URI "^/wp-admin" VIGILANTE_SKIP_CSP';
281 $rules[] = ' SetEnvIf Request_URI "admin-ajax\\.php$" VIGILANTE_SKIP_CSP';
282 $rules[] = ' SetEnvIf Request_URI "wp-login\\.php$" VIGILANTE_SKIP_CSP';
283 $rules[] = '</IfModule>';
284 }
285
286 $rules[] = '';
287 $rules[] = '<IfModule mod_headers.c>';
288
289 // X-Frame-Options
290 if ( ! empty( $this->options['x_frame_options'] ) ) {
291 $value = $this->options['x_frame_options'];
292 $rules[] = ' # Clickjacking protection';
293 $rules[] = ' Header always set X-Frame-Options "' . esc_attr( $value ) . '"';
294 }
295
296 // X-Content-Type-Options
297 if ( ! empty( $this->options['x_content_type_options'] ) ) {
298 $rules[] = ' # Prevent MIME type sniffing';
299 $rules[] = ' Header always set X-Content-Type-Options "nosniff"';
300 }
301
302 // Referrer-Policy
303 if ( ! empty( $this->options['referrer_policy'] ) ) {
304 $value = $this->options['referrer_policy'];
305 $rules[] = ' # Referrer Policy';
306 $rules[] = ' Header always set Referrer-Policy "' . esc_attr( $value ) . '"';
307 }
308
309 // Strict-Transport-Security (HSTS)
310 if ( ! empty( $this->options['hsts']['enabled'] ) ) {
311 $hsts = $this->options['hsts'];
312 $value = 'max-age=' . absint( $hsts['max_age'] );
313
314 if ( ! empty( $hsts['include_subdomains'] ) ) {
315 $value .= '; includeSubDomains';
316 }
317
318 if ( ! empty( $hsts['preload'] ) ) {
319 $value .= '; preload';
320 }
321
322 $rules[] = ' # HTTP Strict Transport Security';
323 $rules[] = ' Header always set Strict-Transport-Security "' . $value . '"';
324 }
325
326 // Permissions-Policy
327 if ( ! empty( $this->options['permissions_policy']['enabled'] ) ) {
328 $permissions = $this->options['permissions_policy'];
329 $directives = array();
330
331 $policy_items = array(
332 'geolocation', 'microphone', 'camera', 'payment',
333 'usb', 'magnetometer', 'gyroscope', 'accelerometer',
334 );
335
336 foreach ( $policy_items as $item ) {
337 if ( isset( $permissions[ $item ] ) ) {
338 $directives[] = $item . '=' . $permissions[ $item ];
339 }
340 }
341
342 if ( ! empty( $directives ) ) {
343 $rules[] = ' # Permissions Policy';
344 $rules[] = ' Header always set Permissions-Policy "' . implode( ', ', $directives ) . '"';
345 }
346 }
347
348 // Content-Security-Policy
349 if ( ! empty( $this->options['csp']['enabled'] ) ) {
350 $csp = $this->options['csp'];
351 $directives = array();
352
353 // Synchronize frame-ancestors with X-Frame-Options
354 $frame_ancestors_value = '';
355 if ( ! empty( $this->options['x_frame_options'] ) ) {
356 if ( 'DENY' === $this->options['x_frame_options'] ) {
357 $frame_ancestors_value = "'none'";
358 } elseif ( 'SAMEORIGIN' === $this->options['x_frame_options'] ) {
359 $frame_ancestors_value = "'self'";
360 }
361 }
362
363 if ( ! empty( $csp['directives'] ) && is_array( $csp['directives'] ) ) {
364 foreach ( $csp['directives'] as $directive => $value ) {
365 // Override frame-ancestors with synchronized value if X-Frame-Options is set
366 if ( 'frame-ancestors' === $directive && ! empty( $frame_ancestors_value ) ) {
367 $directives[] = $directive . ' ' . $frame_ancestors_value;
368 } elseif ( true === $value ) {
369 $directives[] = $directive;
370 } elseif ( false !== $value && ! empty( $value ) ) {
371 $directives[] = $directive . ' ' . $value;
372 }
373 }
374 }
375
376 // Add frame-ancestors if not already present but X-Frame-Options is set
377 if ( ! empty( $frame_ancestors_value ) ) {
378 $has_frame_ancestors = false;
379 foreach ( $directives as $dir ) {
380 if ( 0 === strpos( $dir, 'frame-ancestors' ) ) {
381 $has_frame_ancestors = true;
382 break;
383 }
384 }
385 if ( ! $has_frame_ancestors ) {
386 $directives[] = 'frame-ancestors ' . $frame_ancestors_value;
387 }
388 }
389
390 if ( ! empty( $directives ) ) {
391 $header_value = implode( '; ', $directives );
392
393 if ( ! empty( $csp['report_uri'] ) ) {
394 $header_value .= '; report-uri ' . esc_url( $csp['report_uri'] );
395 }
396
397 $header_name = ! empty( $csp['report_only'] )
398 ? 'Content-Security-Policy-Report-Only'
399 : 'Content-Security-Policy';
400
401 $rules[] = ' # Content Security Policy';
402
403 // If CSP is restrictive, only apply it outside wp-admin
404 if ( $is_csp_restrictive ) {
405 $rules[] = ' # Note: Restrictive CSP skipped for wp-admin to prevent breaking the block editor';
406 $rules[] = ' Header always set ' . $header_name . ' "' . $header_value . '" env=!VIGILANTE_SKIP_CSP';
407 } else {
408 $rules[] = ' Header always set ' . $header_name . ' "' . $header_value . '"';
409 }
410 }
411 }
412
413 // Cross-Origin policies
414 if ( ! empty( $this->options['cross_origin_policies'] ) ) {
415 $policies = $this->options['cross_origin_policies'];
416
417 if ( ! empty( $policies['embedder_policy'] ) && 'unsafe-none' !== $policies['embedder_policy'] ) {
418 $rules[] = ' Header always set Cross-Origin-Embedder-Policy "' . esc_attr( $policies['embedder_policy'] ) . '"';
419 }
420
421 if ( ! empty( $policies['opener_policy'] ) ) {
422 $rules[] = ' Header always set Cross-Origin-Opener-Policy "' . esc_attr( $policies['opener_policy'] ) . '"';
423 }
424
425 if ( ! empty( $policies['resource_policy'] ) ) {
426 $rules[] = ' Header always set Cross-Origin-Resource-Policy "' . esc_attr( $policies['resource_policy'] ) . '"';
427 }
428 }
429
430 // Remove X-Powered-By
431 $rules[] = ' # Hide PHP version';
432 $rules[] = ' Header always unset X-Powered-By';
433
434 $rules[] = '</IfModule>';
435
436 return implode( "\n", $rules );
437 }
438
439 /**
440 * Get headers preview
441 *
442 * @return array
443 */
444 public function get_headers_preview() {
445 $headers = array();
446
447 if ( ! empty( $this->options['x_frame_options'] ) ) {
448 $headers['X-Frame-Options'] = $this->options['x_frame_options'];
449 }
450
451 if ( ! empty( $this->options['x_content_type_options'] ) ) {
452 $headers['X-Content-Type-Options'] = 'nosniff';
453 }
454
455 if ( ! empty( $this->options['referrer_policy'] ) ) {
456 $headers['Referrer-Policy'] = $this->options['referrer_policy'];
457 }
458
459 if ( ! empty( $this->options['hsts']['enabled'] ) ) {
460 $hsts = $this->options['hsts'];
461 $value = 'max-age=' . absint( $hsts['max_age'] );
462 if ( ! empty( $hsts['include_subdomains'] ) ) {
463 $value .= '; includeSubDomains';
464 }
465 if ( ! empty( $hsts['preload'] ) ) {
466 $value .= '; preload';
467 }
468 $headers['Strict-Transport-Security'] = $value;
469 }
470
471 return $headers;
472 }
473
474 /**
475 * Get security grade based on enabled headers
476 *
477 * @return array
478 */
479 public function get_security_grade() {
480 $score = 0;
481 $enabled = array();
482 $missing = array();
483 $warnings = array();
484
485 // X-Frame-Options (15 points)
486 if ( ! empty( $this->options['x_frame_options'] ) ) {
487 $score += 15;
488 $enabled[] = 'X-Frame-Options: ' . $this->options['x_frame_options'];
489 } else {
490 $missing[] = 'X-Frame-Options';
491 }
492
493 // X-Content-Type-Options (15 points)
494 if ( ! empty( $this->options['x_content_type_options'] ) ) {
495 $score += 15;
496 $enabled[] = 'X-Content-Type-Options: nosniff';
497 } else {
498 $missing[] = 'X-Content-Type-Options';
499 }
500
501 // HSTS (20 points)
502 if ( ! empty( $this->options['hsts']['enabled'] ) ) {
503 $hsts = $this->options['hsts'];
504 if ( $hsts['max_age'] >= 31536000 ) {
505 $score += 20;
506 } else {
507 $score += 10;
508 $warnings[] = 'HSTS max-age should be at least 1 year (31536000 seconds)';
509 }
510 $enabled[] = 'Strict-Transport-Security';
511 } else {
512 $missing[] = 'Strict-Transport-Security (HSTS)';
513 }
514
515 // CSP (30 points; absorbs the 10 points freed by retiring the deprecated X-XSS-Protection)
516 if ( ! empty( $this->options['csp']['enabled'] ) ) {
517 if ( empty( $this->options['csp']['report_only'] ) ) {
518 $score += 30;
519 $enabled[] = 'Content-Security-Policy';
520
521 // Add warning if CSP is restrictive
522 if ( $this->is_csp_restrictive() ) {
523 $warnings[] = __( 'Restrictive CSP detected. Admin area is automatically excluded to prevent breaking the dashboard and block editor.', 'vigilante' );
524 }
525
526 // Check for specific compatibility issues
527 $csp_issues = $this->get_csp_compatibility_issues();
528 foreach ( $csp_issues as $issue ) {
529 if ( 'high' === $issue['severity'] ) {
530 $warnings[] = sprintf(
531 /* translators: 1: CSP directive name, 2: Issue description */
532 __( 'CSP %1$s: %2$s', 'vigilante' ),
533 $issue['directive'],
534 $issue['issue']
535 );
536 }
537 }
538 } else {
539 $score += 15;
540 $enabled[] = 'Content-Security-Policy-Report-Only';
541 $warnings[] = 'CSP is in report-only mode (recommended for testing)';
542 }
543 } else {
544 $missing[] = 'Content-Security-Policy';
545 }
546
547 // Referrer-Policy (10 points)
548 if ( ! empty( $this->options['referrer_policy'] ) ) {
549 $score += 10;
550 $enabled[] = 'Referrer-Policy: ' . $this->options['referrer_policy'];
551 } else {
552 $missing[] = 'Referrer-Policy';
553 }
554
555 // Permissions-Policy (10 points)
556 if ( ! empty( $this->options['permissions_policy']['enabled'] ) ) {
557 $score += 10;
558 $enabled[] = 'Permissions-Policy';
559 } else {
560 $missing[] = 'Permissions-Policy';
561 }
562
563 // Calculate grade
564 if ( $score >= 90 ) {
565 $grade = 'A';
566 } elseif ( $score >= 80 ) {
567 $grade = 'B';
568 } elseif ( $score >= 70 ) {
569 $grade = 'C';
570 } elseif ( $score >= 60 ) {
571 $grade = 'D';
572 } else {
573 $grade = 'F';
574 }
575
576 return array(
577 'grade' => $grade,
578 'score' => $score,
579 'headers' => $enabled,
580 'missing' => $missing,
581 'warnings' => $warnings,
582 );
583 }
584 }