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

604 lines 18.2 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 private $known_blocks = array(
55 '# BEGIN Vigilante Protection' => '# END Vigilante Protection',
56 '# BEGIN Vigilante Security Headers' => '# END Vigilante Security Headers',
57 '# BEGIN WordPress' => '# END WordPress',
58 );
59
60 /**
61 * Get singleton instance
62 *
63 * @return Vigilante_Htaccess_Manager
64 */
65 public static function get_instance() {
66 if ( null === self::$instance ) {
67 self::$instance = new self();
68 }
69 return self::$instance;
70 }
71
72 /**
73 * Constructor
74 */
75 private function __construct() {
76 $this->htaccess_path = ABSPATH . '.htaccess';
77 }
78
79 /**
80 * Add or update a block in .htaccess
81 *
82 * @param string $marker_start Start marker (e.g. "# BEGIN Vigilante Protection").
83 * @param string $marker_end End marker (e.g. "# END Vigilante Protection").
84 * @param string $rules Rules content (without markers).
85 * @param string $position Where to add: 'top' or 'before_wordpress'.
86 * @return bool|WP_Error
87 */
88 public function add_block( $marker_start, $marker_end, $rules, $position = 'top' ) {
89 // On a network the root .htaccess is shared by every site, so only the
90 // main site writes it. See Vigilante_Settings::can_write_shared_files().
91 if ( ! Vigilante_Settings::can_write_shared_files() ) {
92 return new WP_Error( 'network_not_owner', Vigilante_Settings::get_shared_files_notice() );
93 }
94
95 // Read current content
96 $content = $this->read_file();
97 if ( false === $content ) {
98 $content = '';
99 }
100
101 // Create backup before modification
102 if ( ! empty( $content ) ) {
103 $this->create_backup( $content );
104 }
105
106 // Remove existing block if present
107 $content = $this->remove_block_from_content( $content, $marker_start, $marker_end );
108
109 // Build new block
110 $block = $marker_start . "\n" . $rules . "\n" . $marker_end;
111
112 // Insert at correct position
113 $new_content = $this->insert_block( $content, $block, $position );
114
115 // Validate result
116 if ( ! $this->validate_content( $new_content ) ) {
117 return new WP_Error( 'invalid_result', __( 'Resulting .htaccess would be invalid', 'vigilante' ) );
118 }
119
120 // Write file
121 if ( $this->write_file( $new_content ) ) {
122 return true;
123 }
124
125 return new WP_Error( 'write_failed', __( 'Failed to write .htaccess', 'vigilante' ) );
126 }
127
128 /**
129 * Remove a block from .htaccess
130 *
131 * @param string $marker_start Start marker.
132 * @param string $marker_end End marker.
133 * @return bool|WP_Error
134 */
135 public function remove_block( $marker_start, $marker_end ) {
136 if ( ! Vigilante_Settings::can_write_shared_files() ) {
137 return new WP_Error( 'network_not_owner', Vigilante_Settings::get_shared_files_notice() );
138 }
139
140 // Read current content
141 $content = $this->read_file();
142
143 if ( false === $content || empty( $content ) ) {
144 return true; // Nothing to remove
145 }
146
147 // Check if block exists
148 if ( strpos( $content, $marker_start ) === false ) {
149 return true; // Block doesn't exist, nothing to do
150 }
151
152 // Create backup before modification
153 $this->create_backup( $content );
154
155 // Remove the block
156 $new_content = $this->remove_block_from_content( $content, $marker_start, $marker_end );
157
158 // Validate result - WordPress rules should still be there if they were before
159 if ( strpos( $content, '# BEGIN WordPress' ) !== false &&
160 strpos( $new_content, '# BEGIN WordPress' ) === false ) {
161 // WordPress rules were removed - this is wrong, restore backup
162 $this->restore_backup();
163 return new WP_Error( 'wordpress_rules_lost', __( 'Operation would remove WordPress rules, aborted', 'vigilante' ) );
164 }
165
166 // Write file
167 if ( $this->write_file( $new_content ) ) {
168 return true;
169 }
170
171 // Write failed, restore backup
172 $this->restore_backup();
173 return new WP_Error( 'write_failed', __( 'Failed to write .htaccess', 'vigilante' ) );
174 }
175
176 /**
177 * Check if a block exists in .htaccess
178 *
179 * @param string $marker_start Start marker.
180 * @return bool
181 */
182 public function block_exists( $marker_start ) {
183 $content = $this->read_file();
184 if ( false === $content ) {
185 return false;
186 }
187 return strpos( $content, $marker_start ) !== false;
188 }
189
190 /**
191 * Remove a specific block from content string
192 *
193 * @param string $content Content to modify.
194 * @param string $marker_start Start marker.
195 * @param string $marker_end End marker.
196 * @return string Modified content.
197 */
198 private function remove_block_from_content( $content, $marker_start, $marker_end ) {
199 if ( strpos( $content, $marker_start ) === false ) {
200 return $content;
201 }
202
203 // Use line-by-line approach for safety (regex can be unpredictable)
204 $lines = explode( "\n", $content );
205 $new_lines = array();
206 $inside_block = false;
207
208 foreach ( $lines as $line ) {
209 // Check for start marker
210 if ( trim( $line ) === $marker_start ) {
211 $inside_block = true;
212 continue;
213 }
214
215 // Check for end marker
216 if ( trim( $line ) === $marker_end ) {
217 $inside_block = false;
218 continue;
219 }
220
221 // Add line if not inside our block
222 if ( ! $inside_block ) {
223 $new_lines[] = $line;
224 }
225 }
226
227 // Join and clean up multiple empty lines
228 $result = implode( "\n", $new_lines );
229 $result = preg_replace( '/\n{3,}/', "\n\n", $result );
230 $result = trim( $result );
231
232 return $result;
233 }
234
235 /**
236 * Insert a block at the specified position
237 *
238 * @param string $content Current content.
239 * @param string $block Block to insert.
240 * @param string $position Position: 'top' or 'before_wordpress'.
241 * @return string Modified content.
242 */
243 private function insert_block( $content, $block, $position ) {
244 $content = trim( $content );
245
246 if ( empty( $content ) ) {
247 return $block . "\n";
248 }
249
250 if ( 'before_wordpress' === $position && strpos( $content, '# BEGIN WordPress' ) !== false ) {
251 // Insert before WordPress block
252 return preg_replace(
253 '/(# BEGIN WordPress)/i',
254 $block . "\n\n$1",
255 $content
256 );
257 }
258
259 // Default: insert at top
260 return $block . "\n\n" . $content;
261 }
262
263 /**
264 * Validate .htaccess content
265 *
266 * @param string $content Content to validate.
267 * @return bool
268 */
269 private function validate_content( $content ) {
270 // Empty content is valid (but unusual)
271 if ( empty( trim( $content ) ) ) {
272 return true;
273 }
274
275 // Check for unmatched block markers
276 foreach ( $this->known_blocks as $start => $end ) {
277 $has_start = strpos( $content, $start ) !== false;
278 $has_end = strpos( $content, $end ) !== false;
279
280 // If has start, must have end (and vice versa)
281 if ( $has_start !== $has_end ) {
282 return false;
283 }
284
285 // Start must come before end
286 if ( $has_start && $has_end ) {
287 if ( strpos( $content, $start ) > strpos( $content, $end ) ) {
288 return false;
289 }
290 }
291 }
292
293 // Check for obvious syntax errors
294 $error_patterns = array(
295 '/^<(?!IfModule|Directory|Files|FilesMatch|Location|LocationMatch|Limit|LimitExcept|Else|ElseIf|If|VirtualHost|Proxy|ProxyMatch|RequireAll|RequireAny|RequireNone|AuthnProviderAlias|AuthzProviderAlias)[^>]*>/im',
296 );
297
298 // Basic check: if it starts with PHP code, it's wrong
299 if ( preg_match( '/^<\?php/i', trim( $content ) ) ) {
300 return false;
301 }
302
303 return true;
304 }
305
306 /**
307 * Read .htaccess file
308 *
309 * @return string|false
310 */
311 private function read_file() {
312 if ( ! file_exists( $this->htaccess_path ) ) {
313 return '';
314 }
315
316 if ( ! is_readable( $this->htaccess_path ) ) {
317 return false;
318 }
319
320 $content = file_get_contents( $this->htaccess_path ); // phpcs:ignore
321
322 return ( false !== $content ) ? $content : false;
323 }
324
325 /**
326 * Write .htaccess file
327 *
328 * @param string $content Content to write.
329 * @return bool
330 */
331 private function write_file( $content ) {
332 // Ensure content ends with newline
333 $content = rtrim( $content ) . "\n";
334
335 // Initialize WP_Filesystem
336 global $wp_filesystem;
337 if ( ! function_exists( 'WP_Filesystem' ) ) {
338 require_once ABSPATH . 'wp-admin/includes/file.php';
339 }
340 WP_Filesystem();
341
342 if ( ! $wp_filesystem ) {
343 return false;
344 }
345
346 // Check writability
347 if ( file_exists( $this->htaccess_path ) ) {
348 if ( ! $wp_filesystem->is_writable( $this->htaccess_path ) ) {
349 return false;
350 }
351 } else {
352 if ( ! $wp_filesystem->is_writable( dirname( $this->htaccess_path ) ) ) {
353 return false;
354 }
355 }
356
357 // Write with WP_Filesystem
358 return $wp_filesystem->put_contents( $this->htaccess_path, $content, FS_CHMOD_FILE );
359 }
360
361 /**
362 * Create backup of current .htaccess
363 *
364 * @param string $content Content to backup.
365 * @return bool
366 */
367 private function create_backup( $content ) {
368 // Store the backup in a private database option instead of a file under
369 // the web root, so it can never be served over HTTP.
370 $stored = update_option(
371 'vigilante_htaccess_backup',
372 array(
373 'content' => (string) $content,
374 'time' => time(),
375 ),
376 false
377 );
378
379 // update_option() also returns false when the value is unchanged.
380 return ( false !== $stored ) || ( (string) $content === $this->get_backup_content() );
381 }
382
383 /**
384 * Get the stored .htaccess backup content, or '' if none.
385 *
386 * @return string
387 */
388 private function get_backup_content() {
389 $backup = get_option( 'vigilante_htaccess_backup' );
390 return ( is_array( $backup ) && isset( $backup['content'] ) ) ? (string) $backup['content'] : '';
391 }
392
393 /**
394 * Restore .htaccess from backup
395 *
396 * @return bool
397 */
398 public function restore_backup() {
399 $content = $this->get_backup_content();
400
401 if ( '' === $content ) {
402 return false;
403 }
404
405 return $this->write_file( $content );
406 }
407
408 /**
409 * Check if server is Apache/LiteSpeed
410 *
411 * @return bool
412 */
413 public function is_apache() {
414 $detected = null;
415
416 if ( function_exists( 'apache_get_modules' ) ) {
417 $detected = true;
418 } else {
419 $server = isset( $_SERVER['SERVER_SOFTWARE'] )
420 ? sanitize_text_field( wp_unslash( $_SERVER['SERVER_SOFTWARE'] ) )
421 : '';
422
423 if ( '' !== $server ) {
424 $detected = self::looks_like_apache( $server );
425
426 // Remember it, because a WP-CLI run has no request to look at.
427 if ( get_option( self::SERVER_OPTION ) !== $server ) {
428 update_option( self::SERVER_OPTION, $server, false );
429 }
430 }
431 }
432
433 /*
434 * Nothing in this request to go on, which is exactly what happens under
435 * WP-CLI: apache_get_modules() only exists under mod_php and
436 * SERVER_SOFTWARE is not defined on the command line. Until 2.9.9 that
437 * answered "not Apache" and every .htaccess write was refused, so a site
438 * activated with `wp plugin activate` silently got no server layer at
439 * all while the switches showed as on. So fall back to what a web
440 * request taught us earlier.
441 */
442 if ( null === $detected ) {
443 $remembered = (string) get_option( self::SERVER_OPTION, '' );
444
445 if ( '' !== $remembered ) {
446 $detected = self::looks_like_apache( $remembered );
447 }
448 }
449
450 /**
451 * Filter the Apache/LiteSpeed detection.
452 *
453 * The escape hatch for a site deployed entirely from the command line,
454 * where there has never been a web request to learn from.
455 *
456 * @since 2.9.9
457 *
458 * @param bool|null $detected True, false, or null when it could not be told.
459 */
460 $detected = apply_filters( 'vigilante_is_apache', $detected );
461
462 return ( true === $detected );
463 }
464
465 /**
466 * Vigilant blocks sitting in .htaccess files above the WordPress directory
467 *
468 * Apache applies the .htaccess of every directory above the one being
469 * served, and this class only ever writes and reads the one in ABSPATH. So
470 * a WordPress in a subfolder can be receiving rules from the block that the
471 * Vigilant of the parent installation left in the document root: the
472 * settings screen says the header is off, headers_list() does not show it,
473 * and the browser receives it all the same. Costed two rounds of diagnosis
474 * on a real site before it was understood, so it is worth naming the file.
475 *
476 * @since 2.9.9
477 *
478 * @return array<string,string[]> Absolute file path => markers found inside.
479 */
480 public function find_blocks_above() {
481 global $wp_filesystem;
482
483 if ( ! function_exists( 'WP_Filesystem' ) ) {
484 require_once ABSPATH . 'wp-admin/includes/file.php';
485 }
486 WP_Filesystem();
487
488 if ( ! $wp_filesystem ) {
489 return array();
490 }
491
492 $found = array();
493 $markers = array_keys( $this->known_blocks );
494 $dir = dirname( $this->htaccess_path );
495
496 // Bounded walk up to the filesystem root. Eight levels is well past any
497 // real docroot and keeps this cheap on a deep path.
498 for ( $level = 0; $level < 8; $level++ ) {
499 $parent = dirname( $dir );
500
501 if ( $parent === $dir || '' === $parent || '.' === $parent ) {
502 break;
503 }
504
505 $dir = $parent;
506 $file = $dir . '/.htaccess';
507
508 if ( ! $wp_filesystem->exists( $file ) || ! $wp_filesystem->is_readable( $file ) ) {
509 continue;
510 }
511
512 $content = $wp_filesystem->get_contents( $file );
513
514 if ( ! is_string( $content ) || '' === $content ) {
515 continue;
516 }
517
518 $hits = array();
519 foreach ( $markers as $marker ) {
520 // The WordPress block is not ours, only the Vigilant ones count.
521 if ( false === strpos( $marker, 'Vigilante' ) ) {
522 continue;
523 }
524 if ( false !== strpos( $content, $marker ) ) {
525 $hits[] = $marker;
526 }
527 }
528
529 if ( ! empty( $hits ) ) {
530 $found[ $file ] = $hits;
531 }
532 }
533
534 return $found;
535 }
536
537 /**
538 * Whether a SERVER_SOFTWARE string is Apache or LiteSpeed
539 *
540 * @since 2.9.9
541 *
542 * @param string $server Server software string.
543 * @return bool
544 */
545 private static function looks_like_apache( $server ) {
546 return ( false !== stripos( $server, 'apache' ) || false !== stripos( $server, 'litespeed' ) );
547 }
548
549 /**
550 * Whether the server could not be identified in this request
551 *
552 * Tells "we know it is not Apache" apart from "we cannot tell from here",
553 * which is what a WP-CLI run gets. The caller uses it to leave the work
554 * pending for the first web request instead of dropping it.
555 *
556 * @since 2.9.9
557 *
558 * @return bool
559 */
560 public function server_is_unknown() {
561 if ( function_exists( 'apache_get_modules' ) ) {
562 return false;
563 }
564
565 if ( ! empty( $_SERVER['SERVER_SOFTWARE'] ) ) {
566 return false;
567 }
568
569 return ( '' === (string) get_option( self::SERVER_OPTION, '' ) );
570 }
571
572 /**
573 * Check if .htaccess is writable
574 *
575 * @return bool
576 */
577 public function is_writable() {
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 if ( file_exists( $this->htaccess_path ) ) {
590 return $wp_filesystem->is_writable( $this->htaccess_path );
591 }
592 return $wp_filesystem->is_writable( ABSPATH );
593 }
594
595 /**
596 * Get current .htaccess content (for debugging)
597 *
598 * @return string
599 */
600 public function get_content() {
601 $content = $this->read_file();
602 return ( false !== $content ) ? $content : '';
603 }
604 }