Вебхуки не должны доверять адресу, который им дали

Product

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

На этой неделе в Kookee появились вебхуки, а поверх них — интеграции со Slack, Discord, Telegram, GitHub, Jira, Asana и Freshdesk. Вы публикуете пост, и статический сайт пересобирается; посетитель оставляет отзыв, и он попадает в Slack-канал вашей команды или становится задачей в Jira.

Страница «Интеграции»: GitHub Issues, Slack и Jira подключены, Telegram, GitHub Actions и Discord готовы к настройке

Форма с полем URL заняла полдня. Всё, что описано ниже, заняло остаток недели, и кое-что из этого я поймал, только когда проверял план, — ещё до того, как появилась первая строчка кода.

Восемь мест, где отправителю вебхуков нельзя ошибиться

1. Проверяйте адрес при отправке, а не при сохранении

Очевидная проверка — отклонять http://localhost и 10.0.0.5, когда кто-то сохраняет эндпоинт. И она же бесполезная. URL проверяется один раз, а имя хоста в нём заново разрешается через DNS при каждой доставке, и ничто не мешает hooks.example.com в понедельник указывать на публичный адрес, а во вторник — на 169.254.169.254, сервис метаданных облака.

Поэтому настоящая проверка происходит в момент отправки. Kookee разрешает имя хоста и отказывается от доставки, если хоть один полученный адрес частный, loopback, link-local или зарезервированный (включая IPv4, записанный внутри IPv6), а затем подключается ровно к тому адресу, который проверил, чтобы повторный DNS-запрос не мог его подменить. По редиректам Kookee не переходит: 302 на внутренний адрес — это просто ответ.

Один диапазон легко забыть — публичные адреса ваших собственных серверов. Если вы за CDN, IP вашего origin-сервера — то, до чего атакующему нужнее всего добраться, а вебхук охотно вызовет его изнутри, в обход файрвола. Адреса серверов Kookee тоже в списке блокировки.

2. Подписывайте так же, как все

Я мог бы придумать собственный заголовок с подписью. Вместо этого Kookee подписывает запросы по Standard Webhooks: webhook-id, webhook-timestamp и HMAC в webhook-signature. Разработчик на стороне получателя берёт готовую библиотеку на своём языке, без лишней работы получает защиту от replay-атак с пятиминутным окном и не учит ещё один формат.

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

3. Медленный получатель должен тормозить только себя

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

У каждого запроса в Kookee жёсткий лимит в 10 секунд, включая DNS-запрос, и в одном процессе идут 25 доставок одновременно. Одного этого мало: медленный эндпоинт, которому весь день повторяют доставки, всё равно занимает эти слоты. Поэтому у каждого эндпоинта есть 2,5 минуты ожидания в час, а у каждого аккаунта — 7,5 минуты на все его эндпоинты. Доставка сверх этого бюджета пропускается, и эндпоинт прямо об этом пишет. Пропуск никогда не считается ошибкой: получатель медленный, а не сломанный, и отключать его значило бы наказывать не за то.

События контента и события посетителей считаются отдельно. Поток спама в обратной связи не должен стать причиной, по которой ваш сайт не пересобрался после публикации.

4. Повторяйте часами, потом остановитесь и сообщите об этом

Серверы отключаются на время деплоя и возвращаются. Неудачная доставка повторяется десять раз примерно за восемь с половиной часов, с паузами в 1, 2, 4 и так до 256 минут, и каждый раз с тем же webhook-id, чтобы получатель мог отбросить дубли.

Чего я не хотел, так это эндпоинта, который молча падает с марта. Ответ 410 Gone сразу отключает эндпоинт. Пять событий подряд, у которых не прошла ни одна попытка, тоже его отключают. В обоих случаях владелец проекта получает уведомление и письмо: какой эндпоинт и почему, а включить его обратно — один клик.

5. Отправляйте событие, когда меняется страница, а не строка в базе

Событие «запись обновлена» выглядит простым, но посмотрите, что его вызывает: автосохранение, исправленная опечатка в черновике, привязанный перевод. Наивный хук пересобирает статический сайт сорок раз за полдня.

