Как доказать, что домен твой: знакомство с 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. Схема с двумя секретами:
- Сайт генерирует два случайных значения — назовём их secret_1 и secret_2 — и отправляет оба на сервер вместе с данными регистрации.
- Сервер не отвечает сразу. Вместо этого он делает обратный запрос на сайт и передаёт туда только secret_1.
- Сайт проверяет, что secret_1 совпадает с сохранённым, удаляет оба секрета и отвечает вторым — secret_2.
- Сервер сверяет 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 — из тех тем, о существовании которых не подозреваешь, пока не начнёшь писать свою систему регистрации. А когда начинаешь — обнаруживаешь, что задачу уже решали много раз, и все грабли давно разложены и подписаны.
Главное, что я вынес: проверять нужно не то, что клиент утверждает, а то, что он может продемонстрировать. Любая строка в запросе — это заявление, а не доказательство. Доказательство появляется только тогда, когда вы просите совершить действие, доступное исключительно владельцу.
