PluginProbe
Vigilant – 100% Free Security Suite: Firewall, 2FA, Login, Headers, Scanner… / 2.10.4
Vigilant – 100% Free Security Suite: Firewall, 2FA, Login, Headers, Scanner… v2.10.4
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-htaccess-recovery.php

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

686 lines 23.1 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Header settings recovery
4 *
5 * Offers back the Security Headers configuration that the 2.9.8 migration
6 * wiped, reading it from the .htaccess snapshot Vigilante_Htaccess_Manager
7 * preserved before the first rewrite that would have overwritten it.
8 *
9 * Deliberately recovers SETTINGS, never the file. Restoring a stored .htaccess
10 * wholesale would also put back the WordPress block and every rule the host,
11 * the cache plugin or the CDN had added since, undoing work that has nothing to
12 * do with Vigilant, and on a site that has changed permalinks or moved to a
13 * different folder it would break routing outright. Reading our own block and
14 * writing the settings back leaves the file to be regenerated the normal way,
15 * through the validated write path, and fixes the settings screen too: a file
16 * restore would have left the screen showing factory values, so the next save
17 * would have wiped everything again.
18 *
19 * @package Vigilante
20 * @since 2.10.0
21 */
22
23 // Prevent direct access
24 if ( ! defined( 'ABSPATH' ) ) {
25 exit;
26 }
27
28 /**
29 * Class Vigilante_Htaccess_Recovery
30 */
31 class Vigilante_Htaccess_Recovery {
32
33 /**
34 * Set once the owner has restored or dismissed the offer.
35 */
36 const DISMISSED_OPTION = 'vigilante_htaccess_recovery_done';
37
38 /**
39 * The security_headers section as it was just before restoring, so the
40 * restore itself can be undone.
41 */
42 const UNDO_OPTION = 'vigilante_htaccess_recovery_undo';
43
44 /**
45 * Markers of the block that is read. Nothing outside them is ever parsed.
46 */
47 /**
48 * The one-off snapshot of the .htaccess taken before the first rewrite that
49 * would have overwritten the owner's real header configuration.
50 */
51 const SNAPSHOT_OPTION = 'vigilante_htaccess_pre_migration';
52
53 const BLOCK_START = '# BEGIN Vigilante Security Headers';
54 const BLOCK_END = '# END Vigilante Security Headers';
55
56 /**
57 * Values each header is allowed to carry back into the settings.
58 *
59 * The snapshot is a file on disk that anything could have edited, so it is
60 * treated as untrusted input: a value that is not on these lists is dropped
61 * rather than written into the options. Same lists the generator enforces.
62 *
63 * @return array
64 */
65 private static function allowed() {
66 return array(
67 'x_frame_options' => array( 'SAMEORIGIN', 'DENY' ),
68 'referrer_policy' => array(
69 'no-referrer',
70 'no-referrer-when-downgrade',
71 'origin',
72 'origin-when-cross-origin',
73 'same-origin',
74 'strict-origin',
75 'strict-origin-when-cross-origin',
76 'unsafe-url',
77 ),
78 'opener_policy' => array( 'unsafe-none', 'same-origin-allow-popups', 'same-origin' ),
79 'embedder_policy' => array( 'unsafe-none', 'require-corp', 'credentialless' ),
80 'resource_policy' => array( 'same-site', 'same-origin', 'cross-origin' ),
81 );
82 }
83
84 /**
85 * Whether there is a recovery to offer.
86 *
87 * @return bool
88 */
89 public static function is_available() {
90 if ( get_option( self::DISMISSED_OPTION ) ) {
91 return false;
92 }
93
94 $recovered = self::get_recovered_settings();
95
96 return ! empty( $recovered );
97 }
98
99 /**
100 * Whether a restore can still be taken back.
101 *
102 * @since 2.10.0
103 * @return bool
104 */
105 public static function has_undo() {
106 $previous = get_option( self::UNDO_OPTION );
107
108 return is_array( $previous ) && ! empty( $previous );
109 }
110
111 /**
112 * Preserve the .htaccess once, when the file and the settings disagree.
113 *
114 * Detecting the damage by the shape of the stored section does not work, and
115 * finding that out cost a day: register_setting() hangs
116 * Vigilante_Settings::validate_options() on this option as a sanitize
117 * callback, and validate_section() fills in a default for every key the input
118 * is missing. So the broken 2.9.8 migration, which passed two keys, did not
119 * leave a two-key section behind: it left a complete one with factory values
120 * in the twelve keys it dropped, which is indistinguishable from a site that
121 * simply never customised its headers.
122 *
123 * What is distinguishable is the desync that Albert reported: the file still
124 * describes what the owner chose while the options already say factory. So
125 * that is what is looked at here. The file is the older truth, and this is
126 * the last moment it exists.
127 *
128 * Called only when the installed version has changed, never on an ordinary
129 * save: a save legitimately leaves the file describing the previous values
130 * for the instant before it is rewritten, and capturing there would burn the
131 * single slot on a difference the owner made on purpose.
132 *
133 * @since 2.10.0
134 * @param string $content The .htaccess about to be rewritten.
135 * @param Vigilante_Settings $settings Settings instance.
136 */
137 public static function maybe_capture( $content, $settings ) {
138 if ( '' === (string) $content || false !== get_option( self::SNAPSHOT_OPTION, false ) ) {
139 return;
140 }
141
142 if ( '' === self::extract_block( $content ) ) {
143 return;
144 }
145
146 /*
147 * Compared as emitted directives, never as settings arrays. The two do
148 * not have the same shape and never will: an unsafe-none COEP is not
149 * written at all, the CSP carries a report_uri the header does not, and a
150 * boolean directive comes back from the file as a flag. Comparing the
151 * arrays reported a difference on a perfectly healthy site.
152 *
153 * What is comparable is what the server is being told. So the block on
154 * disk is measured against the block this configuration would write right
155 * now: if they say different things, the file is describing a
156 * configuration that is no longer stored anywhere.
157 */
158 require_once VIGILANTE_INCLUDES_DIR . 'class-security-headers.php';
159
160 // Solo nuestro bloque de cabeceras: el fichero entero trae también las
161 // directivas del bloque de Protección (unset X-Powered-By, unset Server),
162 // que el generador de cabeceras no produce y que harían diferir a
163 // cualquier sitio sano que tenga el cortafuegos activo.
164 $on_disk = self::directives( self::extract_block( $content ) );
165 $would = self::directives( ( new Vigilante_Security_Headers( $settings ) )->generate_rules_content() );
166
167 if ( empty( $on_disk ) || $on_disk === $would ) {
168 return;
169 }
170
171 update_option(
172 self::SNAPSHOT_OPTION,
173 array(
174 'content' => (string) $content,
175 'time' => time(),
176 'version' => VIGILANTE_VERSION,
177 ),
178 false
179 );
180 }
181
182 /**
183 * Every header directive a chunk of .htaccess sets, normalised for comparison.
184 *
185 * Comments, blank lines, indentation and order are all dropped: two blocks
186 * that tell the server the same things compare equal even if one was written
187 * by an older version with a different timestamp in its header comment.
188 *
189 * @since 2.10.0
190 * @param string $text Block or full file.
191 * @return array
192 */
193 private static function directives( $text ) {
194 $lines = array();
195
196 if ( preg_match_all( '/^\s*Header\s+always\s+(set|unset)\s+([A-Za-z0-9-]+)(?:\s+"([^"]*)")?/mi', (string) $text, $matches, PREG_SET_ORDER ) ) {
197 foreach ( $matches as $match ) {
198 $lines[] = strtolower( $match[1] ) . ' ' . strtolower( $match[2] ) . ' ' . ( isset( $match[3] ) ? trim( $match[3] ) : '' );
199 }
200 }
201
202 sort( $lines );
203
204 return $lines;
205 }
206
207 /**
208 * The stored snapshot, or an empty array.
209 *
210 * @return array
211 */
212 public static function get_snapshot() {
213 $snapshot = get_option( self::SNAPSHOT_OPTION );
214
215 return ( is_array( $snapshot ) && ! empty( $snapshot['content'] ) ) ? $snapshot : array();
216 }
217
218 /**
219 * The raw Vigilant header block from the snapshot, for the owner to read.
220 *
221 * @return string
222 */
223 public static function get_raw_block() {
224 $snapshot = self::get_snapshot();
225
226 if ( empty( $snapshot ) ) {
227 return '';
228 }
229
230 return self::extract_block( $snapshot['content'] );
231 }
232
233 /**
234 * Cut out our own block. Everything outside the markers is ignored.
235 *
236 * @param string $content Full .htaccess content.
237 * @return string
238 */
239 private static function extract_block( $content ) {
240 $start = strpos( $content, self::BLOCK_START );
241
242 if ( false === $start ) {
243 return '';
244 }
245
246 $end = strpos( $content, self::BLOCK_END, $start );
247
248 if ( false === $end ) {
249 return '';
250 }
251
252 return substr( $content, $start, ( $end - $start ) + strlen( self::BLOCK_END ) );
253 }
254
255 /**
256 * Read every "Header always set" line of the block into name => value.
257 *
258 * @param string $block Block content.
259 * @return array
260 */
261 private static function read_headers( $block ) {
262 $headers = array();
263
264 if ( ! preg_match_all( '/^\s*Header\s+always\s+set\s+([A-Za-z0-9-]+)\s+"([^"]*)"/mi', $block, $matches, PREG_SET_ORDER ) ) {
265 return $headers;
266 }
267
268 foreach ( $matches as $match ) {
269 $headers[ strtolower( $match[1] ) ] = trim( $match[2] );
270 }
271
272 return $headers;
273 }
274
275 /**
276 * The settings the snapshot describes, ready to be compared or written.
277 *
278 * @return array Section-shaped array, empty when there is nothing to offer.
279 */
280 public static function get_recovered_settings() {
281 return self::settings_from_block( self::get_raw_block() );
282 }
283
284 /**
285 * The settings a given .htaccess content describes, reading only our block.
286 *
287 * @since 2.10.0
288 * @param string $content Full .htaccess content.
289 * @return array
290 */
291 public static function settings_from_content( $content ) {
292 return self::settings_from_block( self::extract_block( (string) $content ) );
293 }
294
295 /**
296 * Turn one Vigilant header block into a settings array.
297 *
298 * @param string $block Block content.
299 * @return array
300 */
301 private static function settings_from_block( $block ) {
302 if ( '' === $block ) {
303 return array();
304 }
305
306 $headers = self::read_headers( $block );
307
308 if ( empty( $headers ) ) {
309 return array();
310 }
311
312 $allowed = self::allowed();
313 $recovered = array();
314
315 if ( isset( $headers['x-frame-options'] )
316 && in_array( strtoupper( $headers['x-frame-options'] ), $allowed['x_frame_options'], true )
317 ) {
318 $recovered['x_frame_options'] = strtoupper( $headers['x-frame-options'] );
319 }
320
321 if ( isset( $headers['referrer-policy'] )
322 && in_array( strtolower( $headers['referrer-policy'] ), $allowed['referrer_policy'], true )
323 ) {
324 $recovered['referrer_policy'] = strtolower( $headers['referrer-policy'] );
325 }
326
327 $hsts = self::read_hsts( $headers );
328
329 if ( ! empty( $hsts ) ) {
330 $recovered['hsts'] = $hsts;
331 }
332
333 $csp = self::read_csp( $headers );
334
335 if ( ! empty( $csp ) ) {
336 $recovered['csp'] = $csp;
337 }
338
339 $cross_origin = self::read_cross_origin( $headers, $allowed );
340
341 if ( ! empty( $cross_origin ) ) {
342 $recovered['cross_origin_policies'] = $cross_origin;
343 }
344
345 return $recovered;
346 }
347
348 /**
349 * Strict-Transport-Security back into the hsts sub-array.
350 *
351 * @param array $headers Parsed headers.
352 * @return array
353 */
354 private static function read_hsts( $headers ) {
355 if ( ! isset( $headers['strict-transport-security'] ) ) {
356 return array();
357 }
358
359 $value = strtolower( $headers['strict-transport-security'] );
360
361 if ( ! preg_match( '/max-age\s*=\s*(\d+)/', $value, $match ) ) {
362 return array();
363 }
364
365 return array(
366 'enabled' => true,
367 'max_age' => min( absint( $match[1] ), YEAR_IN_SECONDS * 2 ),
368 'include_subdomains' => ( false !== strpos( $value, 'includesubdomains' ) ),
369 'preload' => ( false !== strpos( $value, 'preload' ) ),
370 );
371 }
372
373 /**
374 * Content-Security-Policy back into the csp sub-array.
375 *
376 * @param array $headers Parsed headers.
377 * @return array
378 */
379 private static function read_csp( $headers ) {
380 $report_only = isset( $headers['content-security-policy-report-only'] );
381 $key = $report_only ? 'content-security-policy-report-only' : 'content-security-policy';
382
383 if ( ! isset( $headers[ $key ] ) || '' === $headers[ $key ] ) {
384 return array();
385 }
386
387 $directives = array();
388
389 foreach ( explode( ';', $headers[ $key ] ) as $chunk ) {
390 $chunk = trim( $chunk );
391
392 if ( '' === $chunk ) {
393 continue;
394 }
395
396 $parts = preg_split( '/\s+/', $chunk, 2 );
397 $name = strtolower( trim( $parts[0] ) );
398
399 // Directive names are a fixed vocabulary; anything else is noise.
400 if ( ! preg_match( '/^[a-z0-9-]+$/', $name ) ) {
401 continue;
402 }
403
404 // A valueless directive (upgrade-insecure-requests) is a flag.
405 $directives[ $name ] = isset( $parts[1] ) ? trim( $parts[1] ) : true;
406 }
407
408 if ( empty( $directives ) ) {
409 return array();
410 }
411
412 return array(
413 'enabled' => true,
414 'report_only' => $report_only,
415 'directives' => $directives,
416 );
417 }
418
419 /**
420 * The three cross-origin policies.
421 *
422 * @param array $headers Parsed headers.
423 * @param array $allowed Allowed values.
424 * @return array
425 */
426 private static function read_cross_origin( $headers, $allowed ) {
427 $map = array(
428 'cross-origin-opener-policy' => 'opener_policy',
429 'cross-origin-embedder-policy' => 'embedder_policy',
430 'cross-origin-resource-policy' => 'resource_policy',
431 );
432
433 $out = array();
434
435 foreach ( $map as $header => $key ) {
436 if ( ! isset( $headers[ $header ] ) ) {
437 continue;
438 }
439
440 $value = strtolower( $headers[ $header ] );
441
442 if ( in_array( $value, $allowed[ $key ], true ) ) {
443 $out[ $key ] = $value;
444 }
445 }
446
447 return $out;
448 }
449
450 /**
451 * What the owner is shown before deciding: setting, value now, value found.
452 *
453 * Only rows that would actually change are returned, so the list is the
454 * change itself rather than a dump of the whole section.
455 *
456 * @param Vigilante_Settings $settings Settings instance.
457 * @return array List of array( label, current, recovered ).
458 */
459 public static function get_diff( $settings ) {
460 $recovered = self::get_recovered_settings();
461
462 if ( empty( $recovered ) ) {
463 return array();
464 }
465
466 $current = $settings->get_section( 'security_headers' );
467 $rows = array();
468
469 $labels = array(
470 'x_frame_options' => __( 'X-Frame-Options', 'vigilante' ),
471 'referrer_policy' => __( 'Referrer-Policy', 'vigilante' ),
472 'hsts' => __( 'HSTS', 'vigilante' ),
473 'csp' => __( 'Content Security Policy', 'vigilante' ),
474 'cross_origin_policies' => __( 'Cross-Origin Policies', 'vigilante' ),
475 );
476
477 foreach ( $recovered as $key => $value ) {
478 $now = isset( $current[ $key ] ) ? $current[ $key ] : null;
479
480 /*
481 * Shown as what restoring would actually leave behind, which is the
482 * merge restore() performs, not the recovered value on its own.
483 *
484 * The difference is not academic. A COEP of unsafe-none is never
485 * written to the file, so it cannot be read back, and the recovered
486 * cross_origin_policies arrives with two of its three keys. Described
487 * raw, that read as "your COEP is about to disappear", when
488 * array_replace_recursive() keeps it untouched. Nothing may be written
489 * that was not shown, and nothing may be shown that will not happen.
490 */
491 $applied = ( is_array( $value ) && is_array( $now ) )
492 ? array_replace_recursive( $now, $value )
493 : $value;
494
495 /*
496 * Compared on the values, never on their descriptions. A description
497 * is a summary and summaries collide: two different policies both
498 * described as "14 directives" would have compared equal, so the CSP
499 * would have been restored without ever appearing in the list the
500 * owner approves.
501 */
502 if ( $now == $applied ) { // phpcs:ignore WordPress.PHP.StrictComparisons.LooseComparison -- arrays compare by key/value pairs regardless of order, which is what "same configuration" means here.
503 continue;
504 }
505
506 $rows[] = array(
507 'label' => isset( $labels[ $key ] ) ? $labels[ $key ] : $key,
508 'current' => self::describe( $key, $now ),
509 'recovered' => self::describe( $key, $applied ),
510 'detail' => ( 'csp' === $key ) ? self::describe_csp_changes( $now, $applied ) : '',
511 );
512 }
513
514 return $rows;
515 }
516
517 /**
518 * Name the CSP directives that actually differ.
519 *
520 * Two policies can both be "14 directives" and mean very different things,
521 * so the count alone is not something anyone can make a decision on.
522 *
523 * @param mixed $now Current csp value.
524 * @param array $recovered Recovered csp value.
525 * @return string
526 */
527 private static function describe_csp_changes( $now, $recovered ) {
528 $before = ( is_array( $now ) && isset( $now['directives'] ) && is_array( $now['directives'] ) ) ? $now['directives'] : array();
529 $after = ( isset( $recovered['directives'] ) && is_array( $recovered['directives'] ) ) ? $recovered['directives'] : array();
530
531 $changed = array();
532
533 foreach ( array_keys( $before + $after ) as $name ) {
534 $a = isset( $before[ $name ] ) ? $before[ $name ] : null;
535 $b = isset( $after[ $name ] ) ? $after[ $name ] : null;
536
537 if ( $a !== $b ) {
538 $changed[] = $name;
539 }
540 }
541
542 if ( empty( $changed ) ) {
543 return '';
544 }
545
546 sort( $changed );
547
548 /* translators: %s: comma-separated list of Content Security Policy directive names. */
549 return sprintf( __( 'Differs in: %s', 'vigilante' ), implode( ', ', $changed ) );
550 }
551
552 /**
553 * One-line human description of a setting value.
554 *
555 * @param string $key Setting key.
556 * @param mixed $value Setting value.
557 * @return string
558 */
559 private static function describe( $key, $value ) {
560 if ( null === $value || '' === $value ) {
561 return __( 'not set', 'vigilante' );
562 }
563
564 if ( 'hsts' === $key ) {
565 if ( empty( $value['enabled'] ) ) {
566 return __( 'off', 'vigilante' );
567 }
568
569 $parts = array( 'max-age=' . absint( isset( $value['max_age'] ) ? $value['max_age'] : 0 ) );
570
571 if ( ! empty( $value['include_subdomains'] ) ) {
572 $parts[] = 'includeSubDomains';
573 }
574
575 if ( ! empty( $value['preload'] ) ) {
576 $parts[] = 'preload';
577 }
578
579 return implode( '; ', $parts );
580 }
581
582 if ( 'csp' === $key ) {
583 if ( empty( $value['enabled'] ) ) {
584 return __( 'off', 'vigilante' );
585 }
586
587 $count = ( isset( $value['directives'] ) && is_array( $value['directives'] ) ) ? count( $value['directives'] ) : 0;
588
589 /* translators: %d: number of Content Security Policy directives. */
590 return sprintf( _n( '%d directive', '%d directives', $count, 'vigilante' ), $count );
591 }
592
593 if ( 'cross_origin_policies' === $key ) {
594 $bits = array();
595
596 foreach ( array( 'opener_policy', 'embedder_policy', 'resource_policy' ) as $sub ) {
597 if ( ! empty( $value[ $sub ] ) ) {
598 $bits[] = $value[ $sub ];
599 }
600 }
601
602 return $bits ? implode( ', ', $bits ) : __( 'not set', 'vigilante' );
603 }
604
605 return is_scalar( $value ) ? (string) $value : __( 'not set', 'vigilante' );
606 }
607
608 /**
609 * Write the recovered settings, keeping an undo copy.
610 *
611 * @param Vigilante_Settings $settings Settings instance.
612 * @return true|WP_Error
613 */
614 public static function restore( $settings ) {
615 $recovered = self::get_recovered_settings();
616
617 if ( empty( $recovered ) ) {
618 return new WP_Error( 'nothing_to_restore', __( 'There is nothing to restore.', 'vigilante' ) );
619 }
620
621 $current = $settings->get_section( 'security_headers' );
622
623 // Keep what it looked like first, so this is reversible too.
624 update_option( self::UNDO_OPTION, $current, false );
625
626 $settings->update_section( 'security_headers', array_replace_recursive( $current, $recovered ) );
627 $settings->clear_cache();
628
629 update_option( self::DISMISSED_OPTION, 1, false );
630
631 return self::rewrite_rules();
632 }
633
634 /**
635 * Put back the settings as they were before restoring.
636 *
637 * @param Vigilante_Settings $settings Settings instance.
638 * @return true|WP_Error
639 */
640 public static function undo( $settings ) {
641 $previous = get_option( self::UNDO_OPTION );
642
643 if ( ! is_array( $previous ) || empty( $previous ) ) {
644 return new WP_Error( 'no_undo', __( 'There is nothing to undo.', 'vigilante' ) );
645 }
646
647 $settings->update_section( 'security_headers', $previous );
648 $settings->clear_cache();
649 delete_option( self::UNDO_OPTION );
650
651 return self::rewrite_rules();
652 }
653
654 /**
655 * Stop offering the recovery, without changing any setting.
656 */
657 public static function dismiss() {
658 update_option( self::DISMISSED_OPTION, 1, false );
659 }
660
661 /**
662 * Regenerate the .htaccess from the settings, through the normal path.
663 *
664 * @return true|WP_Error
665 */
666 private static function rewrite_rules() {
667 $settings = new Vigilante_Settings();
668
669 if ( ! $settings->is_module_enabled( 'security_headers' ) ) {
670 return true;
671 }
672
673 require_once VIGILANTE_INCLUDES_DIR . 'class-security-headers.php';
674
675 $result = ( new Vigilante_Security_Headers( $settings ) )->apply_rules();
676
677 // Not being on Apache is not a failure: the settings are what matters,
678 // and there is no block to write on nginx.
679 if ( is_wp_error( $result ) && 'not_apache' === $result->get_error_code() ) {
680 return true;
681 }
682
683 return is_wp_error( $result ) ? $result : true;
684 }
685 }
686