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

880 lines 30.8 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 * The single-slot rollback buffer, cleared at the end of every operation.
64 *
65 * @since 2.10.0
66 */
67 const BACKUP_OPTION = 'vigilante_htaccess_backup';
68
69 private $known_blocks = array(
70 '# BEGIN Vigilante Protection' => '# END Vigilante Protection',
71 '# BEGIN Vigilante Security Headers' => '# END Vigilante Security Headers',
72 '# BEGIN WordPress' => '# END WordPress',
73 );
74
75 /**
76 * Get singleton instance
77 *
78 * @return Vigilante_Htaccess_Manager
79 */
80 public static function get_instance() {
81 if ( null === self::$instance ) {
82 self::$instance = new self();
83 }
84 return self::$instance;
85 }
86
87 /**
88 * Constructor
89 */
90 private function __construct() {
91 $this->htaccess_path = ABSPATH . '.htaccess';
92 }
93
94 /**
95 * Add or update a block in .htaccess
96 *
97 * @param string $marker_start Start marker (e.g. "# BEGIN Vigilante Protection").
98 * @param string $marker_end End marker (e.g. "# END Vigilante Protection").
99 * @param string $rules Rules content (without markers).
100 * @param string $position Where to add: 'top' or 'before_wordpress'.
101 * @return bool|WP_Error
102 */
103 public function add_block( $marker_start, $marker_end, $rules, $position = 'top', $automatic = false ) {
104 /*
105 * On a network the root .htaccess is shared by every site, so only the
106 * main site writes it.
107 *
108 * Which question to ask depends on who is asking. A write a person
109 * started from a settings screen has to clear the capability too, so one
110 * site's administrator cannot overwrite what the network decided. A write
111 * Vigilant performs by itself only has to come from the right site: it
112 * makes no decision, and demanding a capability of it means demanding one
113 * of whichever visitor happened to trigger the request, which nobody has.
114 *
115 * Getting that distinction wrong is what shipped in 2.10.0, and it is why
116 * no network ever had its .htaccess refreshed after an update.
117 */
118 $allowed = $automatic
119 ? Vigilante_Settings::owns_shared_files()
120 : Vigilante_Settings::can_write_shared_files();
121
122 if ( ! $allowed ) {
123 return new WP_Error( 'network_not_owner', Vigilante_Settings::get_shared_files_notice() );
124 }
125
126 /*
127 * One writer at a time. maybe_sync_server_files() runs on init, on every
128 * request, so the first visitors after an update can enter this
129 * read-modify-write at the same moment. Two concurrent writers of
130 * *different* blocks is the case that bites: the second one read the file
131 * before the first one wrote, so its write drops the block the first one
132 * had just added.
133 */
134 if ( ! $this->acquire_lock() ) {
135 return new WP_Error( 'locked', __( 'Another process is writing .htaccess right now', 'vigilante' ) );
136 }
137
138 try {
139 // Read current content
140 $original = $this->read_file();
141
142 /*
143 * read_file() answers '' when there is no file and false when there
144 * is one PHP cannot read. Until 2.11.8 both became an empty string
145 * here, so a .htaccess that PHP could write but not read was replaced
146 * whole by the Vigilant block, and every other rule in it was lost.
147 */
148 if ( false === $original ) {
149 return new WP_Error( 'read_failed', __( '.htaccess could not be read, so it was left as it is.', 'vigilante' ) );
150 }
151
152 /*
153 * A block that has lost a marker would take the rest of the file with
154 * it. The other known blocks are asked too: validate_content() below
155 * refuses any result where one of them is unmatched, and saying why
156 * here keeps that refusal from reading as a write failure worth
157 * retrying every hour.
158 */
159 $whole = $this->blocks_are_whole( $original, $marker_start, $marker_end );
160 foreach ( $this->known_blocks as $known_start => $known_end ) {
161 $whole = $whole && $this->blocks_are_whole( $original, $known_start, $known_end );
162 }
163 if ( ! $whole ) {
164 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' ) );
165 }
166
167 // Create backup before modification
168 if ( ! empty( $original ) ) {
169 $this->create_backup( $original );
170 }
171
172 // Remove existing block if present
173 $content = $this->remove_block_from_content( $original, $marker_start, $marker_end );
174
175 // Build new block
176 $block = $marker_start . "\n" . $rules . "\n" . $marker_end;
177
178 // Insert at correct position
179 $new_content = $this->insert_block( $content, $block, $position );
180
181 // Validate result
182 if ( ! $this->validate_content( $new_content ) ) {
183 return new WP_Error( 'invalid_result', __( 'Resulting .htaccess would be invalid', 'vigilante' ) );
184 }
185
186 // Write file
187 if ( ! $this->write_file( $new_content ) ) {
188 return new WP_Error( 'write_failed', __( 'Failed to write .htaccess', 'vigilante' ) );
189 }
190
191 /*
192 * Read back what actually landed. Writing is not the same as having
193 * written: a truncated write, a full disk or a filesystem layer that
194 * quietly mangles the content would otherwise leave the site serving a
195 * broken .htaccess with nobody the wiser, which on this file means a
196 * 500 on every page. If the file on disk does not validate, or does
197 * not contain the block that was just added, put back exactly what was
198 * there before and report the failure instead of walking away.
199 */
200 $written = $this->read_file();
201
202 if ( false === $written
203 || ! $this->validate_content( $written )
204 || false === strpos( $written, $marker_start )
205 ) {
206 if ( '' !== $original ) {
207 $this->write_file( $original );
208 }
209
210 return new WP_Error( 'verify_failed', __( 'The .htaccess was written but did not read back as expected, so the previous content was restored', 'vigilante' ) );
211 }
212
213 // Record the block as Vigilant's own. The integrity scan leaves
214 // out of its hash only the blocks whose fingerprint is recorded,
215 // so anything else carrying these markers is still checked.
216 if ( class_exists( 'Vigilante_File_Integrity' ) ) {
217 Vigilante_File_Integrity::remember_owned_block( '.htaccess', $marker_start, $block );
218 }
219
220 return true;
221 } finally {
222 // No .htaccess content, which may hold secrets, is left in the
223 // options table after the operation. Since 2.11.9.
224 $this->clear_backup();
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 // No .htaccess content, which may hold secrets, is left in the
342 // options table after the operation. Since 2.11.9.
343 $this->clear_backup();
344 $this->release_lock();
345 }
346 }
347
348 /**
349 * Check if a block exists in .htaccess
350 *
351 * @param string $marker_start Start marker.
352 * @return bool
353 */
354 public function block_exists( $marker_start ) {
355 $content = $this->read_file();
356 if ( false === $content ) {
357 return false;
358 }
359 return strpos( $content, $marker_start ) !== false;
360 }
361
362 /**
363 * Whether no start marker of a block is left without its end
364 *
365 * The removal below works line by line and keeps dropping lines from a start
366 * marker until it meets an end marker, so a start whose end is missing, or a
367 * second start before the end, takes everything after it. Neither existing
368 * check catches that: remove_block() only looks for "# BEGIN WordPress",
369 * which the rules Network Setup hands out do not carry, and the
370 * validate_content() of add_block() only compares marker pairs, which still
371 * match once both WordPress markers have been cut away. An end marker with
372 * no start before it is harmless to the removal, which just drops that
373 * line, so it does not count against the content.
374 *
375 * @since 2.11.6
376 *
377 * @param string $content Content to check.
378 * @param string $marker_start Start marker.
379 * @param string $marker_end End marker.
380 * @return bool
381 */
382 private function blocks_are_whole( $content, $marker_start, $marker_end ) {
383 $inside = false;
384
385 foreach ( explode( "\n", $content ) as $line ) {
386 $line = trim( $line );
387
388 if ( $line === $marker_start ) {
389 if ( $inside ) {
390 return false;
391 }
392 $inside = true;
393 } elseif ( $line === $marker_end ) {
394 $inside = false;
395 }
396 }
397
398 return ! $inside;
399 }
400
401 /**
402 * Remove a specific block from content string
403 *
404 * @param string $content Content to modify.
405 * @param string $marker_start Start marker.
406 * @param string $marker_end End marker.
407 * @return string Modified content.
408 */
409 private function remove_block_from_content( $content, $marker_start, $marker_end ) {
410 if ( strpos( $content, $marker_start ) === false ) {
411 return $content;
412 }
413
414 // Use line-by-line approach for safety (regex can be unpredictable)
415 $lines = explode( "\n", $content );
416 $new_lines = array();
417 $inside_block = false;
418
419 foreach ( $lines as $line ) {
420 // Check for start marker
421 if ( trim( $line ) === $marker_start ) {
422 $inside_block = true;
423 continue;
424 }
425
426 // Check for end marker
427 if ( trim( $line ) === $marker_end ) {
428 $inside_block = false;
429 continue;
430 }
431
432 // Add line if not inside our block
433 if ( ! $inside_block ) {
434 $new_lines[] = $line;
435 }
436 }
437
438 // Join and clean up multiple empty lines
439 $result = implode( "\n", $new_lines );
440 $result = preg_replace( '/\n{3,}/', "\n\n", $result );
441 $result = trim( $result );
442
443 return $result;
444 }
445
446 /**
447 * Insert a block at the specified position
448 *
449 * @param string $content Current content.
450 * @param string $block Block to insert.
451 * @param string $position Position: 'top' or 'before_wordpress'.
452 * @return string Modified content.
453 */
454 private function insert_block( $content, $block, $position ) {
455 $content = trim( $content );
456
457 if ( empty( $content ) ) {
458 return $block . "\n";
459 }
460
461 if ( 'before_wordpress' === $position ) {
462 $pos = stripos( $content, '# BEGIN WordPress' );
463
464 /*
465 * Spliced by offset, never with preg_replace().
466 *
467 * The block used to be passed as the replacement argument, where
468 * "$1" and "\\1" are backreference syntax and a trailing backslash
469 * escapes whatever follows it. That silently ate the escaping that
470 * generate_whitelist_exceptions() had just applied: a User-Agent
471 * whitelist entry ending in a backslash reached the file as
472 * `"!Bot\\" [NC]`, the backslash escaped the closing quote, and
473 * Apache answered 500 for the whole site. Reproduced from the
474 * settings screen on 25 aug 2026. substr() copies the block
475 * verbatim, which is the only correct thing to do here.
476 */
477 if ( false !== $pos ) {
478 return substr( $content, 0, $pos ) . $block . "\n\n" . substr( $content, $pos );
479 }
480 }
481
482 // Default: insert at top
483 return $block . "\n\n" . $content;
484 }
485
486 /**
487 * Validate .htaccess content
488 *
489 * @param string $content Content to validate.
490 * @return bool
491 */
492 private function validate_content( $content ) {
493 // Empty content is valid (but unusual)
494 if ( empty( trim( $content ) ) ) {
495 return true;
496 }
497
498 // Check for unmatched block markers
499 foreach ( $this->known_blocks as $start => $end ) {
500 $has_start = strpos( $content, $start ) !== false;
501 $has_end = strpos( $content, $end ) !== false;
502
503 // If has start, must have end (and vice versa)
504 if ( $has_start !== $has_end ) {
505 return false;
506 }
507
508 // Start must come before end
509 if ( $has_start && $has_end ) {
510 if ( strpos( $content, $start ) > strpos( $content, $end ) ) {
511 return false;
512 }
513 }
514 }
515
516 // Basic check: if it starts with PHP code, it's wrong
517 if ( preg_match( '/^<\?php/i', trim( $content ) ) ) {
518 return false;
519 }
520
521 /*
522 * Every directive argument has to close the quotes it opens. A stray
523 * backslash right before the closing quote escapes it, the directive
524 * runs on into the rest of the line and Apache answers 500 for the
525 * whole site. That is not hypothetical: until 2.10.0 a User-Agent
526 * whitelist entry ending in a backslash did exactly that, and this
527 * function waved it through because the only syntax check it had was
528 * an unused array of patterns.
529 *
530 * Escaped pairs are removed first, so a legitimate \\" or \\\\ inside an
531 * argument is not miscounted.
532 */
533 foreach ( preg_split( '/\r\n|\r|\n/', $content ) as $line ) {
534 $unescaped = str_replace( array( '\\\\', '\\"' ), '', $line );
535
536 if ( 0 !== ( substr_count( $unescaped, '"' ) % 2 ) ) {
537 return false;
538 }
539 }
540
541 /*
542 * Container tags have to balance. An unclosed <IfModule> swallows every
543 * directive below it, including the ones WordPress itself wrote.
544 */
545 if ( preg_match_all( '/^\s*<IfModule\b/im', $content ) !== preg_match_all( '/^\s*<\/IfModule\s*>/im', $content ) ) {
546 return false;
547 }
548
549 return true;
550 }
551
552 /**
553 * Read .htaccess file
554 *
555 * @return string|false
556 */
557 private function read_file() {
558 if ( ! file_exists( $this->htaccess_path ) ) {
559 return '';
560 }
561
562 if ( ! is_readable( $this->htaccess_path ) ) {
563 return false;
564 }
565
566 $content = file_get_contents( $this->htaccess_path ); // phpcs:ignore
567
568 return ( false !== $content ) ? $content : false;
569 }
570
571 /**
572 * Write .htaccess file
573 *
574 * @param string $content Content to write.
575 * @return bool
576 */
577 private function write_file( $content ) {
578 // Ensure content ends with newline
579 $content = rtrim( $content ) . "\n";
580
581 // Initialize WP_Filesystem
582 global $wp_filesystem;
583 if ( ! function_exists( 'WP_Filesystem' ) ) {
584 require_once ABSPATH . 'wp-admin/includes/file.php';
585 }
586 WP_Filesystem();
587
588 if ( ! $wp_filesystem ) {
589 return false;
590 }
591
592 // Check writability
593 if ( file_exists( $this->htaccess_path ) ) {
594 if ( ! $wp_filesystem->is_writable( $this->htaccess_path ) ) {
595 return false;
596 }
597 } else {
598 if ( ! $wp_filesystem->is_writable( dirname( $this->htaccess_path ) ) ) {
599 return false;
600 }
601 }
602
603 /*
604 * Keep the permissions the file already has. put_contents() always sets
605 * a mode, and FS_CHMOD_FILE is "permissions of index.php | 0644", so
606 * until 2.11.6 every write left a .htaccess kept at 0640 at 0644 or
607 * wider. A mode that cannot be read falls back to the old one rather
608 * than to 0, which would lock the server out of the file.
609 */
610 $perms = file_exists( $this->htaccess_path ) ? fileperms( $this->htaccess_path ) : false;
611 $mode = ( false !== $perms && ( $perms & 0777 ) ) ? ( $perms & 0777 ) : FS_CHMOD_FILE;
612
613 // Write with WP_Filesystem
614 return $wp_filesystem->put_contents( $this->htaccess_path, $content, $mode );
615 }
616
617 /**
618 * Create backup of current .htaccess, for rollback within this operation only
619 *
620 * A .htaccess can carry secrets (SetEnv credentials, an Authorization
621 * header, a php_value with a key), so this rollback buffer is a live copy of
622 * the file and is cleared at the end of every add_block/remove_block, in the
623 * finally, rather than left sitting in the options table. Until 2.11.9 it
624 * persisted between operations and a rolling five-version history kept the
625 * raw content indefinitely, so anyone who read the database or a backup of
626 * it recovered those secrets without filesystem access. Reported by the
627 * wp.org automated review of 2.11.8. The history is gone; the buffer holds
628 * the real content because restoring a redacted one would write the marker
629 * into the live file, and lives only for the length of the write.
630 *
631 * @param string $content Content to backup.
632 * @return bool
633 */
634 private function create_backup( $content ) {
635 // A private option, never a file under the web root, and dropped again
636 // by clear_backup() in the finally of the operation that created it.
637 $stored = update_option(
638 self::BACKUP_OPTION,
639 array(
640 'content' => (string) $content,
641 'time' => time(),
642 ),
643 false
644 );
645
646 // update_option() also returns false when the value is unchanged.
647 return ( false !== $stored ) || ( (string) $content === $this->get_backup_content() );
648 }
649
650 /**
651 * Drop the rollback buffer, so no .htaccess content lingers in the options table
652 *
653 * @since 2.11.9
654 */
655 private function clear_backup() {
656 delete_option( self::BACKUP_OPTION );
657 }
658
659 /**
660 * Get the stored .htaccess backup content, or '' if none.
661 *
662 * @return string
663 */
664 private function get_backup_content() {
665 $backup = get_option( self::BACKUP_OPTION );
666 return ( is_array( $backup ) && isset( $backup['content'] ) ) ? (string) $backup['content'] : '';
667 }
668
669 /**
670 * Restore .htaccess from backup
671 *
672 * @return bool
673 */
674 public function restore_backup() {
675 $content = $this->get_backup_content();
676
677 if ( '' === $content ) {
678 return false;
679 }
680
681 return $this->write_file( $content );
682 }
683
684 /**
685 * Check if server is Apache/LiteSpeed
686 *
687 * @return bool
688 */
689 public function is_apache() {
690 $detected = null;
691
692 if ( function_exists( 'apache_get_modules' ) ) {
693 $detected = true;
694 } else {
695 $server = isset( $_SERVER['SERVER_SOFTWARE'] )
696 ? sanitize_text_field( wp_unslash( $_SERVER['SERVER_SOFTWARE'] ) )
697 : '';
698
699 if ( '' !== $server ) {
700 $detected = self::looks_like_apache( $server );
701
702 // Remember it, because a WP-CLI run has no request to look at.
703 if ( get_option( self::SERVER_OPTION ) !== $server ) {
704 update_option( self::SERVER_OPTION, $server, false );
705 }
706 }
707 }
708
709 /*
710 * Nothing in this request to go on, which is exactly what happens under
711 * WP-CLI: apache_get_modules() only exists under mod_php and
712 * SERVER_SOFTWARE is not defined on the command line. Until 2.9.9 that
713 * answered "not Apache" and every .htaccess write was refused, so a site
714 * activated with `wp plugin activate` silently got no server layer at
715 * all while the switches showed as on. So fall back to what a web
716 * request taught us earlier.
717 */
718 if ( null === $detected ) {
719 $remembered = (string) get_option( self::SERVER_OPTION, '' );
720
721 if ( '' !== $remembered ) {
722 $detected = self::looks_like_apache( $remembered );
723 }
724 }
725
726 /**
727 * Filter the Apache/LiteSpeed detection.
728 *
729 * The escape hatch for a site deployed entirely from the command line,
730 * where there has never been a web request to learn from.
731 *
732 * @since 2.9.9
733 *
734 * @param bool|null $detected True, false, or null when it could not be told.
735 */
736 $detected = apply_filters( 'vigilante_is_apache', $detected );
737
738 return ( true === $detected );
739 }
740
741 /**
742 * Vigilant blocks sitting in .htaccess files above the WordPress directory
743 *
744 * Apache applies the .htaccess of every directory above the one being
745 * served, and this class only ever writes and reads the one in ABSPATH. So
746 * a WordPress in a subfolder can be receiving rules from the block that the
747 * Vigilant of the parent installation left in the document root: the
748 * settings screen says the header is off, headers_list() does not show it,
749 * and the browser receives it all the same. Costed two rounds of diagnosis
750 * on a real site before it was understood, so it is worth naming the file.
751 *
752 * @since 2.9.9
753 *
754 * @return array<string,string[]> Absolute file path => markers found inside.
755 */
756 public function find_blocks_above() {
757 global $wp_filesystem;
758
759 if ( ! function_exists( 'WP_Filesystem' ) ) {
760 require_once ABSPATH . 'wp-admin/includes/file.php';
761 }
762 WP_Filesystem();
763
764 if ( ! $wp_filesystem ) {
765 return array();
766 }
767
768 $found = array();
769 $markers = array_keys( $this->known_blocks );
770 $dir = dirname( $this->htaccess_path );
771
772 // Bounded walk up to the filesystem root. Eight levels is well past any
773 // real docroot and keeps this cheap on a deep path.
774 for ( $level = 0; $level < 8; $level++ ) {
775 $parent = dirname( $dir );
776
777 if ( $parent === $dir || '' === $parent || '.' === $parent ) {
778 break;
779 }
780
781 $dir = $parent;
782 $file = $dir . '/.htaccess';
783
784 if ( ! $wp_filesystem->exists( $file ) || ! $wp_filesystem->is_readable( $file ) ) {
785 continue;
786 }
787
788 $content = $wp_filesystem->get_contents( $file );
789
790 if ( ! is_string( $content ) || '' === $content ) {
791 continue;
792 }
793
794 $hits = array();
795 foreach ( $markers as $marker ) {
796 // The WordPress block is not ours, only the Vigilant ones count.
797 if ( false === strpos( $marker, 'Vigilante' ) ) {
798 continue;
799 }
800 if ( false !== strpos( $content, $marker ) ) {
801 $hits[] = $marker;
802 }
803 }
804
805 if ( ! empty( $hits ) ) {
806 $found[ $file ] = $hits;
807 }
808 }
809
810 return $found;
811 }
812
813 /**
814 * Whether a SERVER_SOFTWARE string is Apache or LiteSpeed
815 *
816 * @since 2.9.9
817 *
818 * @param string $server Server software string.
819 * @return bool
820 */
821 private static function looks_like_apache( $server ) {
822 return ( false !== stripos( $server, 'apache' ) || false !== stripos( $server, 'litespeed' ) );
823 }
824
825 /**
826 * Whether the server could not be identified in this request
827 *
828 * Tells "we know it is not Apache" apart from "we cannot tell from here",
829 * which is what a WP-CLI run gets. The caller uses it to leave the work
830 * pending for the first web request instead of dropping it.
831 *
832 * @since 2.9.9
833 *
834 * @return bool
835 */
836 public function server_is_unknown() {
837 if ( function_exists( 'apache_get_modules' ) ) {
838 return false;
839 }
840
841 if ( ! empty( $_SERVER['SERVER_SOFTWARE'] ) ) {
842 return false;
843 }
844
845 return ( '' === (string) get_option( self::SERVER_OPTION, '' ) );
846 }
847
848 /**
849 * Check if .htaccess is writable
850 *
851 * @return bool
852 */
853 public function is_writable() {
854 // Initialize WP_Filesystem
855 global $wp_filesystem;
856 if ( ! function_exists( 'WP_Filesystem' ) ) {
857 require_once ABSPATH . 'wp-admin/includes/file.php';
858 }
859 WP_Filesystem();
860
861 if ( ! $wp_filesystem ) {
862 return false;
863 }
864
865 if ( file_exists( $this->htaccess_path ) ) {
866 return $wp_filesystem->is_writable( $this->htaccess_path );
867 }
868 return $wp_filesystem->is_writable( ABSPATH );
869 }
870
871 /**
872 * Get current .htaccess content (for debugging)
873 *
874 * @return string
875 */
876 public function get_content() {
877 $content = $this->read_file();
878 return ( false !== $content ) ? $content : '';
879 }
880 }