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 с суперюзера на эту роль и прогнал те же проверки, но теперь уже как атакующий, у которого случайно оказался бы актуальный секретный путь:

Проверка

До (роль postgres)

После (роль mk_webhook)

SELECT current_user

postgres

mk_webhook

SELECT count(*) FROM pg_authid

отдаёт хеши паролей всех ролей

permission denied for table pg_authid

COPY (SELECT 1) TO PROGRAM 'id'

выполнился бы

permission denied to COPY to or from an external program

SELECT count(*) FROM mk_max.content_queue (рабочий запрос воркера)

167

167

SELECT count(*) FROM marketing_bot_v2.dm_threads (рабочий запрос воркера)

101

101

Путь /webhook/mk-db-exec (старый)

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, а не полагаться на память о том, что там «вроде бы» стоит проверка.