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

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