PluginProbe
Vigilant – 100% Free Security Suite: Firewall, 2FA, Login, Headers, Scanner… / 2.11.9
Vigilant – 100% Free Security Suite: Firewall, 2FA, Login, Headers, Scanner… v2.11.9
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 2.9.3 All 86 releases
vigilante / includes / class-security-headers.php

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

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