Берегите очко смолоду
Часть 6 — про публичную поверхность и разведку.

Что это значит для владельца#

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

Если команда не может быстро назвать все внешние точки входа, она не управляет риском. Сначала нужно составить карту, потом решать, что закрывать, переносить за VPN, защищать rate limit или убирать совсем.

Что это значит для владельца#

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

Если команда не может быстро назвать все внешние точки входа, она не управляет риском. Сначала нужно составить карту, потом решать, что закрывать, переносить за VPN, защищать rate limit или убирать совсем.

Когда я объясняю, ради чего вообще затевалась эта серия, я обычно прошу собеседника представить простую вещь. Атакующий не сидит и не читает ваш код — он его не видел. Он сидит снаружи и смотрит на то, что вы сами выставили в интернет. И первое, чем он занимается, — это разведка. Не взлом, а инвентаризация: что у вас вообще торчит наружу и что из этого болтает лишнего.

Так вот, болтает обычно много.

Карта эндпоинтов в подарок#

Открытый Swagger в проде — это первое, на что натыкаешься. /docs без всякой авторизации, а там — список всех ручек API, параметры, форматы запросов и ответов. Разработчику удобно, разведчику ещё удобнее: не надо ничего угадывать, готовая карта местности уже нарисована.

Документация API нужна на разработке, не в бою. Поднимается она по флагу окружения, в проде просто не существует. И да — swagger.json не запекается в образ, иначе его выкопают, даже когда вы убрали саму страничку.

if (process.env.ENABLE_DOCS === "true") {
  app.use("/docs", swaggerUi);
}

Ошибки, которые рассказывают слишком много#

Дальше — обработка ошибок. Прилетает невалидный запрос, и сервер честно отвечает: «поле user.billing.cardId ожидало число, получило строку, вот стек вызовов». Для отладки прекрасно. Для атакующего — бесплатная экскурсия по внутренней структуре DTO: он узнаёт имена полей, типы, вложенность, иногда — куски путей файловой системы.

Глобальный фильтр исключений решает это. В проде наружу едет обобщённое «что-то пошло не так» и id ошибки, а все подробности — стек, значения, путь — остаются в серверных логах, где им и место.

app.useGlobalFilters({
  catch(err, res) {
    logger.error(err); // детали в логи
    res.status(500).json({ error: "Internal error", id: traceId });
  },
});

/health, который вываливает потроха#

Health-check — полезная штука, но я регулярно вижу, как он отдаёт наружу статус базы, версию Redis, состояние очередей и заодно версии всех зависимостей. По сути это ещё один разведывательный эндпоинт, только добровольный. Снаружи достаточно знать, жив сервис или нет. Всё остальное — для внутреннего мониторинга, не для случайного прохожего.

CORS с забытым staging-доменом#

Тонкий момент, который мало кто держит в голове. В CORS-allowlist в проде остались localhost и какой-нибудь staging.example. Локалхост — вроде безобидно. А вот staging-домен однажды перестают продлевать, он освобождается, и его регистрирует кто-то посторонний. Теперь с этого домена можно слать к продакшену credentialed-запросы — браузер их разрешит, потому что домен в списке разрешённых.

В коде должны быть только прод-домены. Всё остальное — через переменные окружения, и кто-то должен присматривать, чтобы в списке не оставалось «забытых» адресов, которые однажды уплывут в чужие руки.

Заголовки, которые представляются#

X-Powered-By, версия сервера, баннер фреймворка — мелочь, на которую машут рукой. А зря: это сразу сужает атакующему круг поиска. Он видит «такой-то фреймворк такой-то версии» и идёт смотреть известные дыры именно под него. Отключить эти заголовки — элементарная гигиена, которая ничего не стоит.

Эндпоинт, который подтверждает существование ящиков#

Был неаутентифицированный OTP-эндпоинт. Вводишь email — он отвечает по-разному в зависимости от того, есть такой пользователь или нет. И вот ты уже скриптом проверяешь admin@, root@, support@ и собираешь список реальных служебных ящиков. Перечисление в чистом виде.

Любой неаутентифицированный эндпоинт стоит рассматривать как потенциальный инструмент перечисления. Ответы делаются неинформативными — одинаковыми независимо от того, существует адрес или нет, — и сверху обязательно rate-limit, чтобы перебор стал хотя бы дорогим.

Периметр за пределами приложения#

Всё, о чём я писал выше, живёт в коде. Но самое неприятное обычно живёт там, где кода уже нет. Атакующий перебирает DNS-имена, смотрит обратные записи, по одному IP находит соседние поддомены и натыкается на забытые сервисы — старую админку, тестовый дашборд, мониторинг, который «ну он же только для своих».

И вот тут я честно скажу то, ради чего всё затевалось. В self-hosted и небольших командах почти никогда нет человека, который уверенно ответит на вопрос: «какие из наших поддоменов и панелей реально доступны из интернета?». Не «должны быть доступны», а реально доступны прямо сейчас. Это не про плохой код. Это про разрыв между «кодом» и «инфраструктурой» — и про то, что куча рисков просто повисает в воздухе, без хозяина.

Лечится это не технологией, а дисциплиной. Нужна инвентаризация публичной поверхности: список того, что выставлено наружу, с явным решением по каждому пункту — это должно быть публичным или нет. Всё, что не прошло проверку «зачем оно тут», закрывается.

Отдельная мелкая, но показательная вещь — /.well-known/security.txt. Это не защита и не баг-баунти, а табличка «если нашли дыру, писать сюда». Без неё нормальный исследователь начинает искать контакт по футеру, WHOIS, соцсетям или просто откладывает отчёт.

У BobDaHacker это хорошо видно в публичных разборах: в истории с FIFA исследователь дошёл до звонков MediaKind, CISA и FBI, потому что нормального канала для сообщений об уязвимостях не нашлось; в кейсе McDonald's контакт для таких отчётов оказался сложнее самой технической находки; у Frontier Airlines критичные утечки жили месяцами после отчётов. security.txt не чинит авторизацию, но сокращает путь от «кто-то снаружи нашёл дыру» до человека, который может её закрыть.

В чек-листе публичной поверхности такой файл должен быть рядом со Swagger, health-check и заголовками: канонический путь, Contact, Expires, понятный язык для отчётов. И без личных телефонов или идентификаторов личных чатов, если вы не готовы принимать туда мусор.

Граница «код vs инфра»#

И ещё одна вещь, которая постоянно остаётся ничьей. Часть рисков физически не живёт в приложении. Request smuggling на уровне прокси, общий стор для rate-limit, когда инстансов несколько и каждый считает лимиты по-своему, — это всё инфраструктурный слой. Разработчик скажет «это не мой код», админ скажет «это логика приложения», и проблема зависает между ними.

Вывод у всей серии, по сути, один. У периметра должен быть владелец. Конкретный человек, который знает, что выставлено наружу, и отвечает за это. Пока такого человека нет, кто-то снаружи уже составляет вашу карту — просто вы об этом не знаете.


Если вы не можете быстро ответить, какие домены, админки, API и служебные панели видны снаружи, это уже риск. Напишите мне в Telegram или на почту — помогу собрать карту публичной поверхности, убрать лишнее и назначить понятную ответственность за периметр.