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

445 lines 12.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 * Path to .htaccess file
32 *
33 * @var string
34 */
35 private $htaccess_path;
36
37 /**
38 * Known block markers (start => end)
39 *
40 * @var array
41 */
42 private $known_blocks = array(
43 '# BEGIN Vigilante Protection' => '# END Vigilante Protection',
44 '# BEGIN Vigilante Security Headers' => '# END Vigilante Security Headers',
45 '# BEGIN WordPress' => '# END WordPress',
46 );
47
48 /**
49 * Get singleton instance
50 *
51 * @return Vigilante_Htaccess_Manager
52 */
53 public static function get_instance() {
54 if ( null === self::$instance ) {
55 self::$instance = new self();
56 }
57 return self::$instance;
58 }
59
60 /**
61 * Constructor
62 */
63 private function __construct() {
64 $this->htaccess_path = ABSPATH . '.htaccess';
65 }
66
67 /**
68 * Add or update a block in .htaccess
69 *
70 * @param string $marker_start Start marker (e.g. "# BEGIN Vigilante Protection").
71 * @param string $marker_end End marker (e.g. "# END Vigilante Protection").
72 * @param string $rules Rules content (without markers).
73 * @param string $position Where to add: 'top' or 'before_wordpress'.
74 * @return bool|WP_Error
75 */
76 public function add_block( $marker_start, $marker_end, $rules, $position = 'top' ) {
77 // On a network the root .htaccess is shared by every site, so only the
78 // main site writes it. See Vigilante_Settings::can_write_shared_files().
79 if ( ! Vigilante_Settings::can_write_shared_files() ) {
80 return new WP_Error( 'network_not_owner', Vigilante_Settings::get_shared_files_notice() );
81 }
82
83 // Read current content
84 $content = $this->read_file();
85 if ( false === $content ) {
86 $content = '';
87 }
88
89 // Create backup before modification
90 if ( ! empty( $content ) ) {
91 $this->create_backup( $content );
92 }
93
94 // Remove existing block if present
95 $content = $this->remove_block_from_content( $content, $marker_start, $marker_end );
96
97 // Build new block
98 $block = $marker_start . "\n" . $rules . "\n" . $marker_end;
99
100 // Insert at correct position
101 $new_content = $this->insert_block( $content, $block, $position );
102
103 // Validate result
104 if ( ! $this->validate_content( $new_content ) ) {
105 return new WP_Error( 'invalid_result', __( 'Resulting .htaccess would be invalid', 'vigilante' ) );
106 }
107
108 // Write file
109 if ( $this->write_file( $new_content ) ) {
110 return true;
111 }
112
113 return new WP_Error( 'write_failed', __( 'Failed to write .htaccess', 'vigilante' ) );
114 }
115
116 /**
117 * Remove a block from .htaccess
118 *
119 * @param string $marker_start Start marker.
120 * @param string $marker_end End marker.
121 * @return bool|WP_Error
122 */
123 public function remove_block( $marker_start, $marker_end ) {
124 if ( ! Vigilante_Settings::can_write_shared_files() ) {
125 return new WP_Error( 'network_not_owner', Vigilante_Settings::get_shared_files_notice() );
126 }
127
128 // Read current content
129 $content = $this->read_file();
130
131 if ( false === $content || empty( $content ) ) {
132 return true; // Nothing to remove
133 }
134
135 // Check if block exists
136 if ( strpos( $content, $marker_start ) === false ) {
137 return true; // Block doesn't exist, nothing to do
138 }
139
140 // Create backup before modification
141 $this->create_backup( $content );
142
143 // Remove the block
144 $new_content = $this->remove_block_from_content( $content, $marker_start, $marker_end );
145
146 // Validate result - WordPress rules should still be there if they were before
147 if ( strpos( $content, '# BEGIN WordPress' ) !== false &&
148 strpos( $new_content, '# BEGIN WordPress' ) === false ) {
149 // WordPress rules were removed - this is wrong, restore backup
150 $this->restore_backup();
151 return new WP_Error( 'wordpress_rules_lost', __( 'Operation would remove WordPress rules, aborted', 'vigilante' ) );
152 }
153
154 // Write file
155 if ( $this->write_file( $new_content ) ) {
156 return true;
157 }
158
159 // Write failed, restore backup
160 $this->restore_backup();
161 return new WP_Error( 'write_failed', __( 'Failed to write .htaccess', 'vigilante' ) );
162 }
163
164 /**
165 * Check if a block exists in .htaccess
166 *
167 * @param string $marker_start Start marker.
168 * @return bool
169 */
170 public function block_exists( $marker_start ) {
171 $content = $this->read_file();
172 if ( false === $content ) {
173 return false;
174 }
175 return strpos( $content, $marker_start ) !== false;
176 }
177
178 /**
179 * Remove a specific block from content string
180 *
181 * @param string $content Content to modify.
182 * @param string $marker_start Start marker.
183 * @param string $marker_end End marker.
184 * @return string Modified content.
185 */
186 private function remove_block_from_content( $content, $marker_start, $marker_end ) {
187 if ( strpos( $content, $marker_start ) === false ) {
188 return $content;
189 }
190
191 // Use line-by-line approach for safety (regex can be unpredictable)
192 $lines = explode( "\n", $content );
193 $new_lines = array();
194 $inside_block = false;
195
196 foreach ( $lines as $line ) {
197 // Check for start marker
198 if ( trim( $line ) === $marker_start ) {
199 $inside_block = true;
200 continue;
201 }
202
203 // Check for end marker
204 if ( trim( $line ) === $marker_end ) {
205 $inside_block = false;
206 continue;
207 }
208
209 // Add line if not inside our block
210 if ( ! $inside_block ) {
211 $new_lines[] = $line;
212 }
213 }
214
215 // Join and clean up multiple empty lines
216 $result = implode( "\n", $new_lines );
217 $result = preg_replace( '/\n{3,}/', "\n\n", $result );
218 $result = trim( $result );
219
220 return $result;
221 }
222
223 /**
224 * Insert a block at the specified position
225 *
226 * @param string $content Current content.
227 * @param string $block Block to insert.
228 * @param string $position Position: 'top' or 'before_wordpress'.
229 * @return string Modified content.
230 */
231 private function insert_block( $content, $block, $position ) {
232 $content = trim( $content );
233
234 if ( empty( $content ) ) {
235 return $block . "\n";
236 }
237
238 if ( 'before_wordpress' === $position && strpos( $content, '# BEGIN WordPress' ) !== false ) {
239 // Insert before WordPress block
240 return preg_replace(
241 '/(# BEGIN WordPress)/i',
242 $block . "\n\n$1",
243 $content
244 );
245 }
246
247 // Default: insert at top
248 return $block . "\n\n" . $content;
249 }
250
251 /**
252 * Validate .htaccess content
253 *
254 * @param string $content Content to validate.
255 * @return bool
256 */
257 private function validate_content( $content ) {
258 // Empty content is valid (but unusual)
259 if ( empty( trim( $content ) ) ) {
260 return true;
261 }
262
263 // Check for unmatched block markers
264 foreach ( $this->known_blocks as $start => $end ) {
265 $has_start = strpos( $content, $start ) !== false;
266 $has_end = strpos( $content, $end ) !== false;
267
268 // If has start, must have end (and vice versa)
269 if ( $has_start !== $has_end ) {
270 return false;
271 }
272
273 // Start must come before end
274 if ( $has_start && $has_end ) {
275 if ( strpos( $content, $start ) > strpos( $content, $end ) ) {
276 return false;
277 }
278 }
279 }
280
281 // Check for obvious syntax errors
282 $error_patterns = array(
283 '/^<(?!IfModule|Directory|Files|FilesMatch|Location|LocationMatch|Limit|LimitExcept|Else|ElseIf|If|VirtualHost|Proxy|ProxyMatch|RequireAll|RequireAny|RequireNone|AuthnProviderAlias|AuthzProviderAlias)[^>]*>/im',
284 );
285
286 // Basic check: if it starts with PHP code, it's wrong
287 if ( preg_match( '/^<\?php/i', trim( $content ) ) ) {
288 return false;
289 }
290
291 return true;
292 }
293
294 /**
295 * Read .htaccess file
296 *
297 * @return string|false
298 */
299 private function read_file() {
300 if ( ! file_exists( $this->htaccess_path ) ) {
301 return '';
302 }
303
304 if ( ! is_readable( $this->htaccess_path ) ) {
305 return false;
306 }
307
308 $content = file_get_contents( $this->htaccess_path ); // phpcs:ignore
309
310 return ( false !== $content ) ? $content : false;
311 }
312
313 /**
314 * Write .htaccess file
315 *
316 * @param string $content Content to write.
317 * @return bool
318 */
319 private function write_file( $content ) {
320 // Ensure content ends with newline
321 $content = rtrim( $content ) . "\n";
322
323 // Initialize WP_Filesystem
324 global $wp_filesystem;
325 if ( ! function_exists( 'WP_Filesystem' ) ) {
326 require_once ABSPATH . 'wp-admin/includes/file.php';
327 }
328 WP_Filesystem();
329
330 if ( ! $wp_filesystem ) {
331 return false;
332 }
333
334 // Check writability
335 if ( file_exists( $this->htaccess_path ) ) {
336 if ( ! $wp_filesystem->is_writable( $this->htaccess_path ) ) {
337 return false;
338 }
339 } else {
340 if ( ! $wp_filesystem->is_writable( dirname( $this->htaccess_path ) ) ) {
341 return false;
342 }
343 }
344
345 // Write with WP_Filesystem
346 return $wp_filesystem->put_contents( $this->htaccess_path, $content, FS_CHMOD_FILE );
347 }
348
349 /**
350 * Create backup of current .htaccess
351 *
352 * @param string $content Content to backup.
353 * @return bool
354 */
355 private function create_backup( $content ) {
356 // Store the backup in a private database option instead of a file under
357 // the web root, so it can never be served over HTTP.
358 $stored = update_option(
359 'vigilante_htaccess_backup',
360 array(
361 'content' => (string) $content,
362 'time' => time(),
363 ),
364 false
365 );
366
367 // update_option() also returns false when the value is unchanged.
368 return ( false !== $stored ) || ( (string) $content === $this->get_backup_content() );
369 }
370
371 /**
372 * Get the stored .htaccess backup content, or '' if none.
373 *
374 * @return string
375 */
376 private function get_backup_content() {
377 $backup = get_option( 'vigilante_htaccess_backup' );
378 return ( is_array( $backup ) && isset( $backup['content'] ) ) ? (string) $backup['content'] : '';
379 }
380
381 /**
382 * Restore .htaccess from backup
383 *
384 * @return bool
385 */
386 public function restore_backup() {
387 $content = $this->get_backup_content();
388
389 if ( '' === $content ) {
390 return false;
391 }
392
393 return $this->write_file( $content );
394 }
395
396 /**
397 * Check if server is Apache/LiteSpeed
398 *
399 * @return bool
400 */
401 public function is_apache() {
402 if ( function_exists( 'apache_get_modules' ) ) {
403 return true;
404 }
405
406 $server = isset( $_SERVER['SERVER_SOFTWARE'] )
407 ? sanitize_text_field( wp_unslash( $_SERVER['SERVER_SOFTWARE'] ) )
408 : '';
409
410 return ( stripos( $server, 'apache' ) !== false || stripos( $server, 'litespeed' ) !== false );
411 }
412
413 /**
414 * Check if .htaccess is writable
415 *
416 * @return bool
417 */
418 public function is_writable() {
419 // Initialize WP_Filesystem
420 global $wp_filesystem;
421 if ( ! function_exists( 'WP_Filesystem' ) ) {
422 require_once ABSPATH . 'wp-admin/includes/file.php';
423 }
424 WP_Filesystem();
425
426 if ( ! $wp_filesystem ) {
427 return false;
428 }
429
430 if ( file_exists( $this->htaccess_path ) ) {
431 return $wp_filesystem->is_writable( $this->htaccess_path );
432 }
433 return $wp_filesystem->is_writable( ABSPATH );
434 }
435
436 /**
437 * Get current .htaccess content (for debugging)
438 *
439 * @return string
440 */
441 public function get_content() {
442 $content = $this->read_file();
443 return ( false !== $content ) ? $content : '';
444 }
445 }