30 августа делал security-ревью собственной инфраструктуры и решил проверить один служебный маршрут вручную, вместо того чтобы верить, что раз он «внутренний», значит закрыт. Обычный curl с ноутбука:
curl -s -X POST https://<хост>/webhook/mk-db-exec \ -H 'Content-Type: application/json' \ -d '{"sql":"SELECT current_user, current_database()"}'
Ответ:
[{"current_user":"postgres","current_database":"postgres"}]
HTTP 200, без единого заголовка авторизации. Это n8n-воркфлоу, где нода Webhook принимает JSON с произвольным SQL-текстом, передаёт его в ноду Postgres и возвращает результат. Задумывался как внутренний SQL-раннер для пары фоновых воркеров маркетингового бота — чтобы не тащить в каждый скрипт отдельный драйвер и креды, а бить одним HTTP-запросом в уже настроенное подключение. Подключение было заведено на кред с правами postgres — суперюзер, потому что так было проще на старте и никто не возвращался это поправить.
Что можно было сделать этим запросом
Права суперюзера в PostgreSQL — это не «читать чужие таблицы». Это буквально всё: SELECT/INSERT/UPDATE/DELETE в любой схеме основной базы платформы, включая клиентские данные и таблицы публикаций от имени живого аккаунта. Отдельно я проверил COPY ... TO PROGRAM — команда, которая может пропускать результат запроса через внешнюю программу ОС. У PostgreSQL это документированная функциональность, доступная суперюзеру и роли pg_execute_server_program по умолчанию, и security-сообщество расходится в том, считать ли это уязвимостью движка (EnterpriseDB прямо называет CVE-2019-9193 «не багом», раз для эксплуатации нужны именно эти права) — но для эксплуатации ей ровно всё равно, откуда взялся SQL-текст: из консоли администратора или из тела POST-запроса без авторизации. Разницы для базы нет никакой.
Второй вопрос — откуда вообще посторонний узнает адрес. Ответ неприятный: путь /webhook/mk-db-exec лежал открытым текстом в 54 файлах на двух машинах — в скриптах-потребителях, в бэкапах, местами в приватных репозиториях. Не потому что кто-то умышленно раскрыл секрет, а потому что путь никогда секретом и не был: обычный человекочитаемый URL для внутреннего сервиса, скопированный между скриптами через полтора месяца работы.
Первый слой правки — и почему его одного не хватило
Первым делом заменил путь ноды Webhook на случайный (24 hex-символа) и обновил все 54 места, где он был захардкожен, плюс отдельный скрипт на локальной машине — итого 55. Старый путь после этого отвечает 404:
curl -s -o /dev/null -w '%{http_code}\n' https://<хост>/webhook/mk-db-exec # 404
Здесь я остановился на день, и это было ошибкой в рассуждении, а не в коде. Секретный путь решает задачу «случайный интернет не найдёт эндпоинт перебором», но не решает задачу «путь уже утёк». А он уже утёк: тот же самый читаемый mk-db-exec несколько недель лежал в истории коммитов приватных репозиториев и в старых бэкапах, которые я не стал вычищать (переписывать git-историю ради одного случайно раскрытого внутреннего URL — отдельная и небесплатная операция). То есть у любого, кто когда-либо имел доступ к этой истории, ключ к суперюзеру базы оставался бы рабочим и после ротации пути, потому что права подключения я не трогал.
Настоящая причина не в предсказуемости адреса, а в том, что у эндпоинта не было модели авторизации вообще — ни Basic Auth, ни Header Auth, ни JWT. У ноды Webhook в n8n все три варианта есть из коробки (в разделе Authentication) — просто ни один не был включён. И даже включи я Header Auth в тот вечер, это не решило бы вторую часть проблемы: 55 файлов-потребителей всё равно исполняли запросы через кред, который физически может исполнить DROP DATABASE.
Второй слой — обрезать права, а не только вход
Завёл новую роль mk_webhook с правами ровно по тому, что реально трогают воркеры:
GRANT ALL ON SCHEMA mk_max, marketing_bot_v2 TO mk_webhook; GRANT ALL ON ALL TABLES IN SCHEMA mk_max, marketing_bot_v2 TO mk_webhook; GRANT SELECT, INSERT, UPDATE ON app_content_items, app_content_versions, app_publications, app_platforms, app_profiles, app_strategies, app_topic_pool, app_processes, app_process_runs TO mk_webhook; GRANT SELECT ON app_users TO mk_webhook;
Переключил n8n-креденшел ноды Postgres с суперюзера на эту роль и прогнал те же проверки, но теперь уже как атакующий, у которого случайно оказался бы актуальный секретный путь:
Проверка | До (роль | После (роль |
|---|---|---|
|
|
|
| отдаёт хеши паролей всех ролей |
|
| выполнился бы |
|
| 167 | 167 |
| 101 | 101 |
Путь | HTTP 200 | HTTP 404 |
Рабочие запросы воркеров не изменились ни на строку — это и была цель: урезать всё, чем воркеры не пользуются, не трогая то, чем пользуются.
Что оказалось дороже кода
Технически смена креда в ноде — это один клик в n8n. Дорогим оказался не он, а миграция 55 потребителей на новый путь: 47 мест на requests.post, 9 на urllib, остальные — curl из bash-скриптов и один вызов из Node.js. Часть из них дублировала путь строкой, часть — через переменную окружения, которая сама была объявлена в трёх разных .env-файлах на двух хостах с разным написанием (где-то MK_DB_EXEC_URL, где-то MK_DB_EXEC_ENDPOINT). Свести это к одному источнику правды за вечер было нереально, поэтому сознательно разбил работу на два отдельных коммита с разницей в несколько часов: сначала путь и все потребители, отдельной проверкой — что нигде не осталось старого адреса; затем, только после подтверждённого нуля упоминаний старого пути, смена роли на ограниченную. Одним диффом делать обе вещи сразу означало бы: если роль обрежет права раньше, чем последний потребитель переедет на новый путь, часть воркеров начнёт молча ловить permission denied на легитимных запросах, и разбирать, что из этого атака, а что моя же миграция, пришлось бы по продовым логам в реальном времени.
Что осталось техдолгом
Секретный путь — это защита через неизвестность (security by obscurity), а не полноценная авторизация: он держится, пока никто не увидел его в логе прокси, в истории браузера общего сотрудника или в очередном случайно закоммиченном файле. Правильный следующий шаг — Header Auth поверх уже урезанных прав, чтобы утечка одного только пути не значила вообще ничего без второго секрета в заголовке. Не сделал это в тот же вечер по той же причине, что и с правами: это снова правка всех 55 мест-потребителей (добавить заголовок в каждый вызов), а совмещать миграцию путей, миграцию прав и добавление заголовка одним рывком — верный способ не суметь откатить ни один слой отдельно, если что-то один из них сломает.
Старый кred с правами суперюзера в базе я не отозвал — им продолжают пользоваться другие воркфлоу, не связанные с этим вебхуком, и его ревизия — отдельная, более медленная задача, которую нельзя закрывать в спешке ради одного найденного эндпоинта.
Главный вывод для себя скучный: «внутренний» в названии сервиса — это не свойство сети, а привычка думать, что раз путь никому не давали, значит его никто не найдёт. Проверять стоило не то, знает ли кто-то путь, а то, что произойдёт, если узнает — и делать это руками, curl, а не полагаться на память о том, что там «вроде бы» стоит проверка.

