Vite за Nginx: точный allowedHosts вместо true
Москва, Щёлковское шоссе, д. 92, корп. 7 · Пн–Пт 9:00–19:00 · +7 903 729-62-41
АйТи Фреш
DevOps и автоматизация

Почему Vite за Nginx блокирует hostname и как настроить allowedHosts

Автор: Семёнов Евгений Сергеевич, директор ООО «АйТи-Фреш» · · ~15 мин чтения
Почему Vite за Nginx блокирует hostname и как настроить allowedHosts
Иллюстрация к статье «Почему Vite за Nginx блокирует hostname и как настроить allowedHosts».

После обновления Vite внутренний адрес перестал открываться, а `allowedHosts: true` мгновенно всё починил? Я оставляю точный список: сначала выясняю, какой Host передаёт Nginx, затем разрешаю нужное имя. Я — Семёнов Евгений Сергеевич, директор ООО «АйТи-Фреш». Покажу на лабораторном стенде, какие имена Compose действительно нужны, почему отключение проверки меняет защиту и как проверить результат.

Обновление включило проверку, которой раньше не было

Узнаваемая ситуация: контейнер запущен, Nginx отвечает, но вместо приложения приходит Blocked request. This host ("frontend") is not allowed. Первым делом я смотрю тело ответа и статус. Такой 403 означает, что запрос дошёл до проверки Vite. Ошибки DNS, отказ соединения и типичный 502 от прокси разбираются иначе. Пересобирать Docker-сеть и менять порты до этого простого разделения — верный способ добавить к одной неисправности ещё две.

Проверка Host появилась при исправлении GHSA-vg6x-rcgg-rjx6: первые исправленные версии — 6.0.9, 5.4.12 и 4.5.6, advisory опубликовано 20 января 2025 года. Поэтому даже обновление patch-версии могло сломать доступ через внутреннее имя. Это историческая граница изменения, а не рекомендация установить сегодня 6.0.9. В 2026 году нужно брать исправленный выпуск поддерживаемой ветки. Описание изменения и сценария reverse proxy.

Я начинаю с фактически работающего процесса: docker compose exec frontend npm ls vite, затем смотрю команду запуска в Compose и docker compose exec nginx nginx -T. Версия в старом README ничего не доказывает. Заодно выясняю, запущен vite или vite preview: у preview есть собственный preview.allowedHosts, который по умолчанию наследует server.allowedHosts, но может быть переопределён. Исправлять соседний режим бессмысленно. Настройки preview.

Не путайте server.host и server.allowedHosts: первая настройка задаёт интерфейсы прослушивания, вторая — допустимые имена в запросах.

Разрешать нужно имя из HTTP Host, а не все имена контейнеров

В этой задаче легко смешать три разные вещи: адрес в браузере, имя для соединения Nginx с upstream и заголовок Host, который получает Vite. Пусть браузер обращается к portal.vector.test:8080, а Nginx соединяется с frontend:5173. Из этого ещё не следует, какое имя проверит Vite. Решает конфигурация прокси. В исходной реализации middleware читает req.headers.host и сравнивает hostname без порта; Origin и X-Forwarded-Host эту проверку не заменяют. Код проверки Vite 6.0.9.

Без явного proxy_set_header Host Nginx по умолчанию использует $proxy_host. Для proxy_pass http://frontend:5173 это будет frontend:5173, и разрешать потребуется frontend. Если проксирование идёт через именованную upstream-группу vitepool, в Host может оказаться vitepool. А с proxy_set_header Host $host до Vite дойдёт имя из клиентского обращения. Поэтому я сначала читаю итоговый конфиг с include-файлами, а уже потом составляю список. Правила proxy_set_header.

Мой выбор для обычного dev-стенда — сохранять $host и явно разрешать адрес приложения. Это проще проверять и объяснять следующему разработчику. Имя сервиса Compose нужно только для реального маршрута, где оно становится HTTP Host: например, для прямого запроса тестового контейнера. Названия сети, проекта, контейнера Nginx и всех соседних сервисов в список не попадают «на всякий случай». Docker DNS обеспечивает поиск сервиса, но не превращает его имя в обязательный элемент allowedHosts. Сеть и имена сервисов Compose.

В allowedHosts нужны hostname без протокола, порта и пути. Список сервисов из compose.yaml копировать туда не нужно.
Почему Vite за Nginx блокирует hostname и как настроить allowedHosts — схема
Схема к статье. Открыть схему в полном размере

Стенд «Петля и пряжа»: воспроизведение и схема для Compose

