PunchKano begins diphtheria immunisation in five Kano LGsRTP DesportoFrancisco Cabral na final de pares em HangzhouESPN DeportesGrecia derrotó a Alemania por primera vez en la historia y golpeó a Klopp en su debut ante su públicoThe Jerusalem PostIsrair awaits final approval for Tokyo, Miami flights, adds new European destinations for 2027Daily MaverickWe worried AI would make things up, we should also worry when it doesn’tInquirerRains to continue in Visayas, Mindanao due to ITCZ until Sept. 30Bollywood HungamaVinod Kapri's Pyre to release in theaters on October 23, 2026BlickNächstes Amt abgelegt: Jens Spahn zieht sich aus Haushaltsausschuss zurückRapplerPhilippines should fix tax gaps and procurement, not raise tax rates – WBSportstarIndia in Athletics LIVE Updates, Asian Games 2026: Vithya Ramraj breaks National Record to win 400m hurdles bronze; Javelin throw final at 4:45 PM ISTIl Fatto QuotidianoMorto Stefano Milani, il tifoso del Milan diventato famoso su X. Da Bertolucci a Valenti: “Ha lottato come nessuno, mai un passo indietro”7sur7“Pourquoi je perds toujours?”: quand André Agassi taquine Alexander Zverev
The Daily Newsstand · Free, Always
Monday, September 28, 2026

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

Translate

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

Итог

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

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

View the original on Хабр →

KioskNews shows a cleaned-up reading view extracted from the publisher’s page — the original always lives on their site, not ours.