Add new comment

Как доказать, что домен твой: знакомство с Domain Control Validation

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

Всё выглядело просто ровно до того момента, пока я не начал задавать себе неудобные вопросы.

Как проверить, что запрос пришёл именно с того домена, который в нём указан? Ведь домен — это просто строка в теле POST-запроса. Я могу написать туда что угодно. Хоть google.com.

Что помешает злоумышленнику зарегистрировать чужой домен? Он отправляет запрос с доменом конкурента, получает валидные ключи — и дальше либо работает от его имени, либо просто занимает место, и настоящий владелец при установке модуля упирается в «этот домен уже зарегистрирован».

Что делать, если пользователь потерял токен? Обновил CMS, слетели настройки, перенёс сайт, восстановил старый бэкап. Регистрации на сервере уже есть, а локально ключей нет. Повторно зарегистрироваться нельзя, а отдать существующий секрет — значит открыть дыру: кто угодно назовёт чужой домен и заберёт чужие ключи.

А если секрет скомпрометирован? Нужен способ его отозвать и выпустить новый — и этот способ по определению должен быть доступен пользователю, а значит, доступен и атакующему.

Все четыре вопроса упираются в одну и ту же нерешённую задачу: у меня нет способа связать заявленный домен с тем, кто на самом деле делает запрос. И, покопавшись, я выяснил, что у этой задачи есть устоявшееся название — Domain Control Validation (DCV), или проверка контроля над доменом.

Суть подхода

Идея DCV сводится к одной фразе: тот, кто контролирует домен, может сделать на нём что-то, чего не может посторонний.

Дальше — вопрос выбора этого «чего-то». Классических вариантов несколько:

HTTP-проверка. Сервер просит разместить файл с уникальным значением по заранее известному пути и затем сам его запрашивает. Так работает ACME-челлендж http-01, через который Let's Encrypt выдаёт сертификаты, и так же, по сути, устроена верификация сайта в Google Search Console.

DNS-проверка. В зону домена добавляется TXT-запись с токеном. Медленнее из-за распространения DNS, зато не требует работающего веб-сервера и годится, когда сайт ещё не запущен.

Email-проверка. Письмо на административный адрес домена. Исторически так делали удостоверяющие центры, сейчас метод считается самым слабым — он проверяет контроль над почтой, а не над доменом.

Callback (loopback). Самый интересный вариант для тех, кто пишет модули и плагины. Если ваше приложение уже установлено на сайте — сервер может просто постучаться назад на этот сайт и поговорить с ним. Пользователю при этом делать вообще ничего не нужно, что для установки модуля критично: любое ручное действие — это отвалившиеся клиенты и письма в поддержку.

Одного nonce мало

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

Логика вроде рабочая: атакующий может заявить чужой домен, но не может заставить чужой сайт вернуть его случайное значение.

Но здесь легко ошибиться в реализации — и ошибка убивает всю схему. Если endpoint на стороне модуля просто возвращает то, что ему передали в параметре запроса, проверка превращается в фикцию: сервер сам присылает жертве значение атакующего, жертва послушно его отражает, проверка проходит. Возвращать нужно исключительно то, что сохранено локально, и никогда — то, что пришло в запросе.

Куда изящнее задачу решили в Jetpack — плагине, который связывает self-hosted WordPress с WordPress.com. Схема с двумя секретами:

  1. Сайт генерирует два случайных значения — назовём их secret_1 и secret_2 — и отправляет оба на сервер вместе с данными регистрации.
  2. Сервер не отвечает сразу. Вместо этого он делает обратный запрос на сайт и передаёт туда только secret_1.
  3. Сайт проверяет, что secret_1 совпадает с сохранённым, удаляет оба секрета и отвечает вторым — secret_2.
  4. Сервер сверяет secret_2 и только теперь отвечает на исходный запрос, выдавая идентификатор и токен.

Красота в разделении ролей. secret_1 доказывает сайту, что стучится настоящий сервер, получивший регистрацию. secret_2 доказывает серверу, что на другом конце именно тот сайт, который инициировал запрос — потому что secret_2 по обратному каналу никогда не передавался, и отразить его невозможно. Обе стороны аутентифицируют друг друга за одно рукопожатие.

Секреты живут с коротким TTL (в Jetpack — 10 минут) и удаляются сразу после использования.

Восстановление: не отдавать, а перевыпускать

Это оказался главный сдвиг в моей голове.

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

Правильная формулировка другая: секрет не отдаётся повторно никогда. Он только перевыпускается. Потерял ключ — проходи то же самое рукопожатие заново, старый токен отзывается, выдаётся новый. Эндпоинта «верни мне мой секрет» просто не существует, и атаковать нечего.

Чтобы это работало без потери пользовательских данных, пришлось поменять модель: разделить долгоживущую сущность клиента (настройки, история, тарифный план) и установку (пара ключей, версия модуля, статус). Установка — расходник: отозвал, выпустил новую. Настройки живут уровнем выше и переживают любое количество переустановок. Кстати, Shopify и Slack устроены так же: магазин или воркспейс живут долго, access token привязан к конкретной установке и ротируется.

Чего я не ожидал

Несколько вещей всплыли уже по ходу и оказались не менее важными, чем сама криптография.

Callback — это SSRF-примитив. Вы буквально заставляете свой сервер ходить по URL, который ему прислал неизвестно кто. Приватные диапазоны, localhost, 169.254.169.254 (метаданные облака) должны быть заблокированы, редиректы — ограничены, таймаут и размер ответа — обрезаны.

Rate limit обязателен. Иначе механизм перевыпуска становится инструментом DoS: спамим запросами на чужой домен и мешаем работать. Показательный случай — в одном из популярных решений для лицензирования на WooCommerce сброс активаций не требовал вообще никакого секрета от софта, и его можно было дёргать прямо из браузера.

Нормализация домена. www и без, http и https, порт, точка в конце, punycode, регистр. Без канонизации example.com и www.example.com — два разных клиента, и вы получите поток обращений в поддержку.

Callback падает чаще, чем кажется. Cloudflare с включённым челленджем, basic auth на стейджинге, закрытый по соображениям безопасности endpoint, свежий домен с ещё не разошедшимся DNS. Автоматическая проверка закроет большинство случаев, но не все — поэтому нужна лестница: сначала callback, затем письмо на подтверждённую почту, в самом конце ручное подтверждение администратором.

Идемпотентность. Сценарий, о котором я вообще не думал: сервер зарегистрировал клиента, ответ не дошёл из-за таймаута. На сервере регистрация есть, у пользователя ключей нет. Повторный запрос упирается в «уже зарегистрирован». Лечится идентификатором установки, который генерирует клиент, а не сервер — тогда повтор запроса просто распознаётся как повтор.

Итог

DCV — из тех тем, о существовании которых не подозреваешь, пока не начнёшь писать свою систему регистрации. А когда начинаешь — обнаруживаешь, что задачу уже решали много раз, и все грабли давно разложены и подписаны.

Главное, что я вынес: проверять нужно не то, что клиент утверждает, а то, что он может продемонстрировать. Любая строка в запросе — это заявление, а не доказательство. Доказательство появляется только тогда, когда вы просите совершить действие, доступное исключительно владельцу.

Tags: 
CAPTCHA
Spam protection
Target Image