Безопасные правила .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> стоит между ними для второго условия. Выражение может выбирать и большие блоки конфигурации, не только одно правило. Важны два ограничения:

На 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