Безопасные правила .htaccess: резервная копия, защитные блоки и откат
Сначала резервная копия и откат
Одна неверная строка в .htaccess отвечает 500 для папки и всего ниже, а сайт может не дать вам снова править файл через браузер. До первой правки сохраните копию вне корня сайта:
mkdir -p ~/backups
cp -p ~/example.com/.htaccess ~/backups/example.com.htaccess.$(date +%Y%m%d-%H%M%S)
-p сохраняет права и время. Отметка времени в имени помогает быстро найти файл для отката. Чтобы отменить правку, скопируйте последний хороший файл обратно поверх сломанного:
cp -p ~/backups/example.com.htaccess.20261107-120000 ~/example.com/.htaccess
Если оболочки на хостинге нет, скачайте файл до правки в панели и храните такую же копию с датой на своём компьютере.
Проверка в подпапке
Никогда не пробуйте новую директиву на главной странице живого сайта. Сделайте временную папку, положите правило туда и спрашивайте только эту папку:
mkdir -p ~/example.com/test-htaccess
vi ~/example.com/test-htaccess/.htaccess
RewriteEngine On RewriteRule ^probe$ /about/ [R=302,L] RewriteRule ^probe2$ https://%{HTTP_HOST}/about/ [R=302,L]
На локальном Apache 2.4.68 с mod_php папка /test-htaccess/ на http://localhost:8080 показывает разницу:
curl -s -o /dev/null -w "%{http_code} %{redirect_url}\n" http://localhost:8080/test-htaccess/probe
302 http://localhost:8080/about/
curl -s -o /dev/null -w "%{http_code} %{redirect_url}\n" http://localhost:8080/test-htaccess/probe2
302 https://localhost:8080/about/
Цель из одного пути строит http://, хотя заголовок прокси говорит https; абсолютная цель https://%{HTTP_HOST}/about/ сохраняет https. Здесь 302 намеренно: во время проверки кэшированный 301 - последнее, что нужно. Переключайте на 301 только когда правило верное.
RewriteEngine On в папке отключает там родительские правила rewrite, если файл папки не добавит RewriteOptions Inherit. Эта изоляция и есть смысл тестовой папки. После проверки удалите её.
rm -rf ~/example.com/test-htaccess
Защитные блоки: 500 превращается в пропуск
Защитный блок делает часть конфигурации условной. Если условие ложно, Apache пропускает строки, а не останавливается на неизвестной директиве. Цена - правило может молча ничего не делать, поэтому каждый блок требует проверки из раздела о цене защиты.
IfModule
<IfModule> проверяет, загружен ли модуль. Своего модуля ему не нужно:
<IfModule mod_php.c> php_flag display_errors off php_flag log_errors on </IfModule>
mod_php.c - имя исходного файла mod_php начиная с PHP 8; в PHP 7 он называется mod_php7.c. Отрицание закрывает другую ветку:
<IfModule !mod_php.c> # PHP здесь не mod_php: используйте .user.ini или файл хостинга </IfModule>
Это тот самый блок, который лечит классический 500 после переезда: двенадцать строк php_flag и php_value пропускаются на хостинге с FastCGI, а не валят каждый запрос. Переезд, на котором это случилось, с Beget на DreamHost: проблема 1 примера.
IfDirective
<IfDirective> проверяет одну директиву и работает в Apache 2.4.34 и новее. Он точнее проверки модуля, когда одну директиву могут дать несколько модулей:
<IfDirective php_flag> php_value docref_ext ".zz" </IfDirective>
Директива, которую сервер знает, выполняется; если её нет, блок пропускается вместо ответа 500. Подтверждено на Apache 2.4.68 с mod_php, с файлом-зондом рядом с .htaccess:
<?php echo "docref_ext=", var_export(ini_get("docref_ext"), true), "\n";
curl -sS http://localhost:8080/test-htaccess/p.php
docref_ext='.zz'
На более старом Apache этот блок сам неизвестная директива и отвечает 500, поэтому там нужен IfModule.
If, ElseIf и Else
<If> вычисляет выражение и требует Apache 2.4 или новее:
<If "%{HTTPS} == 'off' && %{HTTP:X-Forwarded-Proto} != 'https'"> RewriteRule ^ https://%{HTTP_HOST}%{REQUEST_URI} [R=301,L] </If> <Else> # Уже https, или TLS заканчивается на прокси спереди </Else>
<ElseIf> стоит между ними для второго условия. Выражение может выбирать и большие блоки конфигурации, не только одно правило. Важны два ограничения:
- Синтаксис выражения не совпадает с RewriteCond: здесь %{ПЕРЕМЕННАЯ}, операторы сравнения и && / ||.
- Redirect внутри <If> используйте в форме с одним аргументом, а источник ставьте в условие: Redirect 301 "https://%{HTTP_HOST}/new/". Форма с двумя аргументами не вычисляет выражение и отправляет буквальный текст %{HTTP_HOST}, как показывает тест ниже.
На Apache 2.4.68 с mod_php форма с двумя аргументами внутри <If> ответила буквальной целью:
<If "%{HTTP_HOST} =~ /localhost/"> Redirect 301 "/test-htaccess/old" "https://%{HTTP_HOST}/test-htaccess/new" </If>
curl -s -o /dev/null -w "%{http_code} %{redirect_url}\n" http://localhost:8080/test-htaccess/old
301 https://%{HTTP_HOST}/test-htaccess/new
Форма с одним аргументом работает, потому что источник стоит в условии:
<If "%{REQUEST_URI} == '/test-htaccess/old'"> Redirect 301 "https://%{HTTP_HOST}/test-htaccess/new" </If>
curl -s -o /dev/null -w "%{http_code} %{redirect_url}\n" http://localhost:8080/test-htaccess/old
301 https://localhost:8080/test-htaccess/new
Запрос /test-htaccess/other отвечает 404: условие ложно, и редирект не срабатывает.
IfVersion
<IfVersion> проверяет версию Apache и требует mod_version. Если модуля может не быть, оберните сам блок версии в IfModule:
<IfModule mod_version.c> <IfVersion >= 2.4> # Только Apache 2.4 и новее </IfVersion> </IfModule>
IfDefine
<IfDefine> проверяет имя, переданное Apache при запуске через -D, а не модуль или версию:
<IfDefine !MOD_PHP> # Только если Apache запущен без -DMOD_PHP </IfDefine>
Общий хостинг редко даёт менять флаги запуска, поэтому IfDefine полезнее на сервере, которым вы управляете сами. Для хостинга практичные блоки - IfModule и If.
Цена защиты: проверьте, что правило сработало
Защитный блок превращает громкий 500 в тихий пропуск. Сайт остаётся живым, но редирект или заголовок может исчезнуть. После каждой правки с блоком спросите адрес и поищите эффект. На Apache 2.4.68 с mod_php блок по заголовку запроса вёл себя так:
RewriteEngine On <If "%{HTTP:X-Test} == 'go'"> RewriteRule ^ https://%{HTTP_HOST}/test-htaccess/target [R=301,L] </If> <Else> # ничего </Else>
Когда условие истинно, правило срабатывает:
curl -sS -o /dev/null -w "%{http_code} %{redirect_url}\n" -H 'X-Test: go' http://localhost:8080/test-htaccess/x
301 https://localhost:8080/test-htaccess/target
Без заголовка условие ложно, и тот же адрес отвечает 404:
curl -sS -o /dev/null -w "%{http_code} %{redirect_url}\n" http://localhost:8080/test-htaccess/x
404
Ответ 301 с новым адресом значит, что блок сработал. Ответ 404 со старой страницы значит, что условие блока ложно: тихий пропуск. Ответ 500 значит, что сам блок не поддерживается или директива стоит вне него. Проверка сервера из Какой у вас сервер говорит, что именно случилось.
Куда девать настройки PHP
php_flag и php_value существуют только в mod_php. Под PHP-FPM или другим FastCGI SAPI положите те же настройки в файл, который PHP читает сам. Руководство PHP описывает файлы .user.ini: PHP просматривает их от папки скрипта вверх до корня сайта.
vi ~/example.com/.user.ini
display_errors = Off log_errors = On error_reporting = -1
Туда попадают только настройки уровня PHP_INI_PERDIR или PHP_INI_USER. PHP кэширует файл пять минут по умолчанию, а работающий процесс FastCGI держит старые настройки до перезапуска.
Некоторые хостинги читают свой файл. DreamHost читает ~/.php/<версия>/phprc:
vi ~/.php/8.5/phprc
display_startup_errors = Off display_errors = Off html_errors = Off log_errors = On error_reporting = -1
PHP читает этот файл при старте процесса. Найдите имя процесса через pgrep (см. Какой процесс PHP работает), затем завершите его, чтобы следующий запрос запустил новый:
pkill -u user -x php85.cgi
Не копируйте track_errors и log_errors_max_len в файл PHP 8: их убрали в PHP 8.0 и 8.1. Файл-зонд из проверки сервера показывает значения, которые действуют на самом деле. Чтобы увидеть весь блок в работе против обоих видов хостинга до переезда, соберите Docker-образы хостингов и запустите проверку 2.
Настоящий случай 500
Переезд на DreamHost разобран по шагам: Разбор ошибок PHP содержит строку журнала, двенадцать виновных директив и исправление через phprc. Коротко: Apache прочитал .htaccess, встретил php_flag, не нашёл mod_php и ответил 500 на каждой странице; обёртка блока в <IfModule mod_php.c> вернула сайт.
Автор статьи: Arthur Isaev