Files
krolewskie-miody.pl/wp-content/plugins/really-simple-ssl/security/class-rsssl-htaccess-file-manager.php
2026-04-28 15:13:50 +02:00

613 lines
21 KiB
PHP
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
/**
* class-rsssl-htaccess-file-manager.php
*
* Responsible for reading, writing and versioning .htaccess
* rules via WordPresss insert_with_markers API.
*
* @package RSSSL\Pro\Security\WordPress\Firewall\Builders\Rules
*/
namespace {
//Multiple requirements to support different WordPress versions and ensure the filesystem API is available.
if ( ! function_exists( 'insert_with_markers' )) {
require_once ABSPATH . 'wp-admin/includes/misc.php';
}
if ( ! function_exists( 'get_home_path' )) {
require_once ABSPATH . 'wp-admin/includes/file.php';
}
}
namespace RSSSL\Security {
/**
* Handles low-level .htaccess file operations:
* locating the file,
* reading/writing rules,
* recording history,
* cooperating with WP Rocket.
* will no longer auto-create a missing .htaccess (opt-in via `rsssl_allow_create_htaccess`).
*/
class RSSSL_Htaccess_File_Manager {
/**
* Singleton instance.
*
* @var self|null
*/
private static ?self $instance = null;
/**
* Return the shared instance of this class.
*
* @return self
*/
public static function get_instance(): self {
if ( self::$instance === null ) {
self::$instance = new self();
}
return self::$instance;
}
/**
* Is used for storing the path to the .htaccess file.
*/
public string $htaccess_file_path;
/**
* Constructor.
*
*/
public function __construct()
{
$this->htaccess_file_path = $this->determineHtaccessFilePath();
$this->registerRocketHooks();
}
/**
* Determines the path to the .htaccess file based on various conditions.
*/
private function determineHtaccessFilePath(): string
{
// Prefer a custom home .htaccess if it exists
$homePath = apply_filters('rsssl_home_htaccess_path', get_home_path() . '.htaccess');
if ($this->file_exists($homePath)) {
return apply_filters('rsssl_htaccess_file_path', $homePath);
}
// Otherwise use the default .htaccess in ABSPATH
$defaultPath = apply_filters('rsssl_default_htaccess_path', ABSPATH . '.htaccess');
if ($this->file_exists($defaultPath)) {
return apply_filters('rsssl_htaccess_file_path', $defaultPath);
}
// Fallback to WP_CONTENT_DIR/.htaccess (path only; file will not be auto-created)
$contentPath = apply_filters('rsssl_wp_content_htaccess_path', WP_CONTENT_DIR . '/.htaccess');
return apply_filters('rsssl_htaccess_file_path', $contentPath);
}
/**
* Registers hooks for WP Rocket activation and deactivation. So we can record the history of changes made by WP Rocket.
*/
private function registerRocketHooks(): void
{
// Register hooks for WP Rocket activation and deactivation
add_action('rocket_activation', [ $this, 'record_history_from_rocket' ]);
add_action('rocket_deactivation', [ $this, 'record_history_from_rocket' ]);
}
/**
* Sets or updates the path to the .htaccess file to be managed.
*/
public function set_htaccess_file_path(string $htaccess_file_path): void {
$this->htaccess_file_path = $htaccess_file_path;
}
/**
* Reads the content of the .htaccess file.
*/
public function get_htaccess_content():? string
{
if ( is_file($this->htaccess_file_path) && is_readable($this->htaccess_file_path)) {
return file_get_contents($this->htaccess_file_path);
}
return null;
}
/**
* Writes a rule block to the .htaccess file.
*/
public function write_rule(array $rule_definition, string $debugTest = 'unknown'): bool
{
if (! $this->validateRuleDefinition($rule_definition)) {
return false;
}
if (! $this->ensure_htaccess_is_writable()) {
return false;
}
return $this->applyMarkerBlock(
$this->extract_name_from_marker($rule_definition['marker']),
$this->prepareLines($rule_definition),
$debugTest
);
}
/**
* Validates the rule definition before writing.
*
* @param array $ruleDefinition
* @return bool True if valid, false otherwise.
*/
private function validateRuleDefinition(array $ruleDefinition): bool
{
if (empty($ruleDefinition['marker'])) {
$this->log_error('No marker provided for write_rule.');
return false;
}
return true;
}
/**
* Prepares the lines to write, inserting a placeholder if needed.
*
* @param array $ruleDefinition
* @return string[] Array of lines to write.
*/
private function prepareLines(array $ruleDefinition): array
{
$lines = $ruleDefinition['lines'] ?? [];
$isBeingCleared = ! empty($ruleDefinition['clear_rule']);
if (empty($lines) && ! $isBeingCleared) {
return [
'',
'# This feature has not been activated.',
'',
];
}
return $lines;
}
/**
* Applies a marker block to the .htaccess file, supporting configurable top-priority markers.
*/
private function applyMarkerBlock(string $markerName, array $lines, string $debugTest = 'unknown'): bool
{
$oldContent = $this->get_htaccess_content() ?: '';
// Allow certain markers to be forced to the very top of .htaccess (right under any existing top block)
$top_markers = apply_filters(
'rsssl_htaccess_top_markers',
[ 'Really Simple Auto Prepend File', 'Really Simple Security Redirect' ]
);
if ( in_array( $markerName, $top_markers, true ) ) {
// first remove any existing marker block with the same name
$result = $this->write_top_marker_block( $markerName, $lines );
} else {
// WP core will preserve everything outside of your marker
$probe = $this->get_htaccess_content();
if ( $this->is_effectively_empty( $probe ) ) {
$result = false;
} else {
// WP core will preserve everything outside of your marker
$result = insert_with_markers( $this->htaccess_file_path, $markerName, $lines );
}
}
if ( $result ) {
$newContent = $this->get_htaccess_content() ?: '';
$this->record_history( $oldContent, $newContent, $markerName, $debugTest );
}
return $result;
}
/**
* Ensures that the .htaccess file exists and is writable.
*/
private function ensure_htaccess_is_writable(): bool
{
$dir = dirname( $this->htaccess_file_path );
// Ensure the directory exists (same as before)
if ( ! is_dir( $dir ) && ! wp_mkdir_p( $dir ) ) {
$this->log_error( 'Cannot create directory for .htaccess at: ' . esc_html( $dir ) );
return false;
}
// Do **not** create a new .htaccess automatically anymore.
// This previously led to empty files overwriting existing rewrite rules in some environments.
// If a site really wants us to create the file, they must opt in via the filter below.
if ( ! is_file( $this->htaccess_file_path ) ) {
$allow_create = apply_filters( 'rsssl_allow_create_htaccess', false, $this->htaccess_file_path );
if ( $allow_create ) {
if ( @file_put_contents( $this->htaccess_file_path, '', LOCK_EX ) === false ) {
$this->log_error( 'Could not create .htaccess file at: ' . esc_html( $this->htaccess_file_path ) );
return false;
} else {
$this->log_error( 'Created new .htaccess file at: ' . esc_html( $this->htaccess_file_path ) );
}
} else {
$this->log_error( '.htaccess file does not exist and automatic creation is disabled. Path: ' . esc_html( $this->htaccess_file_path ) );
return false;
}
}
if ( ! is_writable( $this->htaccess_file_path ) ) {
$this->log_error( '.htaccess file is not writable at: ' . esc_html( $this->htaccess_file_path ) );
return false;
}
return true;
}
/**
* Writes a marker block that must live at the very top of .htaccess.
*
* Used for markers that must run before WordPress rewrite rules e.g.
* - "Really Simple Auto Prepend File"
* - "Really Simple Security Redirect" (HTTP→HTTPS redirect)
*/
private function write_top_marker_block( string $markerName, array $linesToWrite ): bool
{
// Preserve original content for history
$originalHtaccess = $this->get_htaccess_content() ?: '';
// SAFETY: if .htaccess is (effectively) empty or unreadable, do not write our markers
if ( $this->is_effectively_empty( $originalHtaccess ) ) {
return false;
}
// we remove the redirect marker block if it exists, so we can write a new one
// this is needed because the redirect marker block is not removed by insert_with_markers
// We added this function because not on every save we can determine when to remove options when the rule is not present.
if ( $markerName !== 'Really Simple Security Redirect' && 'htaccess' !== rsssl_get_option('redirect')) {
$originalHtaccess = $this->remove_marker_block( $originalHtaccess, 'Really Simple Security Redirect' );
}
$htaccessWithoutMarker = $this->remove_marker_block( $originalHtaccess, $markerName );
if (empty($linesToWrite)) {
return $this->save_htaccess_if_changed($originalHtaccess, $htaccessWithoutMarker, $markerName);
}
$newMarkerBlock = $this->build_marker_block( $markerName, $linesToWrite );
$updatedHtaccess = $this->insert_marker_in_correct_position($htaccessWithoutMarker, $markerName, $newMarkerBlock);
$updatedHtaccess = $this->cleanupEmptyLines($updatedHtaccess);
@file_put_contents( $this->htaccess_file_path, $updatedHtaccess, LOCK_EX );
$this->record_history( $originalHtaccess, $updatedHtaccess, $markerName );
return true;
}
/**
* Inserts a marker block in the correct position in the .htaccess file.
*/
private function insert_marker_in_correct_position(string $htaccess, string $markerName, string $markerBlock): string
{
$autoPrependName = 'Really Simple Auto Prepend File';
if (strcasecmp($markerName, $autoPrependName) === 0) {
return $markerBlock . $htaccess;
}
$escapedAutoPrependName = preg_quote($autoPrependName, '/');
$autoPrependPattern = $this->generate_marker_pattern($autoPrependName);
if (preg_match($autoPrependPattern, $htaccess, $match, PREG_OFFSET_CAPTURE)) {
$insertPosition = $match[1][1] + strlen($match[1][0]);
return substr($htaccess, 0, $insertPosition) . $markerBlock . substr($htaccess, $insertPosition);
}
return $markerBlock . $htaccess;
}
/**
* Generates a regex pattern to match a marker block in the .htaccess file.
*
* This pattern matches both # and ### markers, case-insensitive, and captures
* the entire block including the BEGIN and END lines.
*/
public function generate_marker_pattern(string $markerName): string
{
$escaped = preg_quote($markerName, '/');
//return '/(^#+\s*BEGIN\s+' . $escaped . '[^\n]*\n.*?^#+\s*END\s+' . $escaped . '[^\n]*\n?)/ims';
return '/(^\s*#+\s*BEGIN\s+' . $escaped . '[^\n]*\n.*?^\s*#+\s*END\s+' . $escaped . '[^\n]*\n?)/ims';
}
/**
* Removes a marker block from the .htaccess file.
*/
private function remove_marker_block(string $htaccess, string $markerName): string
{
// Normalize line endings so regex behaves consistently
// $htaccess = preg_replace("/\r\n? /", "\n", $htaccess);
$htaccess = preg_replace("/\r\n?/", "\n", $htaccess);
// Build a single, tolerant pattern matching any number of leading '#', optional trailing text on BEGIN/END lines,
// and capturing across multiple lines.
$pattern = $this->generate_marker_pattern($markerName);
// Apply the replacement and capture match count for debugging
$before = $htaccess;
$htaccess = preg_replace($pattern, '', $htaccess, -1, $count);
return ltrim($htaccess, "\n");
}
/**
* Saves the .htaccess file if it has changed, and record the history.
*/
private function save_htaccess_if_changed(string $original, string $modified, string $markerName): bool
{
if ( $modified === $original ) {
return true;
}
// SAFETY: do not write when the current .htaccess is effectively empty
if ( $this->is_effectively_empty( $original ) ) {
return false;
}
$cleaned = $this->cleanupEmptyLines( $modified );
// Avoid writing an empty result
if ( $this->is_effectively_empty( $cleaned ) ) {
return true;
}
@file_put_contents( $this->htaccess_file_path, $cleaned, LOCK_EX );
$this->record_history( $original, $cleaned, $markerName );
return true;
}
private function build_marker_block(string $markerName, array $lines): string
{
return implode(PHP_EOL, array_merge(
["# BEGIN {$markerName}"],
$lines,
["# END {$markerName}"]
)) . PHP_EOL;
}
/**
* Checks if a specific marker block exists in the .htaccess file.
*
* @param array $markers The start and end markers (e.g., ['#BEGIN rule', '#END rule']).
* @return bool True if the block exists, false otherwise.
*/
public function are_markers_present(array $markers): bool
{
if (count($markers) !== 2) {
return false;
}
$content = $this->get_htaccess_content();
if ($content === null) {
return false;
}
$start_marker_escaped = preg_quote($markers[0], '/');
$end_marker_escaped = preg_quote($markers[1], '/');
return preg_match('/^\s*' . $start_marker_escaped . '.*?^\s*' . $end_marker_escaped . '/ms', $content) === 1;
}
/**
* Extracts a usable name from the BEGIN marker for insert_with_markers.
* E.g., "#BEGIN My Rule" becomes "My Rule".
*/
private function extract_name_from_marker(string $begin_marker): string
{
// Remove #, BEGIN, Begin, begin and then trim
// also remove trailing ###
$name = preg_replace( array( '/^#+\s*(BEGIN|Begin|begin)\s*/i', '/\s*#+$/' ), '', $begin_marker );
return trim($name);
}
/**
* Records a change to the .htaccess history.
*
* @param string $old_content The previous content.
* @param string $new_content The new content.
*/
private function record_history( string $old_content, string $new_content , string $marker = 'unknown', string $debugTest = 'unknown'): void
{
if ( ! $this->is_htaccess_tracking_enabled() ) {
// we remove the option if the constant is not defined.
delete_option( 'rsssl_htaccess_history' );
return;
}
if ( $old_content === $new_content ) {
return;
}
$history = get_option( 'rsssl_htaccess_history', [] );
$history[] = [
'timestamp' => time(),
'file_path' => $this->htaccess_file_path,
'old_content' => $old_content,
'new_content' => $new_content,
'user_id' => function_exists( 'get_current_user_id' ) ? get_current_user_id() : 0,
'marker' => $marker,
// logging the current hook name for debugging purposes
'hook' => current_filter() ?: 'unknown',
// logging the current action for debugging purposes
'action' => current_action()? : 'unknown',
'debug_test' => $debugTest,
];
if ( count( $history ) > 20 ) {
$history = array_slice( $history, -20 );
}
update_option( 'rsssl_htaccess_history', $history, false );
}
/**
* Clears a specific marker block from the .htaccess file.
*
* @param string|array $marker The marker name (string) or marker array (['#Begin ...', '#End ...']).
* @return bool True on success, false on failure.
*/
public function clear_rule($marker, string $debugTest = 'unknown'): bool
{
// Accept either a string (marker name) or an array (markers)
if (is_array($marker)) {
$begin_marker = $marker[0] ?? '';
} else {
$begin_marker = $marker;
}
$rule_definition = [
'marker' => $begin_marker,
'lines' => [],
'clear_rule' => true,
];
return $this->write_rule($rule_definition, $debugTest);
}
/**
* Clears a specific marker block from the .htaccess file without using
* insert_with_markers. This method directly removes the block using raw
* regex matching. This is needed for old markings that had capitalized
* Begin and End markers.
*/
public function clear_legacy_rule(string $marker): bool
{
$content = $this->get_htaccess_content();
if ($content === null) {
return false;
}
// SAFETY: if the file is effectively empty, do not attempt to rewrite it
if ( $this->is_effectively_empty( $content ) ) {
return false;
}
// Match and remove the block with the exact marker name
// Use case-insensitive matching for BEGIN/END to handle both legacy and WordPress standard formats
$escaped = preg_quote($marker, '/');
$pattern = '/^#+\s*BEGIN\s+' . $escaped . '.*?^#+\s*END\s+' . $escaped . '.*?$/msi';
$new_content = trim(preg_replace($pattern, '', $content));
// Regex error
if ($new_content === null) {
return false;
}
// Write the updated content back to the .htaccess file
if ( $new_content !== $content && ! $this->is_effectively_empty( $new_content ) ) {
return file_put_contents($this->htaccess_file_path, $new_content, LOCK_EX) !== false;
}
return true; // No changes needed
}
/**
* Records the history of changes made by WP Rocket to the .htaccess file.
*/
public function record_history_from_rocket(): void
{
// We get the previous content from the history, if it exists.
$history = get_option( 'rsssl_htaccess_history', [] );
$old_content = '';
if ( ! empty( $history ) ) {
$last_entry = end( $history );
if ( isset( $last_entry['new_content'] ) ) {
$old_content = $last_entry['new_content'];
}
}
$new_content = file_get_contents( $this->htaccess_file_path );
if ( $new_content === false ) {
return;
}
$this->record_history( $old_content, $new_content, 'wp-rocket' );
}
/**
* Checks if .htaccess history tracking is enabled via constant.
*
* @since 5.x.x
*
* @return bool True if .htaccess history tracking is enabled, false otherwise.
*/
private function is_htaccess_tracking_enabled(): bool
{
return defined( 'RSSSL_RECORDS_HISTORY_VERSION' );
}
/**
* Reads the content between a marker block in the .htaccess file and returns it as a string, including the marker lines.
*/
public function get_rule_content(string $markerName):? string
{
$content = $this->get_htaccess_content();
if ($content === null) {
return null;
}
// Match both # and ### marker styles, case-insensitive, including the marker lines
$escaped = preg_quote($markerName, '/');
$pattern = '/(#+\s*BEGIN\s+' . $escaped . '[^\n]*\n.*?#+\s*END\s+' . $escaped . '[^\n]*\n?)/is';
if (preg_match($pattern, $content, $matches)) {
return trim($matches[1]);
}
return null;
}
/**
* Writes an error message to the error log.
*/
public function log_error(string $message): void
{
if ( defined( 'WP_DEBUG' ) && WP_DEBUG ) {
error_log( 'RSSSL_Htaccess_File_Manager: ' . $message );
}
}
/**
* Validates the .htaccess file path. If exists, writable and a valid string.
*/
public function validate_htaccess_file_path(): bool {
// Check if the file path is a valid string and not empty
if (empty( $this->htaccess_file_path ) ) {
return false;
}
// Check if the file exists and is writable
if ( ! is_file( $this->htaccess_file_path ) || ! is_writable( $this->htaccess_file_path ) ) {
return false;
}
return true;
}
/**
* Checks if the .htaccess file exists.
*/
public function file_exists( string $file_path ): bool {
return is_file( $file_path );
}
/**
* Cleans up extra empty lines in .htaccess content.
*
* @param string $content The raw .htaccess content.
* @return string The content with consecutive blank lines reduced.
*/
private function cleanupEmptyLines(string $content): string
{
// Normalize all line endings to "\n"
// Collapse three or more consecutive newlines into two
$content = preg_replace( array( "/\r\n?/", "/\n{3,}/" ), array( "\n", "\n\n" ), $content );
return $content;
}
/**
* Checks if the given content is effectively empty (only whitespace).
*/
private function is_effectively_empty( $content ): bool {
if ( $content === null || $content === false ) {
return true;
}
return trim( (string) $content ) === '';
}
}
}