Редиректы за HTTPS-прокси

Лишний шаг на http

Обновление от октября 2026. Проверка этого сайта (aredel.com) показала, что 1 358 из 1 362 редиректов в корневом .htaccess отправляли посетителя с https на http и только потом обратно на https. Все они были строками Redirect или RedirectMatch вроде этой:

Redirect 301 /user_profile/ /services/user_profile/

Первый шаг, запрошенный как в Проверке правил:

curl -s -o /dev/null -w "%{http_code} %{redirect_url}\n" https://example.com/user_profile/

301 http://example.com/services/user_profile/

Браузер идёт по нему обычным HTTP-запросом, а правило HTTPS в начале файла отправляет его обратно:

curl -s -o /dev/null -w "%{http_code} %{redirect_url}\n" http://example.com/services/user_profile/

301 https://example.com/services/user_profile/

Два шага вместо одного, и средний запрос не шифруется: его URL и любой cookie без пометки Secure можно прочитать по дороге. Та же механика стоит за выбором из Redirect 301 или пара.

Почему Apache пишет http://

Старый хостинг заканчивал HTTPS на фронтенде (nginx) и передавал запрос Apache по обычному HTTP. Этот хостинг описан в статье Общий хостинг Beget. mod_alias строит абсолютный Location из схемы, которую видит сам Apache, а эта схема - http. Строка SetEnvIf X-Forwarded-Proto https HTTPS=on в начале файла не помогает: она задаёт переменную для PHP, а не схему сервера.

Ещё два редиректа страдали тем же. mod_dir добавляет слэш к папке, запрошенной без него (/qa на /qa/), с той же схемой http. Правило, которое убирает PATH_INFO из адресов PHP, имело голый путь %1 в качестве цели, а mod_rewrite ставит перед голым путём свою схему http.

Что не сработало: правка Location

Самая короткая идея - исправить заголовок на выходе:

Header always edit Location ^http:// https://

В локальном тесте это ничего не изменило. Тестовый заголовок, добавленный через Header always set, доходил до ответа 301, значит модуль заголовков для редиректов работает, но Location редиректа mod_alias оставался http. Редиректы должны были сами собрать правильный URL.

Исправление: пара mod_rewrite

В mod_rewrite цель несёт свою схему. Блок rewrite теперь открывает файл и сначала выбирает схему для всех редиректов ниже: http для локального контейнера без TLS и https для любого другого хоста.

vi .htaccess

<IfModule mod_rewrite.c> RewriteEngine On RewriteCond %{HTTP_HOST} ^(?:localhost|127\.0\.0\.1|php-apache)(?::\d+)?$ RewriteRule ^ - [E=AREDEL_SCHEME:http] RewriteCond %{HTTP_HOST} !^(?:localhost|127\.0\.0\.1|php-apache)(?::\d+)?$ RewriteRule ^ - [E=AREDEL_SCHEME:https] # Редирект не-HTTPS запросов (условие, добавленное на DreamHost, ниже) RewriteCond %{HTTP:X-Forwarded-Proto} !https RewriteRule ^ https://%{HTTP_HOST}%{REQUEST_URI} [R=301,L] # Редирект любого PHP-файла с PATH_INFO на чистый адрес; # [^/]: перед .php имя файла, а не папка с именем .php RewriteCond %{REQUEST_FILENAME} -f RewriteCond %{REQUEST_URI} ^(.*[^/]\.php)/.+$ RewriteRule ^ %{ENV:AREDEL_SCHEME}://%{HTTP_HOST}%1 [R=301,L] </IfModule>

Первая версия шаблона для PATH_INFO была ^(.+\.php)/.+$. Она подходила и к папке с именем .php, поэтому каждая картинка и каждый скрипт в этой папке уходили редиректом на саму папку. На Beget этого никто не заметил: nginx отдаёт существующие файлы раньше, чем Apache читает правила. На DreamHost их отдаёт Apache: DreamHost: статические файлы проходят через .htaccess.

Каждая старая строка стала парой: RewriteCond проверяет полный путь URL, а RewriteRule отправляет редирект. Редирект из начала этой главы теперь выглядит так:

RewriteCond %{REQUEST_URI} ^/user_profile/(.*)$ RewriteRule ^ %{ENV:AREDEL_SCHEME}://%{HTTP_HOST}/services/user_profile/%1 [R=301,L]

Проверка идёт по %{REQUEST_URI}, а не по шаблону RewriteRule, потому что REQUEST_URI сохраняет ведущий слэш и папку: регулярное выражение читается одинаково в корневом файле и в файле подпапки (шаблон RewriteRule теряет и то и другое, см. Основы RewriteRule). Группы, захваченные в RewriteCond, возвращаются как %1, а не $1. Как преобразовали каждый вид старой строки:

