PluginProbe
Vigilant – 100% Free Security Suite: Firewall, 2FA, Login, Headers, Scanner… / 2.11.8
Vigilant – 100% Free Security Suite: Firewall, 2FA, Login, Headers, Scanner… v2.11.8
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.11.8, at includes/class-htaccess-manager.php

900 lines 31.1 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', $automatic = false ) {
107 /*
108 * On a network the root .htaccess is shared by every site, so only the
109 * main site writes it.
110 *
111 * Which question to ask depends on who is asking. A write a person
112 * started from a settings screen has to clear the capability too, so one
113 * site's administrator cannot overwrite what the network decided. A write
114 * Vigilant performs by itself only has to come from the right site: it
115 * makes no decision, and demanding a capability of it means demanding one
116 * of whichever visitor happened to trigger the request, which nobody has.
117 *
118 * Getting that distinction wrong is what shipped in 2.10.0, and it is why
119 * no network ever had its .htaccess refreshed after an update.
120 */
121 $allowed = $automatic
122 ? Vigilante_Settings::owns_shared_files()
123 : Vigilante_Settings::can_write_shared_files();
124
125 if ( ! $allowed ) {
126 return new WP_Error( 'network_not_owner', Vigilante_Settings::get_shared_files_notice() );
127 }
128
129 /*
130 * One writer at a time. maybe_sync_server_files() runs on init, on every
131 * request, so the first visitors after an update can enter this
132 * read-modify-write at the same moment. Two concurrent writers of
133 * *different* blocks is the case that bites: the second one read the file
134 * before the first one wrote, so its write drops the block the first one
135 * had just added.
136 */
137 if ( ! $this->acquire_lock() ) {
138 return new WP_Error( 'locked', __( 'Another process is writing .htaccess right now', 'vigilante' ) );
139 }
140
141 try {
142 // Read current content
143 $original = $this->read_file();
144
145 /*
146 * read_file() answers '' when there is no file and false when there
147 * is one PHP cannot read. Until 2.11.8 both became an empty string
148 * here, so a .htaccess that PHP could write but not read was replaced
149 * whole by the Vigilant block, and every other rule in it was lost.
150 */
151 if ( false === $original ) {
152 return new WP_Error( 'read_failed', __( '.htaccess could not be read, so it was left as it is.', 'vigilante' ) );
153 }
154
155 /*
156 * A block that has lost a marker would take the rest of the file with
157 * it. The other known blocks are asked too: validate_content() below
158 * refuses any result where one of them is unmatched, and saying why
159 * here keeps that refusal from reading as a write failure worth
160 * retrying every hour.
161 */
162 $whole = $this->blocks_are_whole( $original, $marker_start, $marker_end );
163 foreach ( $this->known_blocks as $known_start => $known_end ) {
164 $whole = $whole && $this->blocks_are_whole( $original, $known_start, $known_end );
165 }
166 if ( ! $whole ) {
167 return new WP_Error( 'block_incomplete', __( 'A Vigilant block in .htaccess is missing one of its markers, so the file was left as it is.', 'vigilante' ) );
168 }
169
170 // Create backup before modification
171 if ( ! empty( $original ) ) {
172 $this->create_backup( $original );
173 }
174
175 // Remove existing block if present
176 $content = $this->remove_block_from_content( $original, $marker_start, $marker_end );
177
178 // Build new block
179 $block = $marker_start . "\n" . $rules . "\n" . $marker_end;
180
181 // Insert at correct position
182 $new_content = $this->insert_block( $content, $block, $position );
183
184 // Validate result
185 if ( ! $this->validate_content( $new_content ) ) {
186 return new WP_Error( 'invalid_result', __( 'Resulting .htaccess would be invalid', 'vigilante' ) );
187 }
188
189 // Write file
190 if ( ! $this->write_file( $new_content ) ) {
191 return new WP_Error( 'write_failed', __( 'Failed to write .htaccess', 'vigilante' ) );
192 }
193
194 /*
195 * Read back what actually landed. Writing is not the same as having
196 * written: a truncated write, a full disk or a filesystem layer that
197 * quietly mangles the content would otherwise leave the site serving a
198 * broken .htaccess with nobody the wiser, which on this file means a
199 * 500 on every page. If the file on disk does not validate, or does
200 * not contain the block that was just added, put back exactly what was
201 * there before and report the failure instead of walking away.
202 */
203 $written = $this->read_file();
204
205 if ( false === $written
206 || ! $this->validate_content( $written )
207 || false === strpos( $written, $marker_start )
208 ) {
209 if ( '' !== $original ) {
210 $this->write_file( $original );
211 }
212
213 return new WP_Error( 'verify_failed', __( 'The .htaccess was written but did not read back as expected, so the previous content was restored', 'vigilante' ) );
214 }
215
216 // Record the block as Vigilant's own. The integrity scan leaves
217 // out of its hash only the blocks whose fingerprint is recorded,
218 // so anything else carrying these markers is still checked.
219 if ( class_exists( 'Vigilante_File_Integrity' ) ) {
220 Vigilante_File_Integrity::remember_owned_block( '.htaccess', $marker_start, $block );
221 }
222
223 return true;
224 } finally {
225 $this->release_lock();
226 }
227 }
228
229 /**
230 * Take the write lock, or fail if another process holds it.
231 *
232 * Until 2.11.8 this relied on add_option() being atomic, and it is not: it
233 * runs INSERT ... ON DUPLICATE KEY UPDATE, so two writers arriving together
234 * both believed they held the lock. See Vigilante_Settings::acquire_option_lock().
235 * A lock older than the timeout still counts as abandoned and is taken over.
236 *
237 * @since 2.10.0
238 * @return bool
239 */
240 private function acquire_lock() {
241 return Vigilante_Settings::acquire_option_lock( self::LOCK_OPTION, self::LOCK_TIMEOUT );
242 }
243
244 /**
245 * Release the write lock.
246 *
247 * @since 2.10.0
248 */
249 private function release_lock() {
250 Vigilante_Settings::release_option_lock( self::LOCK_OPTION );
251 }
252
253 /**
254 * Drop the integrity scan's record of a block Vigilant no longer has in the file
255 *
256 * @since 2.11.5
257 *
258 * @param string $marker_start Start marker of the block.
259 */
260 private function forget_owned_block( $marker_start ) {
261 if ( class_exists( 'Vigilante_File_Integrity' ) ) {
262 Vigilante_File_Integrity::forget_owned_blocks( '.htaccess', $marker_start );
263 }
264 }
265
266 /**
267 * Remove a block from .htaccess
268 *
269 * Takes the same write lock as add_block(): until 2.11.0 this
270 * read-modify-write ran unlocked, so a removal racing an addition of a
271 * different block could drop the block that had just been written (S5).
272 *
273 * @param string $marker_start Start marker.
274 * @param string $marker_end End marker.
275 * @param bool $automatic True when Vigilant removes the block by itself
276 * (a mode expiring on cron), false when a person
277 * asked for it. Same distinction as add_block().
278 * @return bool|WP_Error
279 */
280 public function remove_block( $marker_start, $marker_end, $automatic = false ) {
281 $allowed = $automatic
282 ? Vigilante_Settings::owns_shared_files()
283 : Vigilante_Settings::can_write_shared_files();
284
285 if ( ! $allowed ) {
286 return new WP_Error( 'network_not_owner', Vigilante_Settings::get_shared_files_notice() );
287 }
288
289 if ( ! $this->acquire_lock() ) {
290 return new WP_Error( 'locked', __( 'Another process is writing .htaccess right now', 'vigilante' ) );
291 }
292
293 try {
294 // Read current content
295 $content = $this->read_file();
296
297 if ( false === $content || empty( $content ) ) {
298 // An unreadable file proves nothing about the block, so its
299 // record stays. A missing or empty one has no block left.
300 if ( false !== $content ) {
301 $this->forget_owned_block( $marker_start );
302 }
303 return true; // Nothing to remove
304 }
305
306 // Check if block exists
307 if ( strpos( $content, $marker_start ) === false ) {
308 $this->forget_owned_block( $marker_start );
309 return true; // Block doesn't exist, nothing to do
310 }
311
312 // A block that has lost a marker would take the rest of the file with it.
313 if ( ! $this->blocks_are_whole( $content, $marker_start, $marker_end ) ) {
314 return new WP_Error( 'block_incomplete', __( 'A Vigilant block in .htaccess is missing one of its markers, so the file was left as it is.', 'vigilante' ) );
315 }
316
317 // Create backup before modification
318 $this->create_backup( $content );
319
320 // Remove the block
321 $new_content = $this->remove_block_from_content( $content, $marker_start, $marker_end );
322
323 // Validate result - WordPress rules should still be there if they were before
324 if ( strpos( $content, '# BEGIN WordPress' ) !== false &&
325 strpos( $new_content, '# BEGIN WordPress' ) === false ) {
326 // WordPress rules were removed - this is wrong, restore backup
327 $this->restore_backup();
328 return new WP_Error( 'wordpress_rules_lost', __( 'Operation would remove WordPress rules, aborted', 'vigilante' ) );
329 }
330
331 // Write file
332 if ( $this->write_file( $new_content ) ) {
333 $this->forget_owned_block( $marker_start );
334 return true;
335 }
336
337 // Write failed, restore backup
338 $this->restore_backup();
339 return new WP_Error( 'write_failed', __( 'Failed to write .htaccess', 'vigilante' ) );
340 } finally {
341 $this->release_lock();
342 }
343 }
344
345 /**
346 * Check if a block exists in .htaccess
347 *
348 * @param string $marker_start Start marker.
349 * @return bool
350 */
351 public function block_exists( $marker_start ) {
352 $content = $this->read_file();
353 if ( false === $content ) {
354 return false;
355 }
356 return strpos( $content, $marker_start ) !== false;
357 }
358
359 /**
360 * Whether no start marker of a block is left without its end
361 *
362 * The removal below works line by line and keeps dropping lines from a start
363 * marker until it meets an end marker, so a start whose end is missing, or a
364 * second start before the end, takes everything after it. Neither existing
365 * check catches that: remove_block() only looks for "# BEGIN WordPress",
366 * which the rules Network Setup hands out do not carry, and the
367 * validate_content() of add_block() only compares marker pairs, which still
368 * match once both WordPress markers have been cut away. An end marker with
369 * no start before it is harmless to the removal, which just drops that
370 * line, so it does not count against the content.
371 *
372 * @since 2.11.6
373 *
374 * @param string $content Content to check.
375 * @param string $marker_start Start marker.
376 * @param string $marker_end End marker.
377 * @return bool
378 */
379 private function blocks_are_whole( $content, $marker_start, $marker_end ) {
380 $inside = false;
381
382 foreach ( explode( "\n", $content ) as $line ) {
383 $line = trim( $line );
384
385 if ( $line === $marker_start ) {
386 if ( $inside ) {
387 return false;
388 }
389 $inside = true;
390 } elseif ( $line === $marker_end ) {
391 $inside = false;
392 }
393 }
394
395 return ! $inside;
396 }
397
398 /**
399 * Remove a specific block from content string
400 *
401 * @param string $content Content to modify.
402 * @param string $marker_start Start marker.
403 * @param string $marker_end End marker.
404 * @return string Modified content.
405 */
406 private function remove_block_from_content( $content, $marker_start, $marker_end ) {
407 if ( strpos( $content, $marker_start ) === false ) {
408 return $content;
409 }
410
411 // Use line-by-line approach for safety (regex can be unpredictable)
412 $lines = explode( "\n", $content );
413 $new_lines = array();
414 $inside_block = false;
415
416 foreach ( $lines as $line ) {
417 // Check for start marker
418 if ( trim( $line ) === $marker_start ) {
419 $inside_block = true;
420 continue;
421 }
422
423 // Check for end marker
424 if ( trim( $line ) === $marker_end ) {
425 $inside_block = false;
426 continue;
427 }
428
429 // Add line if not inside our block
430 if ( ! $inside_block ) {
431 $new_lines[] = $line;
432 }
433 }
434
435 // Join and clean up multiple empty lines
436 $result = implode( "\n", $new_lines );
437 $result = preg_replace( '/\n{3,}/', "\n\n", $result );
438 $result = trim( $result );
439
440 return $result;
441 }
442
443 /**
444 * Insert a block at the specified position
445 *
446 * @param string $content Current content.
447 * @param string $block Block to insert.
448 * @param string $position Position: 'top' or 'before_wordpress'.
449 * @return string Modified content.
450 */
451 private function insert_block( $content, $block, $position ) {
452 $content = trim( $content );
453
454 if ( empty( $content ) ) {
455 return $block . "\n";
456 }
457
458 if ( 'before_wordpress' === $position ) {
459 $pos = stripos( $content, '# BEGIN WordPress' );
460
461 /*
462 * Spliced by offset, never with preg_replace().
463 *
464 * The block used to be passed as the replacement argument, where
465 * "$1" and "\\1" are backreference syntax and a trailing backslash
466 * escapes whatever follows it. That silently ate the escaping that
467 * generate_whitelist_exceptions() had just applied: a User-Agent
468 * whitelist entry ending in a backslash reached the file as
469 * `"!Bot\\" [NC]`, the backslash escaped the closing quote, and
470 * Apache answered 500 for the whole site. Reproduced from the
471 * settings screen on 25 aug 2026. substr() copies the block
472 * verbatim, which is the only correct thing to do here.
473 */
474 if ( false !== $pos ) {
475 return substr( $content, 0, $pos ) . $block . "\n\n" . substr( $content, $pos );
476 }
477 }
478
479 // Default: insert at top
480 return $block . "\n\n" . $content;
481 }
482
483 /**
484 * Validate .htaccess content
485 *
486 * @param string $content Content to validate.
487 * @return bool
488 */
489 private function validate_content( $content ) {
490 // Empty content is valid (but unusual)
491 if ( empty( trim( $content ) ) ) {
492 return true;
493 }
494
495 // Check for unmatched block markers
496 foreach ( $this->known_blocks as $start => $end ) {
497 $has_start = strpos( $content, $start ) !== false;
498 $has_end = strpos( $content, $end ) !== false;
499
500 // If has start, must have end (and vice versa)
501 if ( $has_start !== $has_end ) {
502 return false;
503 }
504
505 // Start must come before end
506 if ( $has_start && $has_end ) {
507 if ( strpos( $content, $start ) > strpos( $content, $end ) ) {
508 return false;
509 }
510 }
511 }
512
513 // Basic check: if it starts with PHP code, it's wrong
514 if ( preg_match( '/^<\?php/i', trim( $content ) ) ) {
515 return false;
516 }
517
518 /*
519 * Every directive argument has to close the quotes it opens. A stray
520 * backslash right before the closing quote escapes it, the directive
521 * runs on into the rest of the line and Apache answers 500 for the
522 * whole site. That is not hypothetical: until 2.10.0 a User-Agent
523 * whitelist entry ending in a backslash did exactly that, and this
524 * function waved it through because the only syntax check it had was
525 * an unused array of patterns.
526 *
527 * Escaped pairs are removed first, so a legitimate \\" or \\\\ inside an
528 * argument is not miscounted.
529 */
530 foreach ( preg_split( '/\r\n|\r|\n/', $content ) as $line ) {
531 $unescaped = str_replace( array( '\\\\', '\\"' ), '', $line );
532
533 if ( 0 !== ( substr_count( $unescaped, '"' ) % 2 ) ) {
534 return false;
535 }
536 }
537
538 /*
539 * Container tags have to balance. An unclosed <IfModule> swallows every
540 * directive below it, including the ones WordPress itself wrote.
541 */
542 if ( preg_match_all( '/^\s*<IfModule\b/im', $content ) !== preg_match_all( '/^\s*<\/IfModule\s*>/im', $content ) ) {
543 return false;
544 }
545
546 return true;
547 }
548
549 /**
550 * Read .htaccess file
551 *
552 * @return string|false
553 */
554 private function read_file() {
555 if ( ! file_exists( $this->htaccess_path ) ) {
556 return '';
557 }
558
559 if ( ! is_readable( $this->htaccess_path ) ) {
560 return false;
561 }
562
563 $content = file_get_contents( $this->htaccess_path ); // phpcs:ignore
564
565 return ( false !== $content ) ? $content : false;
566 }
567
568 /**
569 * Write .htaccess file
570 *
571 * @param string $content Content to write.
572 * @return bool
573 */
574 private function write_file( $content ) {
575 // Ensure content ends with newline
576 $content = rtrim( $content ) . "\n";
577
578 // Initialize WP_Filesystem
579 global $wp_filesystem;
580 if ( ! function_exists( 'WP_Filesystem' ) ) {
581 require_once ABSPATH . 'wp-admin/includes/file.php';
582 }
583 WP_Filesystem();
584
585 if ( ! $wp_filesystem ) {
586 return false;
587 }
588
589 // Check writability
590 if ( file_exists( $this->htaccess_path ) ) {
591 if ( ! $wp_filesystem->is_writable( $this->htaccess_path ) ) {
592 return false;
593 }
594 } else {
595 if ( ! $wp_filesystem->is_writable( dirname( $this->htaccess_path ) ) ) {
596 return false;
597 }
598 }
599
600 /*
601 * Keep the permissions the file already has. put_contents() always sets
602 * a mode, and FS_CHMOD_FILE is "permissions of index.php | 0644", so
603 * until 2.11.6 every write left a .htaccess kept at 0640 at 0644 or
604 * wider. A mode that cannot be read falls back to the old one rather
605 * than to 0, which would lock the server out of the file.
606 */
607 $perms = file_exists( $this->htaccess_path ) ? fileperms( $this->htaccess_path ) : false;
608 $mode = ( false !== $perms && ( $perms & 0777 ) ) ? ( $perms & 0777 ) : FS_CHMOD_FILE;
609
610 // Write with WP_Filesystem
611 return $wp_filesystem->put_contents( $this->htaccess_path, $content, $mode );
612 }
613
614 /**
615 * Create backup of current .htaccess
616 *
617 * @param string $content Content to backup.
618 * @return bool
619 */
620 private function create_backup( $content ) {
621 $this->push_history( (string) $content );
622
623 // Store the backup in a private database option instead of a file under
624 // the web root, so it can never be served over HTTP.
625 $stored = update_option(
626 'vigilante_htaccess_backup',
627 array(
628 'content' => (string) $content,
629 'time' => time(),
630 ),
631 false
632 );
633
634 // update_option() also returns false when the value is unchanged.
635 return ( false !== $stored ) || ( (string) $content === $this->get_backup_content() );
636 }
637
638 /**
639 * Keep the last few .htaccess versions, newest first.
640 *
641 * The single-slot backup above is the rollback buffer: it is overwritten by
642 * the very next write, which is right for its job and useless for anything
643 * else. Something that only becomes visible days later, such as a header
644 * that quietly stopped being sent, needs more than one step of history.
645 *
646 * Kept in the database with autoload off, never in a file under the web
647 * root. Bounded on both axes so a large .htaccess cannot inflate the
648 * options table: oversized files are not stored at all, rather than stored
649 * truncated, because half an .htaccess is worse than none.
650 *
651 * @since 2.10.0
652 * @param string $content Content being replaced.
653 */
654 private function push_history( $content ) {
655 if ( '' === $content || strlen( $content ) > self::HISTORY_MAX_BYTES ) {
656 return;
657 }
658
659 $history = get_option( self::HISTORY_OPTION );
660 $history = is_array( $history ) ? $history : array();
661
662 // Nothing changed, nothing to record.
663 if ( isset( $history[0]['content'] ) && $history[0]['content'] === $content ) {
664 return;
665 }
666
667 array_unshift(
668 $history,
669 array(
670 'content' => $content,
671 'time' => time(),
672 'version' => VIGILANTE_VERSION,
673 )
674 );
675
676 update_option( self::HISTORY_OPTION, array_slice( $history, 0, self::HISTORY_ENTRIES ), false );
677 }
678
679 /**
680 * Get the stored .htaccess backup content, or '' if none.
681 *
682 * @return string
683 */
684 private function get_backup_content() {
685 $backup = get_option( 'vigilante_htaccess_backup' );
686 return ( is_array( $backup ) && isset( $backup['content'] ) ) ? (string) $backup['content'] : '';
687 }
688
689 /**
690 * Restore .htaccess from backup
691 *
692 * @return bool
693 */
694 public function restore_backup() {
695 $content = $this->get_backup_content();
696
697 if ( '' === $content ) {
698 return false;
699 }
700
701 return $this->write_file( $content );
702 }
703
704 /**
705 * Check if server is Apache/LiteSpeed
706 *
707 * @return bool
708 */
709 public function is_apache() {
710 $detected = null;
711
712 if ( function_exists( 'apache_get_modules' ) ) {
713 $detected = true;
714 } else {
715 $server = isset( $_SERVER['SERVER_SOFTWARE'] )
716 ? sanitize_text_field( wp_unslash( $_SERVER['SERVER_SOFTWARE'] ) )
717 : '';
718
719 if ( '' !== $server ) {
720 $detected = self::looks_like_apache( $server );
721
722 // Remember it, because a WP-CLI run has no request to look at.
723 if ( get_option( self::SERVER_OPTION ) !== $server ) {
724 update_option( self::SERVER_OPTION, $server, false );
725 }
726 }
727 }
728
729 /*
730 * Nothing in this request to go on, which is exactly what happens under
731 * WP-CLI: apache_get_modules() only exists under mod_php and
732 * SERVER_SOFTWARE is not defined on the command line. Until 2.9.9 that
733 * answered "not Apache" and every .htaccess write was refused, so a site
734 * activated with `wp plugin activate` silently got no server layer at
735 * all while the switches showed as on. So fall back to what a web
736 * request taught us earlier.
737 */
738 if ( null === $detected ) {
739 $remembered = (string) get_option( self::SERVER_OPTION, '' );
740
741 if ( '' !== $remembered ) {
742 $detected = self::looks_like_apache( $remembered );
743 }
744 }
745
746 /**
747 * Filter the Apache/LiteSpeed detection.
748 *
749 * The escape hatch for a site deployed entirely from the command line,
750 * where there has never been a web request to learn from.
751 *
752 * @since 2.9.9
753 *
754 * @param bool|null $detected True, false, or null when it could not be told.
755 */
756 $detected = apply_filters( 'vigilante_is_apache', $detected );
757
758 return ( true === $detected );
759 }
760
761 /**
762 * Vigilant blocks sitting in .htaccess files above the WordPress directory
763 *
764 * Apache applies the .htaccess of every directory above the one being
765 * served, and this class only ever writes and reads the one in ABSPATH. So
766 * a WordPress in a subfolder can be receiving rules from the block that the
767 * Vigilant of the parent installation left in the document root: the
768 * settings screen says the header is off, headers_list() does not show it,
769 * and the browser receives it all the same. Costed two rounds of diagnosis
770 * on a real site before it was understood, so it is worth naming the file.
771 *
772 * @since 2.9.9
773 *
774 * @return array<string,string[]> Absolute file path => markers found inside.
775 */
776 public function find_blocks_above() {
777 global $wp_filesystem;
778
779 if ( ! function_exists( 'WP_Filesystem' ) ) {
780 require_once ABSPATH . 'wp-admin/includes/file.php';
781 }
782 WP_Filesystem();
783
784 if ( ! $wp_filesystem ) {
785 return array();
786 }
787
788 $found = array();
789 $markers = array_keys( $this->known_blocks );
790 $dir = dirname( $this->htaccess_path );
791
792 // Bounded walk up to the filesystem root. Eight levels is well past any
793 // real docroot and keeps this cheap on a deep path.
794 for ( $level = 0; $level < 8; $level++ ) {
795 $parent = dirname( $dir );
796
797 if ( $parent === $dir || '' === $parent || '.' === $parent ) {
798 break;
799 }
800
801 $dir = $parent;
802 $file = $dir . '/.htaccess';
803
804 if ( ! $wp_filesystem->exists( $file ) || ! $wp_filesystem->is_readable( $file ) ) {
805 continue;
806 }
807
808 $content = $wp_filesystem->get_contents( $file );
809
810 if ( ! is_string( $content ) || '' === $content ) {
811 continue;
812 }
813
814 $hits = array();
815 foreach ( $markers as $marker ) {
816 // The WordPress block is not ours, only the Vigilant ones count.
817 if ( false === strpos( $marker, 'Vigilante' ) ) {
818 continue;
819 }
820 if ( false !== strpos( $content, $marker ) ) {
821 $hits[] = $marker;
822 }
823 }
824
825 if ( ! empty( $hits ) ) {
826 $found[ $file ] = $hits;
827 }
828 }
829
830 return $found;
831 }
832
833 /**
834 * Whether a SERVER_SOFTWARE string is Apache or LiteSpeed
835 *
836 * @since 2.9.9
837 *
838 * @param string $server Server software string.
839 * @return bool
840 */
841 private static function looks_like_apache( $server ) {
842 return ( false !== stripos( $server, 'apache' ) || false !== stripos( $server, 'litespeed' ) );
843 }
844
845 /**
846 * Whether the server could not be identified in this request
847 *
848 * Tells "we know it is not Apache" apart from "we cannot tell from here",
849 * which is what a WP-CLI run gets. The caller uses it to leave the work
850 * pending for the first web request instead of dropping it.
851 *
852 * @since 2.9.9
853 *
854 * @return bool
855 */
856 public function server_is_unknown() {
857 if ( function_exists( 'apache_get_modules' ) ) {
858 return false;
859 }
860
861 if ( ! empty( $_SERVER['SERVER_SOFTWARE'] ) ) {
862 return false;
863 }
864
865 return ( '' === (string) get_option( self::SERVER_OPTION, '' ) );
866 }
867
868 /**
869 * Check if .htaccess is writable
870 *
871 * @return bool
872 */
873 public function is_writable() {
874 // Initialize WP_Filesystem
875 global $wp_filesystem;
876 if ( ! function_exists( 'WP_Filesystem' ) ) {
877 require_once ABSPATH . 'wp-admin/includes/file.php';
878 }
879 WP_Filesystem();
880
881 if ( ! $wp_filesystem ) {
882 return false;
883 }
884
885 if ( file_exists( $this->htaccess_path ) ) {
886 return $wp_filesystem->is_writable( $this->htaccess_path );
887 }
888 return $wp_filesystem->is_writable( ABSPATH );
889 }
890
891 /**
892 * Get current .htaccess content (for debugging)
893 *
894 * @return string
895 */
896 public function get_content() {
897 $content = $this->read_file();
898 return ( false !== $content ) ? $content : '';
899 }
900 }