PluginProbe ʕ •ᴥ•ʔ
Really Simple Security – Simple and Performant Security (formerly Really Simple SSL) / 9.5.9
Really Simple Security – Simple and Performant Security (formerly Really Simple SSL) v9.5.9
9.5.11 9.5.10.1 9.5.10 trunk 9.4.0 9.4.1 9.4.2 9.4.3 9.5.0 9.5.0.1 9.5.0.2 9.5.1 9.5.2 9.5.2.2 9.5.2.3 9.5.3 9.5.3.1 9.5.3.2 9.5.4 9.5.5 9.5.6 9.5.7 9.5.8 9.5.9
really-simple-ssl / security / class-rsssl-htaccess-file-manager.php
really-simple-ssl / security Last commit date
includes 2 months ago server 2 months ago tests 2 months ago wordpress 2 months ago class-rsssl-htaccess-file-manager.php 2 months ago cron.php 2 months ago deactivate-integration.php 2 months ago firewall-manager.php 2 months ago functions.php 2 months ago index.php 2 months ago integrations.php 2 months ago notices.php 2 months ago security.php 2 months ago sync-settings.php 2 months ago tests.php 2 months ago
class-rsssl-htaccess-file-manager.php
613 lines
1 <?php
2 /**
3 * class-rsssl-htaccess-file-manager.php
4 *
5 * Responsible for reading, writing and versioning .htaccess
6 * rules via WordPress’s insert_with_markers API.
7 *
8 * @package RSSSL\Pro\Security\WordPress\Firewall\Builders\Rules
9 */
10 namespace {
11 //Multiple requirements to support different WordPress versions and ensure the filesystem API is available.
12 if ( ! function_exists( 'insert_with_markers' )) {
13 require_once ABSPATH . 'wp-admin/includes/misc.php';
14 }
15 if ( ! function_exists( 'get_home_path' )) {
16 require_once ABSPATH . 'wp-admin/includes/file.php';
17 }
18 }
19 namespace RSSSL\Security {
20 /**
21 * Handles low-level .htaccess file operations:
22 * – locating the file,
23 * – reading/writing rules,
24 * – recording history,
25 * – cooperating with WP Rocket.
26 * – will no longer auto-create a missing .htaccess (opt-in via `rsssl_allow_create_htaccess`).
27 */
28 class RSSSL_Htaccess_File_Manager {
29
30 /**
31 * Singleton instance.
32 *
33 * @var self|null
34 */
35 private static ?self $instance = null;
36
37 /**
38 * Return the shared instance of this class.
39 *
40 * @return self
41 */
42 public static function get_instance(): self {
43 if ( self::$instance === null ) {
44 self::$instance = new self();
45 }
46 return self::$instance;
47 }
48
49 /**
50 * Is used for storing the path to the .htaccess file.
51 */
52 public string $htaccess_file_path;
53
54 /**
55 * Constructor.
56 *
57 */
58 public function __construct()
59 {
60 $this->htaccess_file_path = $this->determineHtaccessFilePath();
61 $this->registerRocketHooks();
62 }
63
64 /**
65 * Determines the path to the .htaccess file based on various conditions.
66 */
67 private function determineHtaccessFilePath(): string
68 {
69 // Prefer a custom home .htaccess if it exists
70 $homePath = apply_filters('rsssl_home_htaccess_path', get_home_path() . '.htaccess');
71 if ($this->file_exists($homePath)) {
72 return apply_filters('rsssl_htaccess_file_path', $homePath);
73 }
74
75 // Otherwise use the default .htaccess in ABSPATH
76 $defaultPath = apply_filters('rsssl_default_htaccess_path', ABSPATH . '.htaccess');
77 if ($this->file_exists($defaultPath)) {
78 return apply_filters('rsssl_htaccess_file_path', $defaultPath);
79 }
80
81 // Fallback to WP_CONTENT_DIR/.htaccess (path only; file will not be auto-created)
82 $contentPath = apply_filters('rsssl_wp_content_htaccess_path', WP_CONTENT_DIR . '/.htaccess');
83 return apply_filters('rsssl_htaccess_file_path', $contentPath);
84 }
85
86 /**
87 * Registers hooks for WP Rocket activation and deactivation. So we can record the history of changes made by WP Rocket.
88 */
89 private function registerRocketHooks(): void
90 {
91 // Register hooks for WP Rocket activation and deactivation
92 add_action('rocket_activation', [ $this, 'record_history_from_rocket' ]);
93 add_action('rocket_deactivation', [ $this, 'record_history_from_rocket' ]);
94 }
95
96 /**
97 * Sets or updates the path to the .htaccess file to be managed.
98 */
99 public function set_htaccess_file_path(string $htaccess_file_path): void {
100 $this->htaccess_file_path = $htaccess_file_path;
101 }
102
103 /**
104 * Reads the content of the .htaccess file.
105 */
106 public function get_htaccess_content():? string
107 {
108 if ( is_file($this->htaccess_file_path) && is_readable($this->htaccess_file_path)) {
109 return file_get_contents($this->htaccess_file_path);
110 }
111 return null;
112 }
113
114 /**
115 * Writes a rule block to the .htaccess file.
116 */
117 public function write_rule(array $rule_definition, string $debugTest = 'unknown'): bool
118 {
119 if (! $this->validateRuleDefinition($rule_definition)) {
120 return false;
121 }
122
123 if (! $this->ensure_htaccess_is_writable()) {
124 return false;
125 }
126
127 return $this->applyMarkerBlock(
128 $this->extract_name_from_marker($rule_definition['marker']),
129 $this->prepareLines($rule_definition),
130 $debugTest
131 );
132 }
133
134 /**
135 * Validates the rule definition before writing.
136 *
137 * @param array $ruleDefinition
138 * @return bool True if valid, false otherwise.
139 */
140 private function validateRuleDefinition(array $ruleDefinition): bool
141 {
142 if (empty($ruleDefinition['marker'])) {
143 $this->log_error('No marker provided for write_rule.');
144 return false;
145 }
146 return true;
147 }
148
149 /**
150 * Prepares the lines to write, inserting a placeholder if needed.
151 *
152 * @param array $ruleDefinition
153 * @return string[] Array of lines to write.
154 */
155 private function prepareLines(array $ruleDefinition): array
156 {
157 $lines = $ruleDefinition['lines'] ?? [];
158 $isBeingCleared = ! empty($ruleDefinition['clear_rule']);
159
160 if (empty($lines) && ! $isBeingCleared) {
161 return [
162 '',
163 '# This feature has not been activated.',
164 '',
165 ];
166 }
167
168 return $lines;
169 }
170
171 /**
172 * Applies a marker block to the .htaccess file, supporting configurable top-priority markers.
173 */
174 private function applyMarkerBlock(string $markerName, array $lines, string $debugTest = 'unknown'): bool
175 {
176 $oldContent = $this->get_htaccess_content() ?: '';
177
178 // Allow certain markers to be forced to the very top of .htaccess (right under any existing top block)
179 $top_markers = apply_filters(
180 'rsssl_htaccess_top_markers',
181 [ 'Really Simple Auto Prepend File', 'Really Simple Security Redirect' ]
182 );
183
184 if ( in_array( $markerName, $top_markers, true ) ) {
185 // first remove any existing marker block with the same name
186 $result = $this->write_top_marker_block( $markerName, $lines );
187 } else {
188 // WP core will preserve everything outside of your marker
189 $probe = $this->get_htaccess_content();
190 if ( $this->is_effectively_empty( $probe ) ) {
191 $result = false;
192 } else {
193 // WP core will preserve everything outside of your marker
194 $result = insert_with_markers( $this->htaccess_file_path, $markerName, $lines );
195 }
196 }
197
198 if ( $result ) {
199 $newContent = $this->get_htaccess_content() ?: '';
200 $this->record_history( $oldContent, $newContent, $markerName, $debugTest );
201 }
202
203 return $result;
204 }
205
206 /**
207 * Ensures that the .htaccess file exists and is writable.
208 */
209 private function ensure_htaccess_is_writable(): bool
210 {
211 $dir = dirname( $this->htaccess_file_path );
212
213 // Ensure the directory exists (same as before)
214 if ( ! is_dir( $dir ) && ! wp_mkdir_p( $dir ) ) {
215 $this->log_error( 'Cannot create directory for .htaccess at: ' . esc_html( $dir ) );
216 return false;
217 }
218
219 // Do **not** create a new .htaccess automatically anymore.
220 // This previously led to empty files overwriting existing rewrite rules in some environments.
221 // If a site really wants us to create the file, they must opt in via the filter below.
222 if ( ! is_file( $this->htaccess_file_path ) ) {
223 $allow_create = apply_filters( 'rsssl_allow_create_htaccess', false, $this->htaccess_file_path );
224 if ( $allow_create ) {
225 if ( @file_put_contents( $this->htaccess_file_path, '', LOCK_EX ) === false ) {
226 $this->log_error( 'Could not create .htaccess file at: ' . esc_html( $this->htaccess_file_path ) );
227 return false;
228 } else {
229 $this->log_error( 'Created new .htaccess file at: ' . esc_html( $this->htaccess_file_path ) );
230 }
231 } else {
232 $this->log_error( '.htaccess file does not exist and automatic creation is disabled. Path: ' . esc_html( $this->htaccess_file_path ) );
233 return false;
234 }
235 }
236
237 if ( ! is_writable( $this->htaccess_file_path ) ) {
238 $this->log_error( '.htaccess file is not writable at: ' . esc_html( $this->htaccess_file_path ) );
239 return false;
240 }
241
242 return true;
243 }
244
245 /**
246 * Writes a marker block that must live at the very top of .htaccess.
247 *
248 * Used for markers that must run before WordPress rewrite rules – e.g.
249 * - "Really Simple Auto Prepend File"
250 * - "Really Simple Security Redirect" (HTTP→HTTPS redirect)
251 */
252 private function write_top_marker_block( string $markerName, array $linesToWrite ): bool
253 {
254 // Preserve original content for history
255 $originalHtaccess = $this->get_htaccess_content() ?: '';
256 // SAFETY: if .htaccess is (effectively) empty or unreadable, do not write our markers
257 if ( $this->is_effectively_empty( $originalHtaccess ) ) {
258 return false;
259 }
260 // we remove the redirect marker block if it exists, so we can write a new one
261 // this is needed because the redirect marker block is not removed by insert_with_markers
262 // We added this function because not on every save we can determine when to remove options when the rule is not present.
263 if ( $markerName !== 'Really Simple Security Redirect' && 'htaccess' !== rsssl_get_option('redirect')) {
264 $originalHtaccess = $this->remove_marker_block( $originalHtaccess, 'Really Simple Security Redirect' );
265 }
266 $htaccessWithoutMarker = $this->remove_marker_block( $originalHtaccess, $markerName );
267
268 if (empty($linesToWrite)) {
269 return $this->save_htaccess_if_changed($originalHtaccess, $htaccessWithoutMarker, $markerName);
270 }
271
272 $newMarkerBlock = $this->build_marker_block( $markerName, $linesToWrite );
273 $updatedHtaccess = $this->insert_marker_in_correct_position($htaccessWithoutMarker, $markerName, $newMarkerBlock);
274
275 $updatedHtaccess = $this->cleanupEmptyLines($updatedHtaccess);
276
277 @file_put_contents( $this->htaccess_file_path, $updatedHtaccess, LOCK_EX );
278 $this->record_history( $originalHtaccess, $updatedHtaccess, $markerName );
279 return true;
280 }
281
282 /**
283 * Inserts a marker block in the correct position in the .htaccess file.
284 */
285 private function insert_marker_in_correct_position(string $htaccess, string $markerName, string $markerBlock): string
286 {
287 $autoPrependName = 'Really Simple Auto Prepend File';
288
289 if (strcasecmp($markerName, $autoPrependName) === 0) {
290 return $markerBlock . $htaccess;
291 }
292
293 $escapedAutoPrependName = preg_quote($autoPrependName, '/');
294
295 $autoPrependPattern = $this->generate_marker_pattern($autoPrependName);
296
297 if (preg_match($autoPrependPattern, $htaccess, $match, PREG_OFFSET_CAPTURE)) {
298 $insertPosition = $match[1][1] + strlen($match[1][0]);
299 return substr($htaccess, 0, $insertPosition) . $markerBlock . substr($htaccess, $insertPosition);
300 }
301
302 return $markerBlock . $htaccess;
303 }
304
305 /**
306 * Generates a regex pattern to match a marker block in the .htaccess file.
307 *
308 * This pattern matches both # and ### markers, case-insensitive, and captures
309 * the entire block including the BEGIN and END lines.
310 */
311 public function generate_marker_pattern(string $markerName): string
312 {
313 $escaped = preg_quote($markerName, '/');
314 //return '/(^#+\s*BEGIN\s+' . $escaped . '[^\n]*\n.*?^#+\s*END\s+' . $escaped . '[^\n]*\n?)/ims';
315 return '/(^\s*#+\s*BEGIN\s+' . $escaped . '[^\n]*\n.*?^\s*#+\s*END\s+' . $escaped . '[^\n]*\n?)/ims';
316 }
317
318 /**
319 * Removes a marker block from the .htaccess file.
320 */
321 private function remove_marker_block(string $htaccess, string $markerName): string
322 {
323 // Normalize line endings so regex behaves consistently
324 // $htaccess = preg_replace("/\r\n? /", "\n", $htaccess);
325 $htaccess = preg_replace("/\r\n?/", "\n", $htaccess);
326
327 // Build a single, tolerant pattern matching any number of leading '#', optional trailing text on BEGIN/END lines,
328 // and capturing across multiple lines.
329 $pattern = $this->generate_marker_pattern($markerName);
330
331 // Apply the replacement and capture match count for debugging
332 $before = $htaccess;
333 $htaccess = preg_replace($pattern, '', $htaccess, -1, $count);
334
335 return ltrim($htaccess, "\n");
336 }
337
338 /**
339 * Saves the .htaccess file if it has changed, and record the history.
340 */
341 private function save_htaccess_if_changed(string $original, string $modified, string $markerName): bool
342 {
343 if ( $modified === $original ) {
344 return true;
345 }
346
347 // SAFETY: do not write when the current .htaccess is effectively empty
348 if ( $this->is_effectively_empty( $original ) ) {
349 return false;
350 }
351
352 $cleaned = $this->cleanupEmptyLines( $modified );
353
354 // Avoid writing an empty result
355 if ( $this->is_effectively_empty( $cleaned ) ) {
356 return true;
357 }
358
359 @file_put_contents( $this->htaccess_file_path, $cleaned, LOCK_EX );
360 $this->record_history( $original, $cleaned, $markerName );
361
362 return true;
363 }
364
365 private function build_marker_block(string $markerName, array $lines): string
366 {
367 return implode(PHP_EOL, array_merge(
368 ["# BEGIN {$markerName}"],
369 $lines,
370 ["# END {$markerName}"]
371 )) . PHP_EOL;
372 }
373
374
375
376 /**
377 * Checks if a specific marker block exists in the .htaccess file.
378 *
379 * @param array $markers The start and end markers (e.g., ['#BEGIN rule', '#END rule']).
380 * @return bool True if the block exists, false otherwise.
381 */
382 public function are_markers_present(array $markers): bool
383 {
384 if (count($markers) !== 2) {
385 return false;
386 }
387 $content = $this->get_htaccess_content();
388 if ($content === null) {
389 return false;
390 }
391 $start_marker_escaped = preg_quote($markers[0], '/');
392 $end_marker_escaped = preg_quote($markers[1], '/');
393 return preg_match('/^\s*' . $start_marker_escaped . '.*?^\s*' . $end_marker_escaped . '/ms', $content) === 1;
394 }
395
396 /**
397 * Extracts a usable name from the BEGIN marker for insert_with_markers.
398 * E.g., "#BEGIN My Rule" becomes "My Rule".
399 */
400 private function extract_name_from_marker(string $begin_marker): string
401 {
402 // Remove #, BEGIN, Begin, begin and then trim
403 // also remove trailing ###
404 $name = preg_replace( array( '/^#+\s*(BEGIN|Begin|begin)\s*/i', '/\s*#+$/' ), '', $begin_marker );
405 return trim($name);
406 }
407
408 /**
409 * Records a change to the .htaccess history.
410 *
411 * @param string $old_content The previous content.
412 * @param string $new_content The new content.
413 */
414 private function record_history( string $old_content, string $new_content , string $marker = 'unknown', string $debugTest = 'unknown'): void
415 {
416 if ( ! $this->is_htaccess_tracking_enabled() ) {
417 // we remove the option if the constant is not defined.
418 delete_option( 'rsssl_htaccess_history' );
419 return;
420 }
421 if ( $old_content === $new_content ) {
422 return;
423 }
424 $history = get_option( 'rsssl_htaccess_history', [] );
425 $history[] = [
426 'timestamp' => time(),
427 'file_path' => $this->htaccess_file_path,
428 'old_content' => $old_content,
429 'new_content' => $new_content,
430 'user_id' => function_exists( 'get_current_user_id' ) ? get_current_user_id() : 0,
431 'marker' => $marker,
432 // logging the current hook name for debugging purposes
433 'hook' => current_filter() ?: 'unknown',
434 // logging the current action for debugging purposes
435 'action' => current_action()? : 'unknown',
436 'debug_test' => $debugTest,
437 ];
438 if ( count( $history ) > 20 ) {
439 $history = array_slice( $history, -20 );
440 }
441 update_option( 'rsssl_htaccess_history', $history, false );
442 }
443
444 /**
445 * Clears a specific marker block from the .htaccess file.
446 *
447 * @param string|array $marker The marker name (string) or marker array (['#Begin ...', '#End ...']).
448 * @return bool True on success, false on failure.
449 */
450 public function clear_rule($marker, string $debugTest = 'unknown'): bool
451 {
452 // Accept either a string (marker name) or an array (markers)
453 if (is_array($marker)) {
454 $begin_marker = $marker[0] ?? '';
455 } else {
456 $begin_marker = $marker;
457 }
458 $rule_definition = [
459 'marker' => $begin_marker,
460 'lines' => [],
461 'clear_rule' => true,
462 ];
463 return $this->write_rule($rule_definition, $debugTest);
464 }
465
466 /**
467 * Clears a specific marker block from the .htaccess file without using
468 * insert_with_markers. This method directly removes the block using raw
469 * regex matching. This is needed for old markings that had capitalized
470 * Begin and End markers.
471 */
472 public function clear_legacy_rule(string $marker): bool
473 {
474 $content = $this->get_htaccess_content();
475 if ($content === null) {
476 return false;
477 }
478
479 // SAFETY: if the file is effectively empty, do not attempt to rewrite it
480 if ( $this->is_effectively_empty( $content ) ) {
481 return false;
482 }
483
484 // Match and remove the block with the exact marker name
485 // Use case-insensitive matching for BEGIN/END to handle both legacy and WordPress standard formats
486 $escaped = preg_quote($marker, '/');
487 $pattern = '/^#+\s*BEGIN\s+' . $escaped . '.*?^#+\s*END\s+' . $escaped . '.*?$/msi';
488
489 $new_content = trim(preg_replace($pattern, '', $content));
490
491 // Regex error
492 if ($new_content === null) {
493 return false;
494 }
495
496 // Write the updated content back to the .htaccess file
497 if ( $new_content !== $content && ! $this->is_effectively_empty( $new_content ) ) {
498 return file_put_contents($this->htaccess_file_path, $new_content, LOCK_EX) !== false;
499 }
500
501 return true; // No changes needed
502 }
503
504 /**
505 * Records the history of changes made by WP Rocket to the .htaccess file.
506 */
507 public function record_history_from_rocket(): void
508 {
509 // We get the previous content from the history, if it exists.
510 $history = get_option( 'rsssl_htaccess_history', [] );
511 $old_content = '';
512 if ( ! empty( $history ) ) {
513 $last_entry = end( $history );
514 if ( isset( $last_entry['new_content'] ) ) {
515 $old_content = $last_entry['new_content'];
516 }
517 }
518 $new_content = file_get_contents( $this->htaccess_file_path );
519 if ( $new_content === false ) {
520 return;
521 }
522 $this->record_history( $old_content, $new_content, 'wp-rocket' );
523 }
524
525 /**
526 * Checks if .htaccess history tracking is enabled via constant.
527 *
528 * @since 5.x.x
529 *
530 * @return bool True if .htaccess history tracking is enabled, false otherwise.
531 */
532 private function is_htaccess_tracking_enabled(): bool
533 {
534 return defined( 'RSSSL_RECORDS_HISTORY_VERSION' );
535 }
536
537 /**
538 * Reads the content between a marker block in the .htaccess file and returns it as a string, including the marker lines.
539 */
540 public function get_rule_content(string $markerName):? string
541 {
542 $content = $this->get_htaccess_content();
543 if ($content === null) {
544 return null;
545 }
546 // Match both # and ### marker styles, case-insensitive, including the marker lines
547 $escaped = preg_quote($markerName, '/');
548 $pattern = '/(#+\s*BEGIN\s+' . $escaped . '[^\n]*\n.*?#+\s*END\s+' . $escaped . '[^\n]*\n?)/is';
549 if (preg_match($pattern, $content, $matches)) {
550 return trim($matches[1]);
551 }
552 return null;
553 }
554
555 /**
556 * Writes an error message to the error log.
557 */
558 public function log_error(string $message): void
559 {
560 if ( defined( 'WP_DEBUG' ) && WP_DEBUG ) {
561 error_log( 'RSSSL_Htaccess_File_Manager: ' . $message );
562 }
563 }
564
565 /**
566 * Validates the .htaccess file path. If exists, writable and a valid string.
567 */
568 public function validate_htaccess_file_path(): bool {
569 // Check if the file path is a valid string and not empty
570 if (empty( $this->htaccess_file_path ) ) {
571 return false;
572 }
573
574 // Check if the file exists and is writable
575 if ( ! is_file( $this->htaccess_file_path ) || ! is_writable( $this->htaccess_file_path ) ) {
576 return false;
577 }
578
579 return true;
580 }
581
582 /**
583 * Checks if the .htaccess file exists.
584 */
585 public function file_exists( string $file_path ): bool {
586 return is_file( $file_path );
587 }
588
589 /**
590 * Cleans up extra empty lines in .htaccess content.
591 *
592 * @param string $content The raw .htaccess content.
593 * @return string The content with consecutive blank lines reduced.
594 */
595 private function cleanupEmptyLines(string $content): string
596 {
597 // Normalize all line endings to "\n"
598 // Collapse three or more consecutive newlines into two
599 $content = preg_replace( array( "/\r\n?/", "/\n{3,}/" ), array( "\n", "\n\n" ), $content );
600 return $content;
601 }
602
603 /**
604 * Checks if the given content is effectively empty (only whitespace).
605 */
606 private function is_effectively_empty( $content ): bool {
607 if ( $content === null || $content === false ) {
608 return true;
609 }
610 return trim( (string) $content ) === '';
611 }
612 }
613 }