Я сделал бесплатную проверку сайтов на нарушения 152-ФЗ: вводишь адрес, получаешь список проблем. Первая версия умела ещё кое-что, чего я в неё не закладывал. Подставляешь чужой хост и порт, и по тексту ошибки видно, открыт порт или закрыт. Бесплатный сканер портов для всего интернета, причём запросы идут с адресов Cloudflare.Это одна из трёх дыр, которые автоматическое ревью нашло в первой версии до выкладки. Ниже про все три, про то, почему такой сервис вообще нельзя ставить рядом со своими серверами, и про слабые места. Одно из них я нашёл и закрыл, пока писал эту статью. Читать далее
Уровень сложностиСредний
Время на прочтение6 мин
Охват и читатели5.9K
Кейс
Я сделал бесплатную проверку сайтов на нарушения 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.
Первая версия старалась быть полезной и объясняла, что пошло не так: «сайт не отвечает», «это не 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 МБ, бесконечная обрывается на двенадцатой секунде.
ИтогГлавное решение тут принято до первой строки кода: где эта проверка вообще исполняется. Фильтры я написал с тремя дырами, и их нашли до выкладки. Стой проверка на моём сервере, каждая из этих дыр была бы дверью к внутренним сервисам. В воркере та же ошибка обходится намного дешевле: за ней нет ничего моего.
Если ваш сервис ходит по адресам, которые вводит пользователь, я бы выносил его подальше от всего внутреннего сразу, даже когда фильтры кажутся надёжными. Мои тоже казались.
| # | Наименование новости | Тональность | Информативность | Дата публикации |
|---|---|---|---|---|
| 1 | Как я писал сервер и нечаянно пробил 1М RPS | 0 | 10 | 31-08-2026 |
| 2 | «Интернета нет — а сайт работает»: как мы учили PWA замечать авиарежим на iPhone | 0 | 8.82 | 29-09-2026 |
| 3 | Карта чёрная, в консоли ни одной ошибки. Три бага одной подложки | 0 | 6.61 | 22-09-2026 |
| 4 | Наводишь на слово в субтитрах — видишь перевод: расширение на встроенном ИИ Chrome | 0 | 7.72 | 27-09-2026 |
| 5 | ИИ-ревью, или как я себя чуть не уволил | 0 | 9.94 | 02-10-2026 |
| 6 | Морские карты S-57 в браузере: открытый парсер на TypeScript и что в нём сломали и починили первые пользователи | 0 | 6.55 | 27-09-2026 |
| 7 | Продвинутый статический анализ в TypeScript-проектах: выходим за рамки tsc и ESLint | 0 | 14.75 | 01-10-2026 |
| 8 | А точно ли вы знаете, чем занят ваш агент в песочнице? | 0 | 9.71 | 15-09-2026 |
| 9 | Address already in use: как найти, кто занял порт, и почему он не всегда освобождается сразу | -1 | 5.36 | 02-10-2026 |
| 10 | [Перевод] От тестирования релиза с высоким уровнем риска к новому ИИ-инструменту для QA | 0 | 11.89 | 26-09-2026 |