Перейти к основному содержанию
Блог

Веб

Host header injection: простая на вид, но серьёзная уязвимость

Если приложение доверяет заголовку Host или X-Forwarded-Host, ссылки сброса пароля могут вести на чужой домен. Реальный случай, способ проверки и исправление в Laravel и nginx.

· 7 мин. чтения

Фото: Dietmar Rabich, CC BY-SA 4.0 · Wikimedia Commons

Host header injection — уязвимость, которую многие разработчики не замечают. В коде нет бросающейся в глаза ошибки вроде SQL-инъекции или XSS, всё работает как обычно. Но если приложение слепо доверяет заголовку Host или X-Forwarded-Host из запроса, атакующий может направить ссылки, которые генерирует приложение, на свой домен.

Эта уязвимость была найдена в экспертизе нашего собственного продукта — SaaS-платформы для налогов и финансов. Ниже покажем, как она работает, как её проверить и как мы её закрыли.

Откуда берётся проблема

Приложения часто строят полные (абсолютные) URL: ссылки в письмах, редиректы, теги canonical, адреса файлов. Для этого они берут имя домена из текущего запроса. Фреймворк читает домен из заголовка Host, а если приложение стоит за прокси — из X-Forwarded-Host.

Проблема в том, что эти заголовки отправляет сам клиент. Если приложение доверяет X-Forwarded-Host по принципу «наверное, его добавил прокси», любой может указать любой домен.

Реальный случай: ссылка сброса пароля

В нашем случае приложение доверяло всем заголовкам X-Forwarded-*. Сценарий атаки выглядит так:

  1. Атакующий вводит email жертвы в форму восстановления пароля.
  2. Добавляет к запросу заголовок X-Forwarded-Host: attacker.example.
  3. Приложение строит ссылку сброса с этим доменом: https://attacker.example/reset-password/<token>.
  4. Жертва получает настоящее письмо с настоящего сайта. Если она нажмёт на ссылку, секретный токен уйдёт на сервер атакующего.
  5. Атакующий с этим токеном меняет пароль на настоящем сайте.

Письмо приходит с официального адреса, текст правильный, отличается только домен в ссылке. Поэтому уязвимость считается серьёзной.

Возможны и другие последствия: ссылки на чужой домен в кешируемых страницах (cache poisoning), редиректы на посторонний сайт.

Почему эта уязвимость долго остаётся незамеченной? Потому что при обычном использовании Host всегда приходит правильный, и в тестах всё работает. Проблема видна, только когда кто-то намеренно меняет заголовок. А в приложениях за прокси разработчики часто ставят настройку «доверять всему», чтобы сайт заработал, и потом о ней забывают.

Любой заголовок, который присылает клиент, — это пользовательский ввод. Host — не исключение.

Как проверить

Самая простая проверка — отправить запрос с чужим доменом и посмотреть, появится ли он в ответе:

curl -si https://your-domain.uz/login \
  -H "X-Forwarded-Host: attacker.example" | grep -i attacker

curl -si https://your-domain.uz/login \
  -H "Host: attacker.example" | grep -i attacker

Также:

  • Отправьте форму восстановления пароля с X-Forwarded-Host и проверьте ссылку в пришедшем письме (на своём тестовом аккаунте).
  • Посмотрите ответы-редиректы с заголовком Location.
  • Попробуйте альтернативные заголовки: X-Forwarded-Server, X-Host, Forwarded.

Если attacker.example появился в ответе или письме — уязвимость есть.

Исправление в Laravel

1. Ограничьте доверие к прокси

Middleware TrustProxies в Laravel определяет, каким прокси и каким заголовкам доверять. У нас проблема была именно здесь: доверие ко всем заголовкам. Исправление — доверять только X-Forwarded-For, Proto и Port, а X-Forwarded-Host игнорировать.

В Laravel 11+ в bootstrap/app.php:

use Illuminate\Http\Request;

->withMiddleware(function (Middleware $middleware) {
    $middleware->trustProxies(
        at: ['127.0.0.1'],
        headers: Request::HEADER_X_FORWARDED_FOR
            | Request::HEADER_X_FORWARDED_PORT
            | Request::HEADER_X_FORWARDED_PROTO
    );
})

В at указывайте только реальный адрес прокси, а не '*'.

2. Зафиксируйте root URL в production

Чтобы ссылки строились из конфигурации, а не из запроса, добавьте в AppServiceProvider::boot():

use Illuminate\Support\Facades\URL;

if ($this->app->isProduction()) {
    URL::forceRootUrl(config('app.url'));
    URL::forceScheme('https');
}

APP_URL в .env обязательно должен содержать правильный домен.

3. Разрешённые хосты

Как дополнительный слой включите middleware TrustHosts — он принимает запросы только с разрешённых доменов:

$middleware->trustHosts(at: ['your-domain.uz']);

Исправление в nginx

Помимо защиты на уровне приложения, веб-сервер тоже не должен пропускать к приложению запросы с чужим Host.

default_server

nginx направляет запрос в подходящий блок server по server_name. Если подходящего блока нет, запрос попадает в default_server, а если он не задан — в первый блок, то есть часто в ваш основной сайт. Поэтому добавьте отдельный «улавливающий» блок:

server {
    listen 80 default_server;
    listen 443 ssl default_server;
    server_name _;
    ssl_reject_handshake on;
    return 444;
}

server {
    listen 443 ssl;
    server_name your-domain.uz;
    # ... основной сайт
}

return 444 закрывает соединение без ответа, а ssl_reject_handshake (nginx 1.19.4+) отклоняет TLS-соединение для неизвестного домена.

Заголовки, передаваемые в прокси

Если nginx проксирует запросы в приложение, не пропускайте присланный клиентом X-Forwarded-Host и передавайте Host со значением сервера:

proxy_set_header Host $host;
proxy_set_header X-Forwarded-Host "";

После исправления

Повторите команды curl выше: запрос с чужим Host должен отклоняться nginx, а X-Forwarded-Host — никак не влиять на ссылки. Проверьте ещё раз и письмо сброса пароля.

Итог

Host header injection не видна в коде — она живёт в настройках доверия. Исправление короткое: ограничить доверие к прокси, зафиксировать root URL и добавить default_server в nginx. Но об этом нужно знать и проверять заранее.

Хотите проверить, что ваш сайт не доверяет чужим заголовкам? Оставьте заявку на сканирование — Claude AI агент и наш специалист посмотрят вашу конфигурацию и помогут закрыть найденные дыры.