Старая строкаШаблон RewriteCondЦель RewriteRule
Redirect 301 /hw/disk_b/ /ru/hw/disk_b/^/hw/disk_b/(.*)$/ru/hw/disk_b/%1
Redirect 301 /debian/date_time.php /ru/linux/debian/date_time.php^/debian/date_time\.php(/.*)?$/ru/linux/debian/date_time.php%1
RedirectMatch 301 ^/robot/?$ /qa/robot/^/robot/?$/qa/robot/
RedirectMatch 404 ^/services/user_profile/data/^/services/user_profile/data/- [R=404,L]

Обычный Redirect совпадает с целыми сегментами пути и сохраняет остаток пути, отсюда (.*)$ после папки и (/.*)?$ после файла. Каждая цель, кроме 404, получает префикс %{ENV:AREDEL_SCHEME}://%{HTTP_HOST} и флаги [R=301,L]. Порядок правил сохранён: пары идут сверху вниз, и L останавливается на первом совпадении, как раньше первая подходящая строка Redirect.

Переезд на DreamHost: цикл

Сайт переехал на DreamHost, где TLS заканчивается в самом Apache и никакой прокси не присылает X-Forwarded-Proto. Правило HTTPS, написанное для старого прокси, осталось в файле, и каждый адрес отвечал 301 на самого себя:

curl -sS -o /dev/null -w "%{http_code} %{redirect_url}\n" https://example.com/

301 https://example.com/

curl -sS -L -o /dev/null https://example.com/

curl: (47) Maximum (50) redirects followed

Зонд из проверки сервера показывает почему:

HTTPS='on' XFP=NULL SERVER_SOFTWARE=Apache sapi=cgi-fcgi

%{HTTPS} равен on, но старое условие спрашивало только, не равен ли заголовок прокси https. Заголовка не было вообще, поэтому "не https" было верно для каждого запроса, включая https. PHP тоже работает через FastCGI:

pgrep -l -u user php

2745746 php85.cgi

ls /usr/sbin/apache2*

/usr/sbin/apache2 /usr/sbin/apache2ctl

Теперь должны выполняться два условия вместе:

RewriteCond %{HTTPS} off RewriteCond %{HTTP:X-Forwarded-Proto} !https RewriteRule ^ https://%{HTTP_HOST}%{REQUEST_URI} [R=301,L]

На DreamHost %{HTTPS} равен on для https-запроса, поэтому первое условие ложно и правило пропускается. За старым прокси %{HTTPS} всегда off, и решает заголовок. Цикл исчез, а обычный http-запрос по-прежнему делает один шаг на https.

Тот же переезд вскрыл вторую проблему: в корневом файле оставались строки php_flag и php_value, которым нужен mod_php. DreamHost запускает PHP через FastCGI, поэтому Apache остановился на первой строке и ответил 500 на каждой странице:

curl -sS -D - -o /dev/null https://example.com/

HTTP/2 500 content-length: 533 content-type: text/html; charset=iso-8859-1 server: Apache

tail -n 1 ~/logs/example.com/https/error.log

[Fri Nov 06 23:29:54 2026] [example.com] [alert] [client 203.0.113.7:37118] [pid 3064457] config.c(2045): /home/user/example.com/.htaccess: Invalid command 'php_flag', perhaps misspelled or defined by a module not included in the server configuration

Теперь этот блок находится внутри <IfModule mod_php.c>, поэтому хостинг с FastCGI его пропускает; те же настройки лежат в ~/.php/<версия>/phprc на DreamHost. После обеих правок сайт снова отвечает:

curl -sSI https://example.com/

HTTP/2 200 content-type: text/html; charset=UTF-8 server: Apache

Защитные блоки и путь phprc разобраны в Безопасных правилах, защитных блоках и откате; полный случай - в Разборе ошибок PHP.

Подпапки со своим .htaccess

Первый сравнительный прогон (см. проверку ниже) нашёл 154 изменившихся ответа в /ao/ и /ru/qa/. В обеих папках был свой .htaccess с RewriteEngine On. Строки mod_alias из родительских папок складываются, правила mod_rewrite - нет: правила ближайшего файла, в котором они есть, заменяют родительские. Корневые правила для адресов внутри этих папок перестали работать в тот момент, когда стали RewriteRule.

Одна строка в каждом файле подпапки возвращает их. Унаследованные корневые правила выполняются после собственных правил папки:

vi ao/.htaccess

RewriteEngine On RewriteOptions Inherit

Строки Redirect этих двух файлов преобразовали так же, и каждый файл получил свою копию блока схемы. Позже в тот же день оба файла удалили, см. Один .htaccess вместо трёх.

Папки без завершающего слэша

После этого 8 ответов всё ещё указывали на http://: папки, запрошенные без слэша, которые оставались на mod_dir. Последнее правило корневого файла теперь само добавляет слэш с правильной схемой:

vi .htaccess

RewriteCond %{REQUEST_FILENAME} -d RewriteCond %{REQUEST_URI} !/$ RewriteRule ^ %{ENV:AREDEL_SCHEME}://%{HTTP_HOST}%{REQUEST_URI}/ [R=301,L]

Оно остаётся последним, поэтому любой явный редирект выше побеждает. Оно покрывает все папки сайта, включая /ao/ и /ru/qa/.

Как проверяли изменение

Скрипт сгенерировал примеры адресов для каждого правила трёх файлов, добавил каждую 150-ю страницу карты сайта и несколько контрольных адресов и отправил каждый в локальный контейнер дважды: с локальным хостом и с Host: www.example.com. Статус и Location записали со старыми файлами и с новыми.

Затем три файла ушли в копию для разработки, testsetup.site, и каждый запрос ниже проверяли так:

curl -s -o /dev/null -w "%{http_code} %{redirect_url}\n" https://testsetup.site/user_profile/

ЗапросОжиданиеФакт
https://testsetup.site/user_profile/301, один шаг на https301 https://testsetup.site/services/user_profile/
https://testsetup.site/qa301, слэш добавлен, https301 https://testsetup.site/qa/
https://testsetup.site/ao/edu/mathematics/mathematics.php301 из ao/.htaccess, https301 https://testsetup.site/ao/edu/mathematics/
https://testsetup.site/ru/qa/allure301, слэш добавлен унаследованным правилом301 https://testsetup.site/ru/qa/allure/
https://testsetup.site/dev/php/htaccess.php/extra301, PATH_INFO убран, https301 https://testsetup.site/dev/php/htaccess.php
http://testsetup.site/ai/301 на https301 https://testsetup.site/ai/
файл в /services/user_profile/data/404404
/, /ai/, /ao/, /ru/qa/, /services/user_profile/200200

Один .htaccess вместо трёх

Ближайший разбор двух файлов подпапок показал, что почти ничего в них уже не могло сработать. Их правила были написаны для старых сайтов andreyolegovich.ru и testsetup.ru, где эти папки были корнем сайта, поэтому они проверяют пути вроде /PC/..., которых под /ao/ не бывает:

ФайлПравилПравил, которые могут совпасть с адресом в папке
ao/.htaccess7191
ru/qa/.htaccess310

Остальные строки повторяли корневой файл, кроме двух неверных: error_log указывал на папки старых сайтов, а ErrorDocument 404 отвечал на отсутствующую страницу редиректом 302 на страницу 404 старого домена. Одно живое правило переехало в корневой файл, а оба файла удалили вместе с пустым ru/qa/lessons/.htaccess. RewriteOptions Inherit больше не нужен. Оставшиеся ниже корня .htaccess не содержат правил rewrite: это настройки сессий PHP в демонстрациях сессий и переключатель Passenger для приложения на Python.

То же локальное сравнение, что и в проверке, до и после: 6026 из 6028 адресов дали одинаковый ответ. Остальные 2 были контрольными адресами /ao/no-such-page/ и /ru/qa/no-such-page/: 302 на старый домен раньше, 404 теперь. Все 561 страница карты сайта под /ao/ и /ru/qa/ по-прежнему загружаются.

Как добавить редирект сейчас

Новые редиректы на этом сайте - пары, а не строки Redirect. Страница, которая переехала:

vi .htaccess

# /old/page.php moved to /new/page/ RewriteCond %{REQUEST_URI} ^/old/page\.php$ RewriteRule ^ %{ENV:AREDEL_SCHEME}://%{HTTP_HOST}/new/page/ [R=301,L]

Хук перед коммитом по-прежнему отказывает новой странице, если её адрес является источником редиректа. Он читает обе формы через container/tools/htaccess_redirects.py, который отвечает на тот же вопрос вручную (код выхода 1, когда путь перенаправляется):

python3 container/tools/htaccess_redirects.py match container/src/.htaccess /user_profile/

/user_profile/ line 30 301 ^/user_profile/(.*)$

Проверки этой главы теперь тест. Он запрашивает каждый адрес без перехода по редиректу и сравнивает статус и точный Location, поэтому Location с http:// на https-сайте считается ошибкой. Кроме выбранных вручную адресов он спрашивает каждое 25-е правило редиректа файла:

cd container

BASE_URL=https://testsetup.site ./tools/run-tests-venv.sh -- tests/test_redirects.py -q

Переезд, из которого выросла эта глава, с полным списком симптомов и исправлений: с Beget на DreamHost.

Автор статьи: Андрей Олегович