javascript

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

  • вторник, 29 сентября 2026 г. в 00:00:12
https://habr.com/ru/articles/1086870/

Я сделал бесплатную проверку сайтов на нарушения 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 МБ, бесконечная обрывается на двенадцатой секунде.

Итог

Главное решение тут принято до первой строки кода: где эта проверка вообще исполняется. Фильтры я написал с тремя дырами, и их нашли до выкладки. Стой проверка на моём сервере, каждая из этих дыр была бы дверью к внутренним сервисам. В воркере та же ошибка обходится намного дешевле: за ней нет ничего моего.

Если ваш сервис ходит по адресам, которые вводит пользователь, я бы выносил его подальше от всего внутреннего сразу, даже когда фильтры кажутся надёжными. Мои тоже казались.