Для разбора беру стенд «Петля и пряжа», моделирующий внутреннюю панель заявок. Название условное; это лабораторное воспроизведение, а не история раскрытого клиентского проекта. В измеренной части использованы Vite 6.0.9, Node.js 20.20.2 и Nginx 1.30.4. Vite слушал 127.0.0.1:5173, Nginx работал в контейнере с сетью хоста, а имя frontend было локальным alias для 127.0.0.1. Три конфигурации прокси слушали loopback-порты 18080–18082. Это проверка HTTP-заголовков; работу внутреннего DNS Compose этим опытом я не подтверждаю.

Сначала в allowedHosts стояло только portal.vector.test. Прокси без явного Host вернул 403 с именем frontend. Прокси с Host $host дал 200 для portal.vector.test и 403 для evil.invalid. Добавление frontend в точный массив восстановило первый маршрут, сохранив отказ для чужого имени на втором. Затем контрольное включение true изменило ответ для evil.invalid на 200. Вот чем закончилась проверка: обе точные конфигурации работоспособны, но у каждой свой ожидаемый Host; общий выключатель пропускает дополнительный нежелательный запрос. Браузерный HMR в этом опыте не проверялся. После возврата точного списка отдельно проверен входной фильтр Nginx на порту 18083: нужное имя получило 200, чужое — 400. По окончании опыта временный контейнер удалён, исторический Vite остановлен.

Для переноса схемы в Compose ниже предполагается готовый проект с package-lock.json, поддерживаемым Vite 8.2.x и скриптом dev: vite. Это отдельный пример конфигурации, не отчёт о запуске всей связки. Файл compose.yaml:

services:
  frontend:
    image: node:24-bookworm-slim
    working_dir: /app
    command: ["sh", "-c", "npm ci && npm run dev"]
    volumes:
      - .:/app
      - frontend_modules:/app/node_modules
  nginx:
    image: nginx:1.30.4-alpine
    ports:
      - "127.0.0.1:8080:80"
    volumes:
      - ./nginx.conf:/etc/nginx/conf.d/default.conf:ro
    depends_on:
      - frontend
volumes:
  frontend_modules:

У frontend нет ports: Nginx обращается к контейнерному 5173 через общую сеть. expose для такой связи необязателен. При этом отсутствие публикации порта не изолирует frontend от остальных участников сети. Публикация портов Docker.

Порт 8080 доступен с Docker-хоста; для браузера сопоставьте portal.vector.test с 127.0.0.1 через hosts или DNS. Команды ниже используют --resolve, который действует только для соответствующего запроса curl. depends_on задаёт порядок запуска, но не ждёт окончания npm ci: ранний 502 ожидаем. Тег Node обновляется внутри ветки, поэтому для воспроизводимого командного стенда я фиксирую проверенный digest образа. Node 24 LTS выбран для нового окружения; исторический runtime воспроизведения переносить не нужно. Ни нормативов по памяти, ни оценок производительности из этого маленького опыта вывести нельзя.

Цифры и версии: Стенд «Петля и пряжа»: воспроизведение и схема для Compose — схема
Цифры и версии: Стенд «Петля и пряжа»: воспроизведение и схема для Compose. Открыть схему в полном размере

Конфигурация, которую я оставляю после исправления

В существующем vite.config.ts дополняю блок server, сохраняя плагины React/Vue и остальные настройки проекта. Ниже показан минимальный пример. Протокол, порт и путь в массив не входят. host: '0.0.0.0' нужен, чтобы Vite слушал интерфейс контейнера и был доступен соседнему Nginx; это другая настройка. strictPort не позволяет незаметно уйти с 5173 на следующий порт, пока прокси продолжает стучаться в старый. При таком устройстве мне не нужен frontend в allowedHosts: Nginx передаёт portal.vector.test.

import { defineConfig } from 'vite'

export default defineConfig({
  server: {
    host: '0.0.0.0',
    port: 5173,
    strictPort: true,
    allowedHosts: ['portal.vector.test'],
  },
})

В файле nginx.conf я сохраняю Host и отдельно отклоняю неизвестные имена на входе. Этот файл заменяет /etc/nginx/conf.d/default.conf официального образа. map находится в контексте http, куда включается conf.d; внутрь server его переносить нельзя. Отдельный default server нужен потому, что один server_name сам по себе не запрещает остальные имена: запрос может попасть в сервер по умолчанию. Выбор виртуального сервера Nginx.

map $http_upgrade $connection_upgrade {
    default upgrade;
    ''      close;
}

server {
    listen 80 default_server;
    server_name _;
    return 400;
}

server {
    listen 80;
    server_name portal.vector.test;

    location / {
        proxy_pass http://frontend:5173;
        proxy_http_version 1.1;
        proxy_set_header Host $host;
        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection $connection_upgrade;
    }
}

Добавить ровно frontend вместо изменения Host тоже допустимо, если это осознанная архитектура. Но здесь есть существенная оговорка. Когда прокси принимает любое внешнее имя и всем запросам подставляет разрешённый frontend, Vite уже не видит исходное чужое имя. Точный массив остаётся в файле, а нужную границу фактически должен обеспечивать Nginx. Я предпочитаю, чтобы это было видно в конфигурации, а не существовало только в голове её автора.

Если прокси заменяет любой входящий Host на разрешённый, проверять исходное имя должен сам прокси.

Что снимает allowedHosts: true и насколько это опасно

allowedHosts: true разрешает любые имена. При DNS rebinding страница атакующего может сначала загрузиться с его сервера, а затем через изменённый DNS обратиться к доступному dev-серверу, сохраняя собственное доменное имя в запросе. Проверка Host позволяет отвергнуть такое обращение. Возможность эксплуатации зависит от браузера и сетевых ограничений; утверждать, что после одной строки непременно украдут весь репозиторий, неправильно. Но доступ к исходникам, которые обслуживает dev-сервер, — вполне предметный риск. Модель угроз из advisory Vite.

В закрытом стенде с контролируемым доступом риск обычно ниже, чем у открытого порта на рабочем ноутбуке. Эта строка сама не публикует порт и не отменяет VPN, авторизацию прокси или ограничения файлов. Однако и точный список не заменяет аутентификацию: обычный HTTP-клиент способен отправить допустимый Host вручную. Я не драматизирую временную диагностическую правку, но оставлять её постоянной считаю плохой экономией. Указать одно известное имя ненамного сложнее, чем написать true.

У точного массива тоже есть границы. Vite автоматически допускает localhost, его поддомены и IP-адреса; есть дополнительные разрешения из других настроек, например server.origin. Поэтому обещание «теперь разрешён только один Host вообще» неверно. Запись .vector.test дополнительно охватывает корневое имя и все его поддомены; для одного портала я её не использую. Разрешать стоит только контролируемые имена. Документация allowedHosts, дополнительные разрешения в коде.

Точный allowedHosts не является аутентификацией и не отменяет автоматические разрешения localhost и IP.
Обратите внимание: Что снимает allowedHosts: true и насколько это опасно — схема
Обратите внимание: Что снимает allowedHosts: true и насколько это опасно. Открыть схему в полном размере

Проверяю отказ отдельно от успешного открытия

После запуска docker compose up -d жду готовности Vite в docker compose logs frontend, затем проверяю Nginx командой docker compose exec nginx nginx -t. Одной открывшейся страницы недостаточно. Мне нужны положительный запрос и отрицательный: иначе конфигурация с true выглядит столь же исправной, как точный список. Для показанной Compose-схемы ожидаются следующие ответы:

curl --resolve portal.vector.test:8080:127.0.0.1 \
  -o /dev/null -s -w '%{http_code}\n' \
  http://portal.vector.test:8080/
# 200

curl -H 'Host: foreign.test' \
  -o /dev/null -s -w '%{http_code}\n' \
  http://127.0.0.1:8080/
# 400 от Nginx

Второй запрос проверяет входной фильтр Nginx, а не allowedHosts самого Vite. Для отдельной проверки приложения я использую Node внутри frontend: публиковать 5173 ради диагностики не требуется. Здесь правильный адрес должен получить 200, а чужое имя — 403. Это функциональная проверка политики имён, не демонстрация эксплуатации DNS rebinding.

docker compose exec frontend node --input-type=module -e '
for (const host of ["portal.vector.test", "foreign.test"]) {
  const r = await fetch("http://127.0.0.1:5173/", {
    headers: { Host: host }
  });
  console.log(host, r.status);
}'

Дальше проверяю HMR в браузере: соединение WebSocket получает 101, изменение исходника появляется без ручной перезагрузки. Заголовки Upgrade и Connection в конфиге выше нужны именно для этого. Если HTML уже отдаётся, а обновления не приходят, снова расширять allowedHosts не надо. Сначала смотрю WebSocket URL и проксирование. В Vite 8.1 параметры соединения перенесены в server.ws; прежние поля server.hmr ещё совместимы, но устаревают. Для показанного маршрута ручные ws-настройки обычно не нужны. WebSocket в Nginx, изменения Vite 8.1.

Ещё одна ловушка — HTTPS перед Nginx. Документация говорит о пропуске host-check, когда HTTPS включён у самого Vite. Если TLS завершается на прокси, а дальше стоит proxy_pass http://frontend:5173, сервер Vite остаётся HTTP-сервером и проверка действует. Замок в адресной строке этого не меняет. Я также не добавляю cors: true для исправления Host: разрешения Origin и hostname решают разные задачи, и массовое снятие обеих проверок только затрудняет диагностику. Условие установки middleware в Vite.

400 в показанном примере проверяет фильтр Nginx; 403 при прямом запросе проверяет Vite.

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

Порядок действий для меня простой: выяснить реальный Host, восстановить нужный маршрут точным разрешением, проверить чужое имя, затем проверить HMR. Очистку всех кешей, смену Docker DNS и переезд на другой порт откладываю до появления соответствующих симптомов. После правки фиксирую рядом Nginx-конфиг, Vite-конфиг и короткие проверки. Комментарий «portal.vector.test приходит через Host $host» полезнее длинного списка имён, происхождение которых через месяц никто не вспомнит. В автоматическую проверку после обновления зависимости я включаю оба HTTP-сценария. Положительный ловит сломанный доступ команды, отрицательный — случайный возврат true. Если прямые CI-запросы действительно используют имя frontend, это отдельное документированное разрешение, которое удаляется вместе с соответствующим маршрутом.

Учитываю и способ доставки изменений. Новый конфиг, который запечён в образ, требует пересборки; изменённые переменные Compose — пересоздания контейнера, одного restart недостаточно. После пересоздания frontend его IP может поменяться, а простой статический upstream Nginx — сохранить прежний адрес: для этого стенда предусматриваю reload или перезапуск Nginx. Такая неисправность обычно проявляется как ошибка связи. Добавление ещё одного hostname её не исправит.

На 5 сентября 2026 года регулярные исправления получает Vite 8.2; для нового окружения я выбираю поддерживаемую ветку и Node 24 LTS. Исторические 6.0.9 и Node 20 из воспроизведения не являются целевым комплектом. А если внутренний портал уже обслуживает сотрудников, сначала выясняю, нужен ли ему вообще dev-сервер: обычную SPA собираю через vite build и отдаю dist Nginx. vite preview тоже предназначен для проверки сборки, а не production. Для SSR нужен предусмотренный фреймворком runtime. Поддержка Vite, релизы Node.js, развёртывание сборки.

Цифры и версии: Что закрепить, чтобы следующая сборка не вернула проблему — схема
Цифры и версии: Что закрепить, чтобы следующая сборка не вернула проблему. Открыть схему в полном размере

Частые вопросы

Нужно ли разрешать frontend, nginx и имя сети Compose?

Только если конкретное имя реально приходит в HTTP Host допустимого запроса. При Host $host и адресе portal.vector.test имя frontend остаётся адресом соединения Nginx с сервисом и само по себе не требует разрешения.

Можно ли настроить разрешённое имя через environment?

Да. Для Vite 8.2 передайте процессу через Compose environment переменную __VITE_ADDITIONAL_SERVER_ALLOWED_HOSTS=portal.vector.test. Она дополняет разрешения. Поддержка нескольких имён через запятую появилась в Vite 8.1; для совместимости со старыми ветками используйте массив в конфиге. После изменения environment пересоздайте контейнер.

Почему доступ по IP работает, хотя имени нет в списке?

IP-адреса разрешены Vite автоматически, как localhost и его поддомены. Точный массив задаёт дополнительные имена и не превращает Host-проверку в сетевой ACL.

Почему после исправления 403 не работает HMR?

Проверьте проксирование WebSocket и адрес соединения в браузере. Успешная выдача HTML не подтверждает работу HMR; расширение allowedHosts не исправит отсутствующие Upgrade и Connection.

Разберём ваш стенд разработки
Если после обновления Vite стенд держится на `allowedHosts: true`, я помогу разобрать цепочку запросов и настроить точные разрешения. В «АйТи-Фреш» можно обратиться с задачей по Nginx, Docker и инфраструктуре разработки — начнём с конфигурации и проверяемого результата.
Бесплатная консультация →

Источники

© ООО «АйТи-Фреш» · Москва · Все статьи

Столкнулись с похожей задачей? Обращайтесь — решим

Если у вас происходит что-то из описанного в этой статье — или любая другая проблема с ИТ-инфраструктурой, — обращайтесь в любое время. Мои специалисты и я лично разберём ситуацию, найдём настоящую причину и доведём до решения.

Возьмёмся и за разовую задачу, и за постоянное обслуживание. Первичная консультация — бесплатно и без обязательств.

📞 +7 903 729-62-41 ✈ Telegram @ITfresh_Boss

С уважением, Семёнов Евгений Сергеевич, директор «АйТи Фреш» — IT-аутсорсинг для компаний до 50 рабочих мест, 15+ лет практики

АйТи-Фреш (ООО «АЙТИ-ФРЕШ») · г. Москва, Щёлковское шоссе, д. 92, корп. 7 · +7 903 729-62-41 · info@itfresh.ru · Пн–Пт 9:00–19:00