На одном сервере у нас живут полтора десятка проектов разных команд: сайты на WordPress, старый магазин на другой платформе, новое приложение с поиском и распознаванием картинок. Всё в Docker, всё за обратным прокси, который принимает 80 и 443 и раздаёт запросы по контейнерам. На словах наружу смотрит только прокси. Мы решили проверить это не по конфигам, а снаружи, — и нашли пять дыр, о которых никто не знал.
Это первая часть цикла «Общий сервер: безопасность и восстановление».
Как проверяли
Два независимых взгляда, которые должны совпасть. Снаружи — скан портов с обычной машины в другой сети. Изнутри — список того, что контейнеры публикуют на хост:
# какие контейнеры пробрасывают порты на хост и на какой адрес
docker ps --format '{{.Names}}\t{{.Ports}}' | grep -- '->'
# то же надёжнее — по каждому контейнеру, включая остановленные
for c in $(docker ps -aq); do
docker inspect -f '{{.Name}} {{range $p, $b := .HostConfig.PortBindings}}{{$p}}→{{range $b}}{{.HostIp}}:{{.HostPort}} {{end}}{{end}}' "$c"
done | grep '→'
Из сорока контейнеров порты на хост публиковали ровно шесть: прокси (80 и 443 — так и задумано) и ещё пять. Внешний скан показал открытыми ровно эти же порты, ни одним больше. Когда два метода сходятся, можно перестать гадать.
Что нашлось
| Сервис | Что было снаружи | Чем грозит |
|---|---|---|
| векторная база (поиск похожих картинок) | HTTP-API без ключа, чтение, запись и удаление | любой снесёт или подменит коллекцию, приложение ляжет |
| PostgreSQL | порт 5432 на всех адресах, в pg_hba.conf — host all all all | постоянный подбор паролей и любая уязвимость до проверки пароля |
| три приложения | свой голый HTTP-порт мимо прокси | без TLS, без общих правил прокси; у одного маршрут через прокси уже был — порт просто лишний |
| хост | firewall выключен, цепочка DOCKER-USER пуста | барьер только у хостера |
К векторной базе достаточно было одного запроса без всякой авторизации: GET /collections вернул имя коллекции. В логах приложения с голым портом уже был стук чужого сканера. Это не теория: открытый порт находят за часы.
Почему firewall не спас бы
Первая мысль — «включим firewall, и всё». С Docker это не так. Публикуя порт (ports: "5432:5432"), Docker сам дописывает правила в iptables, в таблицу nat и свою цепочку DOCKER. Пакет на опубликованный порт уходит в контейнер раньше, чем до него доберутся правила ufw или firewalld в цепочке INPUT. Получается, что ufw показывает «deny 5432», а порт открыт.
Опубликованный порт Docker обходит firewall хоста. Закрывать нужно в самом compose (не публиковать или публиковать на 127.0.0.1) либо правилами в цепочке DOCKER-USER — её Docker проверяет до своих.
# запретить доступ к опубликованным портам контейнеров отовсюду, кроме своей сети
iptables -I DOCKER-USER -i eth0 ! -s 203.0.113.0/24 -j DROP
# (правило должно пережить перезагрузку: iptables-persistent / свой юнит)
Но правильнее не публиковать порт вовсе. Контейнерам между собой он не нужен — об этом следующая часть.
Откуда это берётся
Ни одна из дыр не была злым умыслом. Сервер собирали по общему стандарту: образы из реестра, базы внутри сети Docker, наружу — только через прокси. Новые сервисы ставил другой человек, по привычке «как на локальной машине»: ports для всего, что надо открыть хоть кому-то, база с доступом отовсюду «чтобы подключаться из IDE». На ноутбуке разработчика это безобидно, на сервере в интернете — нет.
Когда спросили, зачем базе открытый порт, ответ был «мне нужно к ней подключаться». То есть доступ нужен одному человеку, а открыт он всему интернету. Это разные задачи, и для первой публиковать порт не нужно.
Что сделать на своём сервере
- снаружи — скан портов с машины в другой сети (не с самого сервера: изнутри видно всё);
- изнутри —
docker inspectпо всем контейнерам, включая остановленные: остановленный с портом поднимется после перезагрузки; - сравнить два списка. Расхождение — повод разбираться, а не «наверное, хостер закрыл»;
- для каждого порта, кроме прокси, ответить на вопрос «кто ходит на него снаружи». Если никто — убрать
ports, если человек — SSH-туннель, если сервис из той же сети Docker — по имени контейнера; - базы и хранилища без ключа не держать даже внутри сети: ключ у векторной базы задаётся одной переменной окружения;
- после правки — повторить внешний скан. Порт должен стать closed или filtered, а не «наверное закрылся».
Тот же разбор стоит повторять при каждом новом сервисе от новой команды. Дыры такого рода никто не замечает сам: всё работает, жалоб нет, а узнаёшь о них только тогда, когда проверяешь снаружи.
Не уверены, что у вас торчит наружу?
Пришлите вывод docker ps — разберу, что опубликовано лишнее и как закрыть, не сломав связи между сервисами.
// загружаю комментарии…