CorBlogкейсы · ops

Статья

HAProxy: петля редиректов и X-Forwarded-*

Через балансировщик страница крутит 302/307 сотнями в секунду, по IP бэкенда всё ок — сломаны X-Forwarded-* до PHP-FPM.

Зашёл на приложение через HAProxy — браузер долго крутит загрузку, в DevTools сыпятся редиректы. По прямому IP бэкенда всё открывается сразу. Оказалось: до PHP не доезжали нормальные X-Forwarded-*, и приложение само себя гоняло по неправильному URL.

Что произошло

Симптомы:

  • через HAProxy — задержка 5–10 секунд, в access-логе десятки/сотни 302/307 в секунду;
  • напрямую на Apache — ок;
  • схема: клиент → HAProxy (HTTPS) → Apache → PHP-FPM.

Приложение (типичный PHP/Laravel-паттерн) собирает URL для редиректов из:

  1. APP_URL в .env;
  2. X-Forwarded-Proto / X-Forwarded-Host / X-Forwarded-Port;
  3. заголовка Host.

Если в этой цепочке что-то врёт — получается петля.

Как искал

Проверил, что это именно redirect storm, а не медленный бэкенд:

curl -IL --max-redirs 5 https://app.example.com

И смотрел access-лог на поток редиректов:

tail -f /var/log/apache2/ssl_access.log | grep -E ' 302| 307'

Дальше сверил, что PHP вообще видит forwarded-заголовки. Быстрый test-headers.php за HAProxy показал: HTTP_X_FORWARDED_PROTO / HOST / PORT пустые или не те, хотя HAProxy вроде что-то выставлял.

Корневая дыра нашлась в Apache: строки ProxyFCGISetEnvIf для HTTP_X_FORWARDED_* были закомментированы — до PHP-FPM заголовки просто не доходили.

Параллельно в HAProxy порт брался как %[dst_port] — это порт самого HAProxy, а не логический порт клиента, что тоже путало X-Forwarded-Port.

Решение

1. HAProxy: явные X-Forwarded-*

Рабочий вариант, если снаружи всегда HTTPS:

backend B-App
  mode http
  option forwardfor except 127.0.0.0/8
  http-request set-header X-Forwarded-Proto https
  http-request set-header X-Forwarded-Host %[req.hdr(Host)]
  http-request set-header X-Forwarded-Port 443
  server S-App 10.0.0.10:443 check ssl verify none

Если бывают и HTTP, и HTTPS на фронте:

http-request set-header X-Forwarded-Proto https if { ssl_fc }
http-request set-header X-Forwarded-Proto http if !{ ssl_fc }
http-request set-header X-Forwarded-Port 443 if { ssl_fc }
http-request set-header X-Forwarded-Port 80 if !{ ssl_fc }

2. Apache: прокинуть заголовки в PHP-FPM

Критичный кусок — без него HAProxy можно править бесконечно:

<FilesMatch \.php$>
    SetHandler "proxy:unix:/run/php/php8.3-fpm.sock|fcgi://localhost/"

    ProxyFCGISetEnvIf "%{HTTP:X-Forwarded-Proto} =https" HTTPS on
    ProxyFCGISetEnvIf "%{HTTP:X-Forwarded-Proto} =http" HTTPS off
    ProxyFCGISetEnvIf "!%{HTTP:X-Forwarded-Proto}" HTTPS on

    ProxyFCGISetEnvIf "%{HTTP:X-Forwarded-Port} ^(.*)$" SERVER_PORT %1
    ProxyFCGISetEnvIf "!%{HTTP:X-Forwarded-Port}" SERVER_PORT 443

    ProxyFCGISetEnvIf "%{HTTP:X-Forwarded-Proto} ^(.*)$" HTTP_X_FORWARDED_PROTO %1
    ProxyFCGISetEnvIf "%{HTTP:X-Forwarded-Host} ^(.*)$" HTTP_X_FORWARDED_HOST %1
    ProxyFCGISetEnvIf "%{HTTP:X-Forwarded-Port} ^(.*)$" HTTP_X_FORWARDED_PORT %1
    ProxyFCGISetEnvIf "%{HTTP:X-Forwarded-For} ^(.*)$" HTTP_X_FORWARDED_FOR %1
</FilesMatch>

3. APP_URL и Host должны совпадать

В .env:

APP_URL=https://app.example.com

URL должен совпадать с тем, что клиент реально пишет в браузере (Host). Если в Apache ServerName внутренний — добавь ServerAlias на внешний домен.

Итог

Если через reverse proxy крутится redirect loop, а бэкенд напрямую живой — почти всегда врут схема/хост/порт для приложения.

Чеклист:

  • HAProxy выставляет X-Forwarded-Proto/Host/Port явно (не %[dst_port] вслепую)
  • Apache реально прокидывает их в PHP-FPM через ProxyFCGISetEnvIf
  • APP_URL совпадает с внешним URL клиента