Мой бесплатный сервис чуть не стал сканером портов для всего интернета

Я сделал бесплатную проверку сайтов на нарушения 152-ФЗ: вводишь адрес, получаешь список проблем. Первая версия умела ещё кое-что, чего я в неё не закладывал. Подставляешь чужой хост и порт, и по тексту ошибки видно, открыт порт или закрыт. Бесплатный сканер портов для всего интернета, причём запросы идут с адресов Cloudflare.
Это одна из трёх дыр, которые автоматическое ревью нашло в первой версии до выкладки. Ниже про все три, про то, почему такой сервис вообще нельзя ставить рядом со своими серверами, и про слабые места. Одно из них я нашёл и закрыл, пока писал эту статью.
Что за сервис
Посетитель вводит адрес сайта. Проверка открывает главную страницу, читает разметку и ищет шесть типичных проблем с 152-ФЗ:
зарубежные сервисы на странице: Google Fonts, Tag Manager и Analytics, пиксель Facebook, Hotjar, публичные CDN вроде jsDelivr и unpkg, плееры YouTube и Vimeo, Google Maps. Всего 14 доменов. Браузер посетителя ходит к ним напрямую и отдаёт свой IP-адрес;
формы с именем, телефоном или почтой и без галочки согласия;
нет политики обработки персональных данных;
сайт открывается по http, и данные из форм идут открытым текстом;
куки ставятся сразу при заходе, до любого клика;
разметка главной тяжелее 3 МБ.
JavaScript не выполняется, в формы и личные кабинеты проверка не заходит. Это не юридическое заключение, и так прямо написано на самой странице.
Звучит безобидно. Интересно становится, когда задаёшь простой вопрос: а откуда, собственно, делается запрос?
Почему не на моём сервере
У такого сервиса есть неприятное свойство: адрес, по которому он пойдёт, выбирает посетитель. Это SSRF-поверхность в чистом виде.
На моих серверах живут боты и служебные сервисы, которые слушают только 127.0.0.1. Так и задумано: внутренние панели и API не торчат наружу. Но поставь рядом проверку сайтов, и посетитель введёт http://127.0.0.1:<порт>/. Сервер вежливо сходит сам к себе и покажет ответ внутреннего сервиса. Его ведь попросили.
Закрыть это можно фильтрами. Но фильтры пишет человек, и ниже видно, как у меня это получилось с первого раза. Поэтому я убрал сам периметр. Проверка живёт в Cloudflare Worker: у него нет localhost с моими сервисами, нет моей внутренней сети и нет ключей. Если фильтр ошибётся, дотянуться всё равно будет не до чего моего.
Схема:
Браузер посетителя
│
├─> страница /check/ (статика)
│
└─> Worker: GET /check?url=...
1. разбор адреса: только http/https, блок-лист хостов, порт 80/443
2. лимит: 20 проверок в час с одного IP
3. DoH-резолв: все адреса хоста публичные?
4. запрос с redirect: manual, до 3 прыжков, каждый проверяется заново
5. разбор HTML → список находок в JSON
Первая версия была дырявой
Первая версия выглядела аккуратно: только http и https, чёрный список приватных диапазонов по тексту хоста (localhost, 127.0.0.0/8, 10.0.0.0/8, 192.168.0.0/16, 172.16.0.0/12, 169.254.0.0/16), таймаут и лимит размера. Я был доволен. Перед выкладкой её разобрало автоматическое ревью коммита и нашло три дыры. Подтвердились все три.
Дыра 1: localhost под чужим именем
Блок-лист смотрит на строку: localhost, 127.0.0.1. Но домен может называться как угодно и указывать куда угодно. Публичный домен localtest.me резолвится в 127.0.0.1, и первая версия пропускала его без вопросов: в имени же ничего подозрительного.
Внутри воркера такой запрос почти безвреден. А вот на моём сервере тот же фильтр открыл бы дорогу к внутренним сервисам. Ровно от этого я и уходил.
Лечится так: перед каждым запросом воркер сам резолвит имя через DNS-over-HTTPS и пускает дальше, только если все адреса хоста публичные.
async function resolvesToPublicOnly(hostname) {
const ask = async (type) => {
const r = await fetch(
`https://cloudflare-dns.com/dns-query?name=${encodeURIComponent(hostname)}&type=${type}`,
{ headers: { Accept: "application/dns-json" } }
);
if (!r.ok) return null;
const j = await r.json();
return (j.Answer || []).filter((a) => a.type === 1 || a.type === 28).map((a) => a.data);
};
// сокращено: A и AAAA запрашиваются параллельно, пустой ответ означает отказ
return addresses.every((ip) => !isPrivateIp(ip));
}
isPrivateIp покрывает IPv4: 0.0.0.0/8, 10.0.0.0/8, 127.0.0.0/8, 169.254.0.0/16 с метаданными облака, 172.16.0.0/12, 192.168.0.0/16, CGNAT 100.64.0.0/10 и всё от 224 и выше. И IPv6: ::1, fc00::/7, fe80::/10, а ещё IPv4-mapped адреса вида ::ffff:127.0.0.1, которые иначе проскочили бы как «не IPv4».
Проверил вживую, пока писал этот текст: localtest.me сейчас получает общий отказ с кодом 502.
Дыра 2: вежливые ошибки, которые сдают порты
Первая версия старалась быть полезной и объясняла, что пошло не так: «сайт не отвечает», «это не HTML», «истёк таймаут». Звучит заботливо. На деле это оракул. Подставляешь http://чужой-хост:22, :5432, :6379 и по тексту ответа видишь, какие порты открыты. Бесплатный сканер портов для всего интернета, и цель видела бы в логах только адреса Cloudflare.
Закрыл в два слоя. Разрешены только веб-порты: 80, 443 или порт не указан. А любая сетевая неудача, таймаут или ответ не в HTML получают один и тот же код 502 и один и тот же текст:
const OPAQUE_FAIL = "Сайт не открылся: не отвечает, закрыт для проверки или это не веб-страница";
Да, понять, почему твой сайт не открылся, теперь сложнее. Это честная цена.
Дыра 3: редирект, который уводит за кулисы
fetch с redirect: "follow" проверяет только первый адрес. Дальше сервер на той стороне отвечает 302 куда захочет, и воркер послушно идёт туда, мимо блок-листа и DNS-проверки. Охрана на входе есть, а за дверью никого.
Теперь redirect: "manual", не больше трёх прыжков, и каждый следующий адрес заново проходит проверку порта, блок-лист и DoH-резолв.
for (let hop = 0; hop <= MAX_REDIRECTS; hop++) {
if (!ALLOWED_PORTS.has(current.port)) return null;
if (BLOCKED_HOST_PATTERNS.some((re) => re.test(current.hostname))) return null;
if (!(await resolvesToPublicOnly(current.hostname))) return null;
const response = await fetch(current.toString(), { redirect: "manual", signal });
if (response.status >= 300 && response.status < 400) {
current = new URL(response.headers.get("location"), current); // сокращено: проверки Location
continue;
}
return { response, finalUrl: current };
}
return null;
Как я проверяю, что дыры закрыты
Тест гоняет логику на заведомо опасных входах. На одних чистых входах он бы только успокаивал.
11 адресов, которые ведут внутрь сетей или вообще не в веб: 127.0.0.1, localhost, 10.0.0.5, 192.168.1.1, 172.16.0.1, 169.254.169.254 (метаданные облака),
[::1],router.local, а ещёfile://,ftp://иjavascript:. Отклоняются все 11.4 адреса с нестандартными портами, включая SSH и PostgreSQL, отклоняются. 3 варианта обычных веб-портов принимаются.
12 приватных адресов распознаются, включая
::ffff:127.0.0.1и100.64.0.1. 4 публичных, в том числе IPv6, принимаются.Разбор находок: страница с Google Fonts, GTM, формой с телефоном, без политики и с кукой даёт все ожидаемые находки, чистая страница лишних не даёт.
Слабые места
Окно DNS rebinding. Между моим DoH-резолвом и самим запросом проходит время, и запись может смениться на приватную. Полностью это закрывается запросом по IP с подстановкой заголовка Host, а Workers так делать не дают. Остаточный риск небольшой: даже если трюк сработает, запрос уйдёт изнутри Cloudflare, а не из моей сети.
Лимит приблизительный. 20 проверок в час с одного IP считаются через кэш воркера, а кэш у Cloudflare свой в каждом дата-центре. От случайного злоупотребления это защищает, от распределённой нагрузки нет.
Эвристики простые. Любая галочка в форме считается согласием. Зарубежный сервис определяется по вхождению домена в разметку, так что сработает и упоминание в тексте. JavaScript не выполняется, поэтому одностраничные приложения видны плохо.
Нашёл, пока писал эту статью. Таймаут 12 секунд снимался сразу после получения заголовков ответа, а тело страницы читалось целиком. Лимит 1,5 МБ ограничивал только разбор текста. Скачивалось всё. Проверяемый сайт мог отдавать огромную или бесконечно медленную страницу и держать проверку, пока её не оборвёт сам Cloudflare. А обрыв сети посреди скачивания давал ошибку 500 вместо общего 502 и снова подсказывал лишнее. Закрыл в тот же день: тело читается потоком под тем же таймером, чтение останавливается на 3 МБ, любой сбой отдаёт общий 502. На стенде страница в 50 МБ дочитывается до 3 МБ, бесконечная обрывается на двенадцатой секунде.
Итог
Главное решение тут принято до первой строки кода: где эта проверка вообще исполняется. Фильтры я написал с тремя дырами, и их нашли до выкладки. Стой проверка на моём сервере, каждая из этих дыр была бы дверью к внутренним сервисам. В воркере та же ошибка обходится намного дешевле: за ней нет ничего моего.
Если ваш сервис ходит по адресам, которые вводит пользователь, я бы выносил его подальше от всего внутреннего сразу, даже когда фильтры кажутся надёжными. Мои тоже казались.
KioskNews shows a cleaned-up reading view extracted from the publisher’s page — the original always lives on their site, not ours.