Что именно нашлось на служебном маршруте
Один curl. Вот и весь эксплойт: никаких кодировок, обходов, слепых инъекций — просто POST на /webhook/mk-db-exec с заголовком Content-Type: application/json и SQL в теле. Автор статьи сам так себя и проверил: отправил {"sql":"SELECT current_user, current_database()"} — и получил в ответ {"current_user":"postgres","current_database":"postgres"}. Соединение живое, права полные, авторизации нет.
Реализовано это как n8n-воркфлоу: нода Webhook принимает JSON с произвольным SQL-текстом, нода Postgres его исполняет и отдаёт результат. Идея была безобидная — не тащить написанные креды и драйвер в каждый фоновый скрипт маркетингового бота. Один HTTP-запрос в уже настроенное подключение, и все счастливы. Подключение при этом заведено на кред с правами postgres — суперюзера. Просто потому что «так быстрее на старте», а потом руки не дошли.
Суперюзер в PostgreSQL — это не «ещё один пользователь». Это SELECT, INSERT, UPDATE и DELETE в любой схеме основной базы, включая клиентские данные и таблицы публикаций, причём от имени живого аккаунта. Через тот же вебхук проходит и COPY ... TO PROGRAM — команда передаёт вывод запроса внешней программе ОС. Документированная функциональность, доступная суперюзеру, так что спор о том, считать ли это уязвимостью движка, к делу не относится: для базы нет разницы, откуда пришёл SQL — из админской консоли или из тела ничем не защищённого POST.
Почему подключение суперпользователя превращает вебхук в DROP DATABASE
Считаете ущерб от такой дыры в таблицах — и уже ошиблись в масштабе. Потому что роль postgres в PostgreSQL — это не «доступ к одной базе». Это доступ ко всем схемам платформы сразу: SELECT, INSERT, UPDATE, DELETE в любой из них. И клиентские данные, и таблицы публикаций, которые база отдаёт от имени живого аккаунта. Один и тот же SQL-текст проходит по всей платформе, а не по тому уголку, ради которого воркфлоу и заводили.
Дальше — интереснее. В PostgreSQL у суперюзера есть команда COPY ... TO PROGRAM: результат запроса уходит во внешнюю программу операционной системы. Это документированное поведение движка, доступное суперюзеру и роли pg_execute_server_program по умолчанию. Споры о том, баг это или фича, ведутся давно — EnterpriseDB, например, называет CVE-2019-9193 «не багом», раз для эксплуатации и так нужны эти права. Но базе безразлично, откуда пришёл SQL-текст: из консоли администратора или из тела POST-запроса без авторизации. Разницы для неё нет никакой.
Вот где инцидент окончательно перестаёт помещаться в рамки «утечка из одной базы». Через COPY ... TO PROGRAM запрос упирается уже не в таблицы, а в операционную систему хоста. То есть дыра в вебхуке — это не про чтение чужих строк. Это потенциальный выход за пределы PostgreSQL вообще.
Ротация секретного пути как мера, которая ничего не закрывает
24 hex-символа. Хорошая энтропия — брутфорсом такое не подберёшь. И всё же это не авторизация.
Сменили путь ноды Webhook на случайный — и старый адрес тут же начал отдавать 404. Красиво. А потом приходит отрезвляющая мысль: а куда девался прежний, читаемый mk-db-exec? Он лежал открытым текстом в 54 файлах на двух машинах — в скриптах-потребителях, в бэкапах, кое-где в приватных репозиториях. Не потому что кто-то злонамеренно слил секрет, а потому что секретом он никогда и не был — обычный URL для внутреннего сервиса, размноженный копипастой за полтора месяца.
Вот в чём ловушка. Секретный путь закрывает одну задачу — «случайный сканер в интернете не угадает эндпоинт». И совсем не закрывает другую — «путь уже утёк». А он утёк: тот же читаемый адрес неделями пылился в истории коммитов и в старых бэкапах. Переписывать git-историю ради одного URL — затея небесплатная, и автор её отложил. Итог: у любого, кто хоть раз видел ту историю, ключ к суперюзеру базы остался бы рабочим даже после ротации пути. Права подключения-то никто не трогал.
То есть корень проблемы не в предсказуемости адреса, а в том, что у эндпоинта не было модели авторизации вообще: ни Basic Auth, ни Header Auth, ни JWT. В n8n всё это есть из коробки в разделе Authentication ноды Webhook — просто ни один вариант не был включён.
Сменили строку в URL — получили ощущение, что починили. Но в базе по-прежнему торчал кред с правами postgres. Зайдите в свой мониторинг и честно спросите: сколько ваших «внутренних» сервисов держится ровно на этом — на том, что адрес «вроде никто не давал»?
Реальная причина: у эндпоинта не было модели авторизации
curl с ноутбука. Ни одного заголовка авторизации — ни Basic, ни Bearer, ни самодельного токена. Ответ приходит 200, а в теле — не что-нибудь, а {"current_user":"postgres","current_database":"postgres"}. Это и есть тот самый момент, когда перестаёшь верить, что слово «внутренний» что-то гарантирует.
Речь про n8n-воркфлоу: нода Webhook принимает JSON с произвольным SQL-текстом, отдаёт его в ноду Postgres и возвращает результат. Служебный SQL-раннер для пары фоновых воркеров маркетингового бота — чтобы не тянуть драйвер и креды в каждый скрипт. Подключение было заведено под ролью postgres, то есть под суперюзером: на старте так было проще, и никто не вернулся это поправить.
Почему суперюзер — это не «читать чужие таблицы»? Потому что это SELECT/INSERT/UPDATE/DELETE в любой схеме основной базы: клиентские данные, таблицы публикаций от имени живого аккаунта. И отдельная неприятность — COPY ... TO PROGRAM, команда, пропускающая результат запроса через внешнюю программу ОС. Для PostgreSQL это документированное поведение, доступное суперюзеру и роли pg_execute_server_program по умолчанию; в сообществе до сих пор спорят, считать ли это уязвимостью движка (EnterpriseDB называет CVE-2019-9193 «не багом», раз эксплуатировать её можно только с этими правами). Но базе всё равно, откуда пришёл SQL-текст — из консоли администратора или из тела POST без авторизации. Разницы для неё нет никакой.
Отсюда неприятный вывод, который я сначала проскочил. Подкрутив ротацию пути, легко решить, что проблема закрыта. Но корень — не в предсказуемости адреса, а в том, что у эндпоинта не было модели авторизации вообще. В ноде Webhook в n8n три готовых варианта — Basic Auth, Header Auth, JWT — лежат прямо в разделе Authentication. Ни один не был включён. Включи я Header Auth в тот вечер — и это закрыло бы лишь вход. Вторая половина проблемы осталась бы: все потребители всё равно били через кред, который физически способен исполнить DROP DATABASE.
Разбор и правки автор описал в статье на Хабре: curl с ноутбука и что из этого вышло. Суть простая: предсказуемый URL — это симптом, а не болезнь. Аутентификация на входе и урезанные права на креденшеле — две независимые вещи, и лечить одну, забывая про вторую, означает оставить дверь открытой ровно наполовину.
Новая роль mk_webhook: минимальные права вместо суперпользователя
Сколько таблиц из вашей базы воркер действительно трогает? У автора разбора ответ нашёлся быстро: две схемы плюс девять таблиц app_*, и ни одна из них не называлась pg_authid. Значит, роль суперюзера даёт воркеру куда больше, чем он просит, и именно эту разницу надо было закрывать.
Новая роль mk_webhook получила права ровно по рабочему списку. GRANT ALL ON SCHEMA mk_max, marketing_bot_v2, затем GRANT ALL ON ALL TABLES в этих схемах. Отдельно — SELECT, INSERT, UPDATE на девять таблиц app_content_*, app_platforms, app_profiles, app_strategies, app_topic_pool, app_processes, app_process_runs и только SELECT на app_users. Пароли в списке не фигурируют, потому что читать их незачем.
Затем n8n-креденшел ноды Postgres переключили с суперюзера на эту роль — и прогнали проверки руками.
Смотрим контраст. SELECT current_user теперь возвращает mk_webhook вместо postgres. Запрос к pg_authid — таблице, где лежат хеши паролей всех ролей, — падает с permission denied for table pg_authid. COPY (SELECT 1) TO PROGRAM 'id', который раньше прошёл бы, отдаёт permission denied to COPY to or from an external program. Эксплуатация через произвольный SQL перестаёт давать что-либо осмысленное.
И главное — рабочие запросы воркеров не изменились ни на строку. SELECT count(*) FROM mk_max.content_queue как возвращал 167, так и возвращает. SELECT count(*) FROM marketing_bot_v2.dm_threads — те же 101.
Именно в этом и была цель: урезать всё, чем воркеры не пользуются, не тронув то, чем пользуются. Смена креда в ноде — один клик. Сложность — в проверке, что после этого клика не отвалилось ничего живого.
Миграция 55 потребителей: цена правки на живую
Один клик в n8n — и креденшел ноды Postgres смотрит на новую роль. Дальше начинается скучное. По всему коду раскидано 55 мест, которые дёргают старый эндпоинт: 47 — через requests.post, девять — через urllib, остальные скромно притаились в bash-скриптах и в одном вызове из Node.js.
И это ещё не худшая новость. Часть скриптов держит путь строкой прямо в теле, а часть — через переменную окружения. Вот тут и вылезает главный подвох: переменная объявлена аж в трёх разных .env-файлах на двух хостах, и в каждом она названа по-своему. Где-то MK_DB_EXEC_URL, где-то MK_DB_EXEC_ENDPOINT. Попробуйте вспомнить навскидку, какой из вариантов ждёт конкретный воркер, — я не смог.
Свести всё к одному источнику правды за вечер не выйдет. Так что миграцию пришлось резать на два коммита с паузой в несколько часов. Сначала — новый путь и все 55 потребителей, плюс отдельная проверка, что старого адреса не осталось нигде. И только когда упоминаний старого пути ровно ноль — переключение роли на ограниченную.
Почему не одним диффом? Потому что слои ломаются по-разному и откатываются по-разному. Если ограниченная роль приедет раньше, чем последний воркер переедет на новый путь, часть легитимных запросов начнёт молча получать permission denied. И тогда разбираться придётся по продовым логам в реальном времени: где тут атака, а где — собственная миграция. Удовольствие ниже среднего.
Кстати, саму переменную окружения стоило бы свести к одному имени, но это уже техдолг следующего спринта.
Старые креды суперпользователя, которые никто не отозвал
Суперюзерский кред я не отозвал. И это не забывчивость, а осознанный размен — им пользуются другие воркфлоу, к вебхуку отношения не имеющие. То есть старый ключ всё ещё живёт, просто больше не открывает ту дверь, о которой мы говорили.
Чем это опасно? Давайте честно: любой, кто засветил его где-то в своём коде, получил доступ к базе ничуть не хуже того атакующего, что стучался через вебхук. Мы закрыли вход, но не отозвали пропуск. Кто уже вошёл в систему с этим пропуском — вопрос открытый.
Ревизия креда — задача другого масштаба, чем ротация пути. Тут мало просто поменять пароль в одном месте. Нужно обойти все воркфлоу, которые держат это подключение, и для каждого понять: точно ли ему нужен суперюзер, или его тоже можно урезать до конкретных прав. А это уже не «поправить путь в 55 файлах», а полноценное расследование по каждому потребителю. Медленное, ручное, с оглядкой на прод.
Я отложил её сознательно. Спешка здесь — прямой путь к тому, что один воркфлоу, который тихо полагался на широкие права, упадёт где-нибудь посреди ночи. Сначала пути, потом права, а ревизия старого креда — следующим шагом, когда будет время и когда будет ясно, кто вообще им дышит. Секретный путь — это ещё и защита через неизвестность: он держится, пока ссылку не увидели в логе прокси или в чужом коммите. Настоящий следующий слой — Header Auth поверх урезанных прав, но это снова правка всех 55 потребителей, и за один вечер три миграции не совмещают.
Два вывода и один практический совет
Первый и самый неприятный: секретный URL — это не авторизация. Ротация пути на 24 hex-символа убрала эндпоинт из выдачи для случайного сканера, но любой, кто хоть раз держал в руках репозиторий или бэкап со старым /webhook/mk-db-exec, всё ещё мог бы стучаться в базу после ротации. Защита через неизвестность работает ровно до первого лога прокси или случайного коммита.
Второй вывод про приоритеты. Реальный источник риска был не в предсказуемости имени, а в модели прав: эндпоинт без единого заголовка авторизации исполнял произвольный SQL под суперюзером. Пока в ноде Postgres стоит кред с правами postgres, никакой пароль-в-заголовке не спасёт от COPY ... TO PROGRAM. Ограничение прав роли (в нашем случае — до mk_webhook с правами только на те схемы и таблицы, которые реально трогают воркеры) даёт куда больший эффект на единицу усилий, чем любая обфускация маршрута.
Третий, менее очевидный. Миграцию нельзя валить одним диффом: если роль обрежет права раньше, чем последний из 55 потребителей переедет на новый путь, вы будете по живым логам разгребать, где permission denied от ваших же правок, а где — атака. Разделять слои (сначала путь и потребители, потом права, потом заголовок) — это не про аккуратность, а про способность откатить каждый слой отдельно.
Практический совет. Не полагайтесь на память о том, что «там вроде стоит проверка». Возьмите curl и постучитесь в служебные маршруты руками — как это было сделано 30 августа:
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 без заголовка авторизации — вы только что узнали о проблеме раньше постороннего. Источник, откуда взят разбор: статья на Хабре.