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

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

775 lines 24.9 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * HTAccess Manager Class
4 *
5 * Centralized, safe management of .htaccess modifications
6 * Used by both Firewall and Security Headers modules
7 *
8 * @package Vigilante
9 */
10
11 // Prevent direct access
12 if ( ! defined( 'ABSPATH' ) ) {
13 exit;
14 }
15
16 /**
17 * Class Vigilante_Htaccess_Manager
18 *
19 * Provides atomic, safe operations on .htaccess file
20 */
21 class Vigilante_Htaccess_Manager {
22
23 /**
24 * Singleton instance
25 *
26 * @var Vigilante_Htaccess_Manager
27 */
28 private static $instance = null;
29
30 /**
31 * Option where the server software seen in a web request is remembered
32 *
33 * WP-CLI has no request to look at, so the detection made from the web is
34 * kept here and used as the fallback. See is_apache().
35 *
36 * @since 2.9.9
37 *
38 * @var string
39 */
40 const SERVER_OPTION = 'vigilante_server_software';
41
42 /**
43 * Path to .htaccess file
44 *
45 * @var string
46 */
47 private $htaccess_path;
48
49 /**
50 * Known block markers (start => end)
51 *
52 * @var array
53 */
54 /**
55 * Option holding the write lock, and how long a held lock is believed.
56 *
57 * @since 2.10.0
58 */
59 const LOCK_OPTION = 'vigilante_htaccess_write_lock';
60 const LOCK_TIMEOUT = 30;
61
62 /**
63 * Rolling history of replaced .htaccess versions, and the one-off snapshot
64 * taken from a site the 2.9.8 migration had already wiped.
65 *
66 * @since 2.10.0
67 */
68 const HISTORY_OPTION = 'vigilante_htaccess_history';
69 const HISTORY_ENTRIES = 5;
70 const HISTORY_MAX_BYTES = 262144;
71
72 private $known_blocks = array(
73 '# BEGIN Vigilante Protection' => '# END Vigilante Protection',
74 '# BEGIN Vigilante Security Headers' => '# END Vigilante Security Headers',
75 '# BEGIN WordPress' => '# END WordPress',
76 );
77
78 /**
79 * Get singleton instance
80 *
81 * @return Vigilante_Htaccess_Manager
82 */
83 public static function get_instance() {
84 if ( null === self::$instance ) {
85 self::$instance = new self();
86 }
87 return self::$instance;
88 }
89
90 /**
91 * Constructor
92 */
93 private function __construct() {
94 $this->htaccess_path = ABSPATH . '.htaccess';
95 }
96
97 /**
98 * Add or update a block in .htaccess
99 *
100 * @param string $marker_start Start marker (e.g. "# BEGIN Vigilante Protection").
101 * @param string $marker_end End marker (e.g. "# END Vigilante Protection").
102 * @param string $rules Rules content (without markers).
103 * @param string $position Where to add: 'top' or 'before_wordpress'.
104 * @return bool|WP_Error
105 */
106 public function add_block( $marker_start, $marker_end, $rules, $position = 'top' ) {
107 // On a network the root .htaccess is shared by every site, so only the
108 // main site writes it. See Vigilante_Settings::can_write_shared_files().
109 if ( ! Vigilante_Settings::can_write_shared_files() ) {
110 return new WP_Error( 'network_not_owner', Vigilante_Settings::get_shared_files_notice() );
111 }
112
113 /*
114 * One writer at a time. maybe_sync_server_files() runs on init, on every
115 * request, so the first visitors after an update can enter this
116 * read-modify-write at the same moment. Two concurrent writers of
117 * *different* blocks is the case that bites: the second one read the file
118 * before the first one wrote, so its write drops the block the first one
119 * had just added.
120 */
121 if ( ! $this->acquire_lock() ) {
122 return new WP_Error( 'locked', __( 'Another process is writing .htaccess right now', 'vigilante' ) );
123 }
124
125 try {
126 // Read current content
127 $original = $this->read_file();
128 if ( false === $original ) {
129 $original = '';
130 }
131
132 // Create backup before modification
133 if ( ! empty( $original ) ) {
134 $this->create_backup( $original );
135 }
136
137 // Remove existing block if present
138 $content = $this->remove_block_from_content( $original, $marker_start, $marker_end );
139
140 // Build new block
141 $block = $marker_start . "\n" . $rules . "\n" . $marker_end;
142
143 // Insert at correct position
144 $new_content = $this->insert_block( $content, $block, $position );
145
146 // Validate result
147 if ( ! $this->validate_content( $new_content ) ) {
148 return new WP_Error( 'invalid_result', __( 'Resulting .htaccess would be invalid', 'vigilante' ) );
149 }
150
151 // Write file
152 if ( ! $this->write_file( $new_content ) ) {
153 return new WP_Error( 'write_failed', __( 'Failed to write .htaccess', 'vigilante' ) );
154 }
155
156 /*
157 * Read back what actually landed. Writing is not the same as having
158 * written: a truncated write, a full disk or a filesystem layer that
159 * quietly mangles the content would otherwise leave the site serving a
160 * broken .htaccess with nobody the wiser, which on this file means a
161 * 500 on every page. If the file on disk does not validate, or does
162 * not contain the block that was just added, put back exactly what was
163 * there before and report the failure instead of walking away.
164 */
165 $written = $this->read_file();
166
167 if ( false === $written
168 || ! $this->validate_content( $written )
169 || false === strpos( $written, $marker_start )
170 ) {
171 if ( '' !== $original ) {
172 $this->write_file( $original );
173 }
174
175 return new WP_Error( 'verify_failed', __( 'The .htaccess was written but did not read back as expected, so the previous content was restored', 'vigilante' ) );
176 }
177
178 return true;
179 } finally {
180 $this->release_lock();
181 }
182 }
183
184 /**
185 * Take the write lock, or fail if another process holds it.
186 *
187 * add_option() is the atomic part: option_name carries a unique index, so
188 * exactly one caller can create the row. A lock older than the timeout is
189 * treated as abandoned (a fatal between acquire and release) and taken over,
190 * otherwise a single crash would freeze every future write.
191 *
192 * @since 2.10.0
193 * @return bool
194 */
195 private function acquire_lock() {
196 $now = time();
197 $held = get_option( self::LOCK_OPTION );
198
199 if ( false !== $held && is_numeric( $held ) && ( $now - (int) $held ) < self::LOCK_TIMEOUT ) {
200 return false;
201 }
202
203 if ( false !== $held ) {
204 // Abandoned lock: take it over.
205 update_option( self::LOCK_OPTION, $now, false );
206 return true;
207 }
208
209 return (bool) add_option( self::LOCK_OPTION, $now, '', false );
210 }
211
212 /**
213 * Release the write lock.
214 *
215 * @since 2.10.0
216 */
217 private function release_lock() {
218 delete_option( self::LOCK_OPTION );
219 }
220
221 /**
222 * Remove a block from .htaccess
223 *
224 * @param string $marker_start Start marker.
225 * @param string $marker_end End marker.
226 * @return bool|WP_Error
227 */
228 public function remove_block( $marker_start, $marker_end ) {
229 if ( ! Vigilante_Settings::can_write_shared_files() ) {
230 return new WP_Error( 'network_not_owner', Vigilante_Settings::get_shared_files_notice() );
231 }
232
233 // Read current content
234 $content = $this->read_file();
235
236 if ( false === $content || empty( $content ) ) {
237 return true; // Nothing to remove
238 }
239
240 // Check if block exists
241 if ( strpos( $content, $marker_start ) === false ) {
242 return true; // Block doesn't exist, nothing to do
243 }
244
245 // Create backup before modification
246 $this->create_backup( $content );
247
248 // Remove the block
249 $new_content = $this->remove_block_from_content( $content, $marker_start, $marker_end );
250
251 // Validate result - WordPress rules should still be there if they were before
252 if ( strpos( $content, '# BEGIN WordPress' ) !== false &&
253 strpos( $new_content, '# BEGIN WordPress' ) === false ) {
254 // WordPress rules were removed - this is wrong, restore backup
255 $this->restore_backup();
256 return new WP_Error( 'wordpress_rules_lost', __( 'Operation would remove WordPress rules, aborted', 'vigilante' ) );
257 }
258
259 // Write file
260 if ( $this->write_file( $new_content ) ) {
261 return true;
262 }
263
264 // Write failed, restore backup
265 $this->restore_backup();
266 return new WP_Error( 'write_failed', __( 'Failed to write .htaccess', 'vigilante' ) );
267 }
268
269 /**
270 * Check if a block exists in .htaccess
271 *
272 * @param string $marker_start Start marker.
273 * @return bool
274 */
275 public function block_exists( $marker_start ) {
276 $content = $this->read_file();
277 if ( false === $content ) {
278 return false;
279 }
280 return strpos( $content, $marker_start ) !== false;
281 }
282
283 /**
284 * Remove a specific block from content string
285 *
286 * @param string $content Content to modify.
287 * @param string $marker_start Start marker.
288 * @param string $marker_end End marker.
289 * @return string Modified content.
290 */
291 private function remove_block_from_content( $content, $marker_start, $marker_end ) {
292 if ( strpos( $content, $marker_start ) === false ) {
293 return $content;
294 }
295
296 // Use line-by-line approach for safety (regex can be unpredictable)
297 $lines = explode( "\n", $content );
298 $new_lines = array();
299 $inside_block = false;
300
301 foreach ( $lines as $line ) {
302 // Check for start marker
303 if ( trim( $line ) === $marker_start ) {
304 $inside_block = true;
305 continue;
306 }
307
308 // Check for end marker
309 if ( trim( $line ) === $marker_end ) {
310 $inside_block = false;
311 continue;
312 }
313
314 // Add line if not inside our block
315 if ( ! $inside_block ) {
316 $new_lines[] = $line;
317 }
318 }
319
320 // Join and clean up multiple empty lines
321 $result = implode( "\n", $new_lines );
322 $result = preg_replace( '/\n{3,}/', "\n\n", $result );
323 $result = trim( $result );
324
325 return $result;
326 }
327
328 /**
329 * Insert a block at the specified position
330 *
331 * @param string $content Current content.
332 * @param string $block Block to insert.
333 * @param string $position Position: 'top' or 'before_wordpress'.
334 * @return string Modified content.
335 */
336 private function insert_block( $content, $block, $position ) {
337 $content = trim( $content );
338
339 if ( empty( $content ) ) {
340 return $block . "\n";
341 }
342
343 if ( 'before_wordpress' === $position ) {
344 $pos = stripos( $content, '# BEGIN WordPress' );
345
346 /*
347 * Spliced by offset, never with preg_replace().
348 *
349 * The block used to be passed as the replacement argument, where
350 * "$1" and "\\1" are backreference syntax and a trailing backslash
351 * escapes whatever follows it. That silently ate the escaping that
352 * generate_whitelist_exceptions() had just applied: a User-Agent
353 * whitelist entry ending in a backslash reached the file as
354 * `"!Bot\\" [NC]`, the backslash escaped the closing quote, and
355 * Apache answered 500 for the whole site. Reproduced from the
356 * settings screen on 25 aug 2026. substr() copies the block
357 * verbatim, which is the only correct thing to do here.
358 */
359 if ( false !== $pos ) {
360 return substr( $content, 0, $pos ) . $block . "\n\n" . substr( $content, $pos );
361 }
362 }
363
364 // Default: insert at top
365 return $block . "\n\n" . $content;
366 }
367
368 /**
369 * Validate .htaccess content
370 *
371 * @param string $content Content to validate.
372 * @return bool
373 */
374 private function validate_content( $content ) {
375 // Empty content is valid (but unusual)
376 if ( empty( trim( $content ) ) ) {
377 return true;
378 }
379
380 // Check for unmatched block markers
381 foreach ( $this->known_blocks as $start => $end ) {
382 $has_start = strpos( $content, $start ) !== false;
383 $has_end = strpos( $content, $end ) !== false;
384
385 // If has start, must have end (and vice versa)
386 if ( $has_start !== $has_end ) {
387 return false;
388 }
389
390 // Start must come before end
391 if ( $has_start && $has_end ) {
392 if ( strpos( $content, $start ) > strpos( $content, $end ) ) {
393 return false;
394 }
395 }
396 }
397
398 // Basic check: if it starts with PHP code, it's wrong
399 if ( preg_match( '/^<\?php/i', trim( $content ) ) ) {
400 return false;
401 }
402
403 /*
404 * Every directive argument has to close the quotes it opens. A stray
405 * backslash right before the closing quote escapes it, the directive
406 * runs on into the rest of the line and Apache answers 500 for the
407 * whole site. That is not hypothetical: until 2.10.0 a User-Agent
408 * whitelist entry ending in a backslash did exactly that, and this
409 * function waved it through because the only syntax check it had was
410 * an unused array of patterns.
411 *
412 * Escaped pairs are removed first, so a legitimate \\" or \\\\ inside an
413 * argument is not miscounted.
414 */
415 foreach ( preg_split( '/\r\n|\r|\n/', $content ) as $line ) {
416 $unescaped = str_replace( array( '\\\\', '\\"' ), '', $line );
417
418 if ( 0 !== ( substr_count( $unescaped, '"' ) % 2 ) ) {
419 return false;
420 }
421 }
422
423 /*
424 * Container tags have to balance. An unclosed <IfModule> swallows every
425 * directive below it, including the ones WordPress itself wrote.
426 */
427 if ( preg_match_all( '/^\s*<IfModule\b/im', $content ) !== preg_match_all( '/^\s*<\/IfModule\s*>/im', $content ) ) {
428 return false;
429 }
430
431 return true;
432 }
433
434 /**
435 * Read .htaccess file
436 *
437 * @return string|false
438 */
439 private function read_file() {
440 if ( ! file_exists( $this->htaccess_path ) ) {
441 return '';
442 }
443
444 if ( ! is_readable( $this->htaccess_path ) ) {
445 return false;
446 }
447
448 $content = file_get_contents( $this->htaccess_path ); // phpcs:ignore
449
450 return ( false !== $content ) ? $content : false;
451 }
452
453 /**
454 * Write .htaccess file
455 *
456 * @param string $content Content to write.
457 * @return bool
458 */
459 private function write_file( $content ) {
460 // Ensure content ends with newline
461 $content = rtrim( $content ) . "\n";
462
463 // Initialize WP_Filesystem
464 global $wp_filesystem;
465 if ( ! function_exists( 'WP_Filesystem' ) ) {
466 require_once ABSPATH . 'wp-admin/includes/file.php';
467 }
468 WP_Filesystem();
469
470 if ( ! $wp_filesystem ) {
471 return false;
472 }
473
474 // Check writability
475 if ( file_exists( $this->htaccess_path ) ) {
476 if ( ! $wp_filesystem->is_writable( $this->htaccess_path ) ) {
477 return false;
478 }
479 } else {
480 if ( ! $wp_filesystem->is_writable( dirname( $this->htaccess_path ) ) ) {
481 return false;
482 }
483 }
484
485 // Write with WP_Filesystem
486 return $wp_filesystem->put_contents( $this->htaccess_path, $content, FS_CHMOD_FILE );
487 }
488
489 /**
490 * Create backup of current .htaccess
491 *
492 * @param string $content Content to backup.
493 * @return bool
494 */
495 private function create_backup( $content ) {
496 $this->push_history( (string) $content );
497
498 // Store the backup in a private database option instead of a file under
499 // the web root, so it can never be served over HTTP.
500 $stored = update_option(
501 'vigilante_htaccess_backup',
502 array(
503 'content' => (string) $content,
504 'time' => time(),
505 ),
506 false
507 );
508
509 // update_option() also returns false when the value is unchanged.
510 return ( false !== $stored ) || ( (string) $content === $this->get_backup_content() );
511 }
512
513 /**
514 * Keep the last few .htaccess versions, newest first.
515 *
516 * The single-slot backup above is the rollback buffer: it is overwritten by
517 * the very next write, which is right for its job and useless for anything
518 * else. Something that only becomes visible days later, such as a header
519 * that quietly stopped being sent, needs more than one step of history.
520 *
521 * Kept in the database with autoload off, never in a file under the web
522 * root. Bounded on both axes so a large .htaccess cannot inflate the
523 * options table: oversized files are not stored at all, rather than stored
524 * truncated, because half an .htaccess is worse than none.
525 *
526 * @since 2.10.0
527 * @param string $content Content being replaced.
528 */
529 private function push_history( $content ) {
530 if ( '' === $content || strlen( $content ) > self::HISTORY_MAX_BYTES ) {
531 return;
532 }
533
534 $history = get_option( self::HISTORY_OPTION );
535 $history = is_array( $history ) ? $history : array();
536
537 // Nothing changed, nothing to record.
538 if ( isset( $history[0]['content'] ) && $history[0]['content'] === $content ) {
539 return;
540 }
541
542 array_unshift(
543 $history,
544 array(
545 'content' => $content,
546 'time' => time(),
547 'version' => VIGILANTE_VERSION,
548 )
549 );
550
551 update_option( self::HISTORY_OPTION, array_slice( $history, 0, self::HISTORY_ENTRIES ), false );
552 }
553
554 /**
555 * Get the stored .htaccess backup content, or '' if none.
556 *
557 * @return string
558 */
559 private function get_backup_content() {
560 $backup = get_option( 'vigilante_htaccess_backup' );
561 return ( is_array( $backup ) && isset( $backup['content'] ) ) ? (string) $backup['content'] : '';
562 }
563
564 /**
565 * Restore .htaccess from backup
566 *
567 * @return bool
568 */
569 public function restore_backup() {
570 $content = $this->get_backup_content();
571
572 if ( '' === $content ) {
573 return false;
574 }
575
576 return $this->write_file( $content );
577 }
578
579 /**
580 * Check if server is Apache/LiteSpeed
581 *
582 * @return bool
583 */
584 public function is_apache() {
585 $detected = null;
586
587 if ( function_exists( 'apache_get_modules' ) ) {
588 $detected = true;
589 } else {
590 $server = isset( $_SERVER['SERVER_SOFTWARE'] )
591 ? sanitize_text_field( wp_unslash( $_SERVER['SERVER_SOFTWARE'] ) )
592 : '';
593
594 if ( '' !== $server ) {
595 $detected = self::looks_like_apache( $server );
596
597 // Remember it, because a WP-CLI run has no request to look at.
598 if ( get_option( self::SERVER_OPTION ) !== $server ) {
599 update_option( self::SERVER_OPTION, $server, false );
600 }
601 }
602 }
603
604 /*
605 * Nothing in this request to go on, which is exactly what happens under
606 * WP-CLI: apache_get_modules() only exists under mod_php and
607 * SERVER_SOFTWARE is not defined on the command line. Until 2.9.9 that
608 * answered "not Apache" and every .htaccess write was refused, so a site
609 * activated with `wp plugin activate` silently got no server layer at
610 * all while the switches showed as on. So fall back to what a web
611 * request taught us earlier.
612 */
613 if ( null === $detected ) {
614 $remembered = (string) get_option( self::SERVER_OPTION, '' );
615
616 if ( '' !== $remembered ) {
617 $detected = self::looks_like_apache( $remembered );
618 }
619 }
620
621 /**
622 * Filter the Apache/LiteSpeed detection.
623 *
624 * The escape hatch for a site deployed entirely from the command line,
625 * where there has never been a web request to learn from.
626 *
627 * @since 2.9.9
628 *
629 * @param bool|null $detected True, false, or null when it could not be told.
630 */
631 $detected = apply_filters( 'vigilante_is_apache', $detected );
632
633 return ( true === $detected );
634 }
635
636 /**
637 * Vigilant blocks sitting in .htaccess files above the WordPress directory
638 *
639 * Apache applies the .htaccess of every directory above the one being
640 * served, and this class only ever writes and reads the one in ABSPATH. So
641 * a WordPress in a subfolder can be receiving rules from the block that the
642 * Vigilant of the parent installation left in the document root: the
643 * settings screen says the header is off, headers_list() does not show it,
644 * and the browser receives it all the same. Costed two rounds of diagnosis
645 * on a real site before it was understood, so it is worth naming the file.
646 *
647 * @since 2.9.9
648 *
649 * @return array<string,string[]> Absolute file path => markers found inside.
650 */
651 public function find_blocks_above() {
652 global $wp_filesystem;
653
654 if ( ! function_exists( 'WP_Filesystem' ) ) {
655 require_once ABSPATH . 'wp-admin/includes/file.php';
656 }
657 WP_Filesystem();
658
659 if ( ! $wp_filesystem ) {
660 return array();
661 }
662
663 $found = array();
664 $markers = array_keys( $this->known_blocks );
665 $dir = dirname( $this->htaccess_path );
666
667 // Bounded walk up to the filesystem root. Eight levels is well past any
668 // real docroot and keeps this cheap on a deep path.
669 for ( $level = 0; $level < 8; $level++ ) {
670 $parent = dirname( $dir );
671
672 if ( $parent === $dir || '' === $parent || '.' === $parent ) {
673 break;
674 }
675
676 $dir = $parent;
677 $file = $dir . '/.htaccess';
678
679 if ( ! $wp_filesystem->exists( $file ) || ! $wp_filesystem->is_readable( $file ) ) {
680 continue;
681 }
682
683 $content = $wp_filesystem->get_contents( $file );
684
685 if ( ! is_string( $content ) || '' === $content ) {
686 continue;
687 }
688
689 $hits = array();
690 foreach ( $markers as $marker ) {
691 // The WordPress block is not ours, only the Vigilant ones count.
692 if ( false === strpos( $marker, 'Vigilante' ) ) {
693 continue;
694 }
695 if ( false !== strpos( $content, $marker ) ) {
696 $hits[] = $marker;
697 }
698 }
699
700 if ( ! empty( $hits ) ) {
701 $found[ $file ] = $hits;
702 }
703 }
704
705 return $found;
706 }
707
708 /**
709 * Whether a SERVER_SOFTWARE string is Apache or LiteSpeed
710 *
711 * @since 2.9.9
712 *
713 * @param string $server Server software string.
714 * @return bool
715 */
716 private static function looks_like_apache( $server ) {
717 return ( false !== stripos( $server, 'apache' ) || false !== stripos( $server, 'litespeed' ) );
718 }
719
720 /**
721 * Whether the server could not be identified in this request
722 *
723 * Tells "we know it is not Apache" apart from "we cannot tell from here",
724 * which is what a WP-CLI run gets. The caller uses it to leave the work
725 * pending for the first web request instead of dropping it.
726 *
727 * @since 2.9.9
728 *
729 * @return bool
730 */
731 public function server_is_unknown() {
732 if ( function_exists( 'apache_get_modules' ) ) {
733 return false;
734 }
735
736 if ( ! empty( $_SERVER['SERVER_SOFTWARE'] ) ) {
737 return false;
738 }
739
740 return ( '' === (string) get_option( self::SERVER_OPTION, '' ) );
741 }
742
743 /**
744 * Check if .htaccess is writable
745 *
746 * @return bool
747 */
748 public function is_writable() {
749 // Initialize WP_Filesystem
750 global $wp_filesystem;
751 if ( ! function_exists( 'WP_Filesystem' ) ) {
752 require_once ABSPATH . 'wp-admin/includes/file.php';
753 }
754 WP_Filesystem();
755
756 if ( ! $wp_filesystem ) {
757 return false;
758 }
759
760 if ( file_exists( $this->htaccess_path ) ) {
761 return $wp_filesystem->is_writable( $this->htaccess_path );
762 }
763 return $wp_filesystem->is_writable( ABSPATH );
764 }
765
766 /**
767 * Get current .htaccess content (for debugging)
768 *
769 * @return string
770 */
771 public function get_content() {
772 $content = $this->read_file();
773 return ( false !== $content ) ? $content : '';
774 }
775 }