События контента в Kookee следуют за тем, что видит посетитель. entry.published срабатывает, когда запись становится видимой, в том числе когда запланированный пост выходит в 9:00, а за клавиатурой никого нет: каждую будущую дату ждёт своя задача. entry.updated срабатывает только для записи, которая была видна и осталась видна. Черновик, сохранённый десять раз, не отправляет ничего.

6. У каждого сервиса свой способ привлечь внимание

Интеграции отправляют в инструменты вашей команды текст, который написал посторонний человек, а у каждого инструмента своя разметка, чтобы привлечь внимание. Отзыв с заголовком <!channel> urgent уведомил бы весь канал в Slack. В Discord тот же трюк — @everyone, а ещё замаскированная ссылка, которая скрывает, куда ведёт. В GitHub #123 или owner/repo#1 в тексте issue оставляют перекрёстную ссылку в чужом issue — от имени вашего аккаунта.

Поэтому у каждого сервиса своё экранирование. В Slack спецсимволы экранируются, а разворачивание ссылок выключено. В Discord экранируется каждый символ Markdown, упоминания выключены, а превью ссылок скрыты. Текст для Telegram экранируется как HTML. В issue на GitHub текст посетителя стоит в блоке кода, обрамлённом более длинной серией обратных кавычек, чем любая серия внутри него. Freshdesk получает текст с экранированным HTML, потому что тело тикета там — HTML. А Jira отклоняет описание с пустым абзацем, поэтому пустые строки удаляются ещё до отправки.

Когда всё работает, ничего из этого не видно. Но именно этим инструмент для обратной связи отличается от способа, которым кто угодно может написать всей вашей компании.

7. Учётные данные не лежат в очереди

URL вебхука Slack — это пароль: любой, у кого он есть, может писать в ваш канал. То же с токеном бота Telegram, который стоит прямо в URL API. Соблазнительно положить расшифрованную конфигурацию в задачу доставки, чтобы у воркера было всё нужное. Тогда каждый токен лежит в Redis открытым текстом, в каждой повторной попытке, часами.

В задачах Kookee лежат id интеграции, id события и само событие. Воркер загружает строку из базы, расшифровывает конфигурацию в последний момент и никогда не пишет URL запроса в лог. Токенов нет и в журнале аудита: он записывает, кто что изменил, но не секрет, который человек ввёл.

8. Прочитайте правила другой стороны, прежде чем писать свои

В моём первом плане 403 Forbidden означал «эта интеграция не заработает никогда», и интеграция отключалась. HTTP-клиент Node не отправляет заголовок User-Agent, если его не задать, а GitHub отвечает на запрос без него кодом 403. Каждая интеграция с GitHub отключилась бы на первой же доставке.

Это поймала проверка плана, и теперь каждый запрос уходит с content-type и User-Agent. Урок оказался шире одного заголовка: на превышение лимита запросов GitHub тоже отвечает 403, и тогда доставка повторяется. А об удалённом чате Telegram сообщает ответом 400 с текстом ошибки, и тогда интеграция отключается. У каждого сервиса есть короткий список исключений из простого правила, и он записан рядом с кодом, который отправляет в этот сервис.

Если вы делаете вебхуки

  • Разрешайте имя хоста в момент отправки, отклоняйте частные и зарезервированные адреса, подключайтесь к проверенному адресу и не переходите по редиректам.

  • Внесите публичные адреса своих серверов в список блокировки.

  • Подписывайте по Standard Webhooks, а не в собственном формате.

  • Дайте каждой доставке жёсткий лимит времени, а каждому эндпоинту и аккаунту — бюджет ожидания, чтобы один медленный получатель не задерживал всех.

  • Повторяйте часами со стабильным id сообщения, потом отключайте эндпоинт и сообщайте владельцу.

  • Отправляйте события контента, когда меняется то, что видят посетители, а не на каждое сохранение.

  • Экранируйте текст под разметку каждого сервиса, прежде чем его отправить.

  • Не держите учётные данные в очереди, логах и журнале аудита.

Вебхуки есть на всех тарифах Kookee, а интеграции — на Pro и Ultra. О событиях, содержимом запроса и проверке подписи — в статье Отправляйте события на свой сайт через вебхуки.

Читайте также