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 / class-security-headers.php

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

596 lines 23.3 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 * Whether the site address is an https:// one
314 *
315 * Read from the stored address and from nothing else. is_ssl() is false
316 * behind a proxy that terminates TLS and says nothing in a cron run or from
317 * WP-CLI, and home_url() takes its scheme from is_ssl() when the request is
318 * secure (wp-includes/link-template.php:3471-3477 in 7.1.2), so the same site
319 * would write different rules from an https request and from a scheduled
320 * task. Vigilante_Htaccess_Recovery and File
321 * Integrity compare the file with what this class would write now, so the
322 * answer has to be the same wherever it is asked.
323 *
324 * @since 3.0.2
325 *
326 * @return bool
327 */
328 private function site_address_is_https() {
329 return 0 === strpos( (string) get_option( 'home' ), 'https://' );
330 }
331
332 /**
333 * Generate rules content (without markers)
334 *
335 * Public since 2.10.0: it is also "what this configuration would write right
336 * now", which is what Vigilante_Htaccess_Recovery compares the file against.
337 *
338 * @return string
339 */
340 public function generate_rules_content() {
341 $rules = array();
342
343 $rules[] = '# Vigilante - Security Headers';
344 $rules[] = '# Generated: ' . gmdate( 'Y-m-d H:i:s' ) . ' UTC';
345
346 // Check if CSP is restrictive (could break admin interface)
347 $is_csp_restrictive = $this->is_csp_restrictive();
348
349 // If CSP is restrictive, we need to skip it for wp-admin to prevent breaking the admin interface
350 if ( $is_csp_restrictive ) {
351 $rules[] = '';
352 $rules[] = '# Skip restrictive CSP for WordPress admin area to prevent breaking the editor';
353 $rules[] = '<IfModule mod_setenvif.c>';
354 $rules[] = ' SetEnvIf Request_URI "^/wp-admin" VIGILANTE_SKIP_CSP';
355 $rules[] = ' SetEnvIf Request_URI "admin-ajax\\.php$" VIGILANTE_SKIP_CSP';
356 $rules[] = ' SetEnvIf Request_URI "wp-login\\.php$" VIGILANTE_SKIP_CSP';
357 $rules[] = '</IfModule>';
358 }
359
360 $rules[] = '';
361 $rules[] = '<IfModule mod_headers.c>';
362
363 // X-Frame-Options
364 if ( ! empty( $this->options['x_frame_options'] ) ) {
365 $value = $this->options['x_frame_options'];
366 $rules[] = ' # Clickjacking protection';
367 $rules[] = ' Header always set X-Frame-Options "' . $this->sanitize_header_value( $value ) . '"';
368 }
369
370 // X-Content-Type-Options
371 if ( ! empty( $this->options['x_content_type_options'] ) ) {
372 $rules[] = ' # Prevent MIME type sniffing';
373 $rules[] = ' Header always set X-Content-Type-Options "nosniff"';
374 }
375
376 // Referrer-Policy
377 if ( ! empty( $this->options['referrer_policy'] ) ) {
378 $value = $this->options['referrer_policy'];
379 $rules[] = ' # Referrer Policy';
380 $rules[] = ' Header always set Referrer-Policy "' . $this->sanitize_header_value( $value ) . '"';
381 }
382
383 // Strict-Transport-Security (HSTS)
384 if ( ! empty( $this->options['hsts']['enabled'] ) ) {
385 $hsts = $this->options['hsts'];
386 $value = 'max-age=' . absint( $hsts['max_age'] );
387
388 if ( ! empty( $hsts['include_subdomains'] ) ) {
389 $value .= '; includeSubDomains';
390 }
391
392 if ( ! empty( $hsts['preload'] ) ) {
393 $value .= '; preload';
394 }
395
396 $rules[] = ' # HTTP Strict Transport Security';
397 $rules[] = ' Header always set Strict-Transport-Security "' . $value . '"';
398 }
399
400 // Permissions-Policy
401 if ( ! empty( $this->options['permissions_policy']['enabled'] ) ) {
402 $permissions = $this->options['permissions_policy'];
403 $directives = array();
404
405 $policy_items = array(
406 'geolocation', 'microphone', 'camera', 'payment',
407 'usb', 'magnetometer', 'gyroscope', 'accelerometer',
408 );
409
410 foreach ( $policy_items as $item ) {
411 if ( isset( $permissions[ $item ] ) ) {
412 $directives[] = $item . '=' . $permissions[ $item ];
413 }
414 }
415
416 if ( ! empty( $directives ) ) {
417 $rules[] = ' # Permissions Policy';
418 $rules[] = ' Header always set Permissions-Policy "' . $this->sanitize_header_value( implode( ', ', $directives ) ) . '"';
419 }
420 }
421
422 // Content-Security-Policy
423 if ( ! empty( $this->options['csp']['enabled'] ) ) {
424 $csp = $this->options['csp'];
425 $directives = array();
426
427 // Synchronize frame-ancestors with X-Frame-Options
428 $frame_ancestors_value = '';
429 if ( ! empty( $this->options['x_frame_options'] ) ) {
430 if ( 'DENY' === $this->options['x_frame_options'] ) {
431 $frame_ancestors_value = "'none'";
432 } elseif ( 'SAMEORIGIN' === $this->options['x_frame_options'] ) {
433 $frame_ancestors_value = "'self'";
434 }
435 }
436
437 if ( ! empty( $csp['directives'] ) && is_array( $csp['directives'] ) ) {
438 $site_is_https = $this->site_address_is_https();
439
440 foreach ( $csp['directives'] as $directive => $value ) {
441 /*
442 * upgrade-insecure-requests tells the browser to ask for every
443 * http:// resource of the page over https. On a site whose
444 * address is http:// nothing answers there, so every
445 * stylesheet, script and image stops loading, and the factory
446 * policy carries it. It is written only when the site address
447 * is https://. The setting itself is left as it is, so the
448 * next rewrite of the block after the address changes writes
449 * it.
450 */
451 if ( 'upgrade-insecure-requests' === strtolower( (string) $directive ) && ! $site_is_https ) {
452 continue;
453 }
454
455 // Override frame-ancestors with synchronized value if X-Frame-Options is set
456 if ( 'frame-ancestors' === $directive && ! empty( $frame_ancestors_value ) ) {
457 $directives[] = $directive . ' ' . $frame_ancestors_value;
458 } elseif ( true === $value ) {
459 $directives[] = $directive;
460 } elseif ( false !== $value && ! empty( $value ) ) {
461 $directives[] = $directive . ' ' . $value;
462 }
463 }
464 }
465
466 // Add frame-ancestors if not already present but X-Frame-Options is set
467 if ( ! empty( $frame_ancestors_value ) ) {
468 $has_frame_ancestors = false;
469 foreach ( $directives as $dir ) {
470 if ( 0 === strpos( $dir, 'frame-ancestors' ) ) {
471 $has_frame_ancestors = true;
472 break;
473 }
474 }
475 if ( ! $has_frame_ancestors ) {
476 $directives[] = 'frame-ancestors ' . $frame_ancestors_value;
477 }
478 }
479
480 if ( ! empty( $directives ) ) {
481 $header_value = implode( '; ', $directives );
482
483 // The value travels inside a double-quoted argument of the
484 // "Header always set" line below. A double quote in a directive
485 // would close that argument early and leave the rest of the
486 // policy as stray arguments, which Apache rejects with a 500.
487 // CSP source expressions use single quotes ('self',
488 // 'unsafe-inline'), never double ones, so no legitimate policy
489 // can be affected. Done here rather than at save time so the
490 // guard holds however the value reached the option.
491 $header_value = $this->sanitize_header_value( $header_value );
492
493 if ( ! empty( $csp['report_uri'] ) ) {
494 $header_value .= '; report-uri ' . $this->sanitize_header_value( esc_url( $csp['report_uri'] ) );
495 }
496
497 $header_name = ! empty( $csp['report_only'] )
498 ? 'Content-Security-Policy-Report-Only'
499 : 'Content-Security-Policy';
500
501 $rules[] = ' # Content Security Policy';
502
503 // If CSP is restrictive, only apply it outside wp-admin
504 if ( $is_csp_restrictive ) {
505 $rules[] = ' # Note: Restrictive CSP skipped for wp-admin to prevent breaking the block editor';
506 $rules[] = ' Header always set ' . $header_name . ' "' . $header_value . '" env=!VIGILANTE_SKIP_CSP';
507 } else {
508 $rules[] = ' Header always set ' . $header_name . ' "' . $header_value . '"';
509 }
510 }
511 }
512
513 /*
514 * Cross-Origin policies.
515 *
516 * Allow-listed rather than escaped, for the same reason sanitize_header_value()
517 * runs at generation time: these three became editable in 2.10.0 and their value
518 * is written verbatim inside a Header directive. Only the tokens the specs define
519 * are ever emitted, so an unexpected value (a crafted request, an imported
520 * settings file, a direct write to the option) drops the header instead of
521 * reaching the .htaccess.
522 *
523 * COEP deliberately omits unsafe-none: it is the browser default, so emitting it
524 * adds nothing. That was the behaviour before this list existed and it is kept.
525 */
526 if ( ! empty( $this->options['cross_origin_policies'] ) ) {
527 $policies = $this->options['cross_origin_policies'];
528
529 $coep = isset( $policies['embedder_policy'] ) ? (string) $policies['embedder_policy'] : '';
530 if ( in_array( $coep, array( 'require-corp', 'credentialless' ), true ) ) {
531 $rules[] = ' Header always set Cross-Origin-Embedder-Policy "' . $coep . '"';
532 }
533
534 $coop = isset( $policies['opener_policy'] ) ? (string) $policies['opener_policy'] : '';
535 if ( in_array( $coop, array( 'unsafe-none', 'same-origin-allow-popups', 'same-origin' ), true ) ) {
536 $rules[] = ' Header always set Cross-Origin-Opener-Policy "' . $coop . '"';
537 }
538
539 $corp = isset( $policies['resource_policy'] ) ? (string) $policies['resource_policy'] : '';
540 if ( in_array( $corp, array( 'same-site', 'same-origin', 'cross-origin' ), true ) ) {
541 $rules[] = ' Header always set Cross-Origin-Resource-Policy "' . $corp . '"';
542 }
543 }
544
545 // Remove X-Powered-By
546 $rules[] = ' # Hide PHP version';
547 $rules[] = ' Header always unset X-Powered-By';
548
549 $rules[] = '</IfModule>';
550
551 return implode( "\n", $rules );
552 }
553
554 /**
555 * Warnings about the Content-Security-Policy as it is configured
556 *
557 * They are about what is saved in the tab, not about what reaches the
558 * browser: that is measured by the checks of Security Check, which is
559 * also what the Test Headers button shows. Until 3.0.4 that button graded
560 * these settings and listed them as enabled headers without asking the
561 * site for anything.
562 *
563 * @since 3.0.5
564 *
565 * @return string[]
566 */
567 public function get_csp_warnings() {
568 $warnings = array();
569
570 if ( empty( $this->options['csp']['enabled'] ) ) {
571 return $warnings;
572 }
573
574 if ( ! empty( $this->options['csp']['report_only'] ) ) {
575 $warnings[] = __( 'CSP is in report-only mode: the browser reports what the policy would block, and blocks nothing.', 'vigilante' );
576 return $warnings;
577 }
578
579 if ( $this->is_csp_restrictive() ) {
580 $warnings[] = __( 'Restrictive CSP detected. Admin area is automatically excluded to prevent breaking the dashboard and block editor.', 'vigilante' );
581 }
582
583 foreach ( $this->get_csp_compatibility_issues() as $issue ) {
584 if ( 'high' === $issue['severity'] ) {
585 $warnings[] = sprintf(
586 /* translators: 1: CSP directive name, 2: Issue description */
587 __( 'CSP %1$s: %2$s', 'vigilante' ),
588 $issue['directive'],
589 $issue['issue']
590 );
591 }
592 }
593
594 return $warnings;
595 }
596 }