Зачем подпись, если канал уже закрыт
Вебхук — это HTTP-запрос, который отправляет не клиент, а сервер. В обычном API клиент стучится к серверу, и сертификат предъявляет сервер: он доказывает клиенту, что тот попал на нужный домен, а о самом клиенте канал не сообщает ничего. В вебхуке меняются не правила TLS, а роли: стучится к вам сервер отправителя, а сервером в канале оказывается ваш сервис — значит, сертификат предъявляете вы, и он подтверждает отправителю, что тот попал на нужный домен. Этим закрытый канал и исчерпывается — шифрование и подтверждение домена приёмника; сам этот слой, от сертификата до проверки, разобран отдельно, в материале HTTPS с нуля: сертификат, перенаправление, проверка. Но односторонний TLS о личности отправителя не сообщает ничего — она появляется на уровне канала только при взаимной проверке сертификатов, которой в обычной интеграции с чужим отправителем нет. Доказательство того, кто вызвал, переезжает в содержимое запроса: отправитель кладёт рядом с телом подпись, а вы пересчитываете её своим секретом и сравниваете с присланной.
Подпись вебхука — это не подпись тела как такового. И это не та же задача, что проверка токена, который вы выдали сами: там вы проверяете собственный выпуск собственным ключом, здесь — чужое событие ключом, согласованным с чужой стороной; устройство такого токена разобрано отдельно, в материале JWT: что это на самом деле и где он не подходит. Что именно попадает под подпись, разбираем ниже.
Тупик: секретный адрес вместо подписи
Первое, что пробуют вместо подписи, — секретный адрес приёмника. Идея простая: вместо того чтобы проверять подпись, делают путь к обработчику длинным и непредсказуемым — например, /webhook/3a7b9c2d-e4f5-6789-0123-456789abcdef. Логика такая: если злоумышленник не знает этот путь, он не сможет отправить запрос.
Но секретный адрес не решает ни одну из проблем, которые должна решать подпись. Вот что происходит на самом деле:
-
Адрес оседает в журналах. Каждый запрос проходит через прокси, балансировщик, инструменты мониторинга. Путь целиком логируется, и если хотя бы один из этих компонентов скомпрометирован, секрет перестаёт быть секретом.
-
Адрес не защищает от повтора. Если злоумышленник перехватил запрос — например, из журналов прокси — он может отправить его повторно, и ваш сервис примет его как настоящий. Подпись защищает от повтора, потому что включает отметку времени, а секретный адрес — нет.
-
Адрес не защищает от подмены тела. Если злоумышленник знает адрес, он может отправить запрос с любым телом, и ваш сервис примет его как настоящий. Подпись защищает от подмены, потому что сырые байты тела входят в подписываемую строку, а секретный адрес их не покрывает.
Секретный адрес — это иллюзия безопасности. Он не заменяет подпись, а лишь создаёт ложное чувство защищённости.
Рядом с секретным адресом ставят вторую подпорку — список разрешённых адресов отправителя. Список адресов проверяет маршрут, а не отправителя. Он ломается при смене инфраструктуры у партнёра: меняется адрес или домен — список приходится обновлять. И он же подделывается, если перед приложением стоит прокси, а адрес клиента приложение берёт из заголовка как есть: заголовок ставит тот, кто отправляет запрос, поэтому в список проходит любой, кто впишет туда разрешённый адрес. Подпись не зависит от адреса отправителя.
Что подписывают на самом деле
Подпись считают по строке, которую собрал отправитель. В этой строке могут быть:
- сырые байты тела запроса,
- идентификатор события,
- отметка времени,
- разделитель между частями.
Порядок частей и разделитель задаёт отправитель. Если отправитель говорит, что подписывает тело и отметку времени через двоеточие, а вы подписываете тело и идентификатор через запятую, подписи не совпадут.
Байты тела берут сырыми, потому что разбор и повторная сериализация меняют строку. Вот где сырые байты теряются чаще всего:
-
Разбирающая прослойка уже вычитала поток. Поток вычитывает не сам фреймворк, а разбирающая прослойка, подключённая до вашего обработчика: она забирает тело целиком и отдаёт готовый объект, поэтому повторное чтение того же потока даёт пусто. Сырые байты остаются доступны только тогда, когда их перехватывают на этапе чтения потока, до разбора.
-
Запрос переписал посредник перед приёмником. Прокси, шлюз или сеть доставки не меняют смысл события, но принимают запрос на себя и передают дальше уже своей передачей: сжатое тело распаковывается, собирается из частей заново, заголовки длины и кодирования передачи переписываются под посредника. Речь не только о внешней сети доставки — так же ведёт себя обратный прокси, стоящий перед приложением. Например, отправитель подписал сжатые байты, а до вашего обработчика тело дошло уже распакованным — подписывали одни байты, считаете по другим.
-
Тело пришло form-encoded. Если запрос пришёл как
application/x-www-form-urlencoded, а подписана сырая форма, а не декодированное поле внутри неё, подпись не сойдётся. Например, если отправитель подписал строкуfield1=value1&field2=value2, а вы разобрали её в объект и собрали обратно, порядок полей может измениться.
HMAC — это не хеш от склейки секрета и данных. Самодельная конструкция вида hash(секрет + данные) уязвима к удлинению сообщения, если хеш-функция под ней идёт по данным блоками и переносит состояние от блока к блоку, — а так устроены распространённые: зная подпись и длину секрета, можно дописать данные в конец и пересчитать валидную подпись, не зная самого секрета. HMAC устроен так, что этого сделать нельзя.
Когда проверка не сходится на каждом запросе
Если проверка написана и не сходится на каждом запросе, проблема обычно в кодировке. Вот типичные ошибки:
-
Секрет не декодирован. Отправитель часто выдаёт секрет в base64 или hex. Если вы используете его как строку символов, а не как байты, подпись не сойдётся. Например, если секрет в base64 —
c2VjcmV0, а вы используете его как строку"c2VjcmV0", а не как байты[0x73, 0x65, 0x63, 0x72, 0x65, 0x74], подпись будет неверной. -
Подпись не приведена к байтам. Подпись может приходить в hex или base64, в верхнем или нижнем регистре, с префиксом схемы через знак равенства перед значением. Перед сравнением её приводят к байтам: убирают префикс схемы и декодируют — из hex или base64, смотря в каком виде она пришла. Регистр при этом — свойство печатной записи, а не байтов: у hex он не значащий, и выравнивать его, если вообще нужно, приходится до декодирования, а в base64 регистр значащий, и трогать его нельзя. К уже полученным байтам регистр неприменим: в некоторых языках у байтовой строки всё же есть операция приведения к нижнему регистру, и она молча портит подпись.
Отдельно — то, что на несовпадение не влияет вовсе. Сравнение с постоянным временем на тех же байтах даёт тот же ответ, что и обычное: несошедшуюся подпись оно не чинит и к кодировке отношения не имеет. Обычное сравнение строк завершается на первом различии, и по сети эта разница тонет в дрожании: подбор по времени ответа потребовал бы многих замеров при стабильном поведении приёмника. Спорить об этом не нужно — раз проверку всё равно переписывают, константное сравнение стоит одну строку кода и снимает вопрос целиком.
При отладке ни подпись, ни секрет, ни тело события целиком в журналы не пишут. Тело — это чужие персональные данные, которые вы приняли, но не запрашивали.
Повтор запроса: откуда берётся валидный запрос
Валидный запрос для повтора берётся не с провода — HTTPS канал закрывает. Реальные источники:
-
Повторная доставка самим отправителем. Отправитель может повторять доставку на любой ответ, который сам не счёл успешным. Например, если ваш сервис вернул ошибку сервера (5xx), отправитель будет повторять запрос по расписанию.
-
Запись в журналах. Тело и заголовки запроса могут быть записаны в журналах прокси, балансировщика или приложения. Если эти журналы скомпрометированы, злоумышленник может повторить запрос.
-
Зеркалирование трафика. Если вы используете зеркалирование трафика для отладки или мониторинга, копия запроса может быть сохранена и использована для повтора.
-
Скомпрометированный посредник. Если посредник — например, прокси или балансировщик — скомпрометирован, злоумышленник может сохранить и повторить запрос.
-
Тестовый приёмник. Если вы используете тестовый приёмник для отладки интеграции, он может получать те же события, что и основной. Если тестовый приёмник скомпрометирован, злоумышленник может повторить запрос.
Отметка времени защищает от повтора только тогда, когда входит в ту же строку, по которой считается подпись. Переданная отдельно от подписываемых данных, она подменяется, и защита исчезает.
Допуск на расхождение часов двусторонний: отметка времени может оказаться и позади, и впереди часов приёмника. Ширину окна выбирают так, чтобы она покрывала максимальное расхождение часов между отправителем и получателем, плюс время на повторную доставку. Чем шире окно, тем больше повторов внутри него проходит.
Идемпотентность по идентификатору события разобрана отдельно — в материале о проектировании API. Здесь важно другое: порядок доставки она не чинит. Если два разных события об одном объекте приходят в обратном порядке, обработка более старого затирает свежее состояние. Лечится это версией или временем изменения объекта внутри события либо обращением к API отправителя за текущим состоянием.
Как устроен обработчик
Порядок работы обработчика:
-
Сырые байты тела и предел размера. Предел выбирают так, чтобы он покрывал максимальный размер события у вашего отправителя. Если тело превышает предел, обработчик сразу возвращает ошибку клиента (4xx), не тратя ресурсы на криптографию.
-
Дешёвая проверка свежести. Отметка времени из заголовка сравнивается с текущим временем. Если расхождение превышает допустимое окно, обработчик возвращает ошибку клиента. Это дешёвая отсечка до криптографии.
-
Подпись. Подпись считается по сырым байтам тела и другим частям строки, заданным отправителем. Если подпись не совпадает, обработчик возвращает ошибку клиента.
-
Разбор JSON. Только после проверки подписи тело разбирается в объект. Разбор — это работа над неподтверждёнными данными, поэтому его делают после криптографии.
Код ответа управляет повторами отправителя:
-
Успешный ответ (2xx) означает «принято, не повторять». Его отдают после сохранения события или постановки в очередь, а не после обработки.
-
Ошибка клиента (4xx) говорит «повторять бессмысленно». Например, если подпись не сошлась, отправитель не должен повторять запрос.
-
Ошибка сервера (5xx) говорит «повторите». Отправитель будет повторять запрос по расписанию.
На несошедшуюся подпись ставят код ошибки клиента — успешный ответ на неё прячет атаку и ломает разбор инцидента.
Как жить с секретом
Симметричная подпись на общем секрете подтверждает только знание секрета: тем же секретом приёмник может изготовить событие сам, и каждый новый приёмник расширяет круг тех, кто на это способен. Асимметричная подпись меняет не момент инцидента, а обычную работу: закрытый ключ остаётся у отправителя, а ключ проверки секретом не является — его раздают нескольким приёмникам, кладут в конфигурацию, в образ и в репозиторий.
Секрет свой на каждую пару «отправитель — приёмник» и на каждый адрес приёмника — где его хранить и как раздавать, разобрано в материале Секреты в CI: пять типовых утечек. Один секрет на все интеграции делает ротацию невозможной без простоя, а компрометацию одной превращает в компрометацию всех.
Ротация секрета выглядит так:
-
Отправитель в период ротации кладёт в один заголовок несколько подписей. Например, в заголовке с подписью через разделитель стоят два значения: одно посчитано прежним секретом, другое — новым.
-
Приёмник держит набор действующих ключей. Он перебирает все ключи и ищет любое совпадение. Перебор проходят до конца, без раннего выхода на первом совпадении.
Идентификатор ключа рядом с подписью лишь укорачивает перебор, но не обязателен. Например, заголовок с подписью может нести два значения через разделитель: ключ1=схема=abc123,ключ2=схема=def456.
Позиция редакции
Подпись считают по сырым байтам, собранным в строку ровно по правилу отправителя. В эту строку включают отметку времени — без любого из этих двух условий проверка декоративна. Сравнивают за постоянное время: не потому, что иначе подпись подберут, а потому что это одна строка кода, снимающая вопрос целиком.
Если фреймворк не отдаёт сырое тело, меняют способ чтения запроса, а не подписывают разобранный и заново собранный объект.
Позиция неверна, если отправитель и получатель — ваши собственные сервисы со взаимной проверкой сертификатов. Канал уже аутентифицирует обе стороны, отдельная подпись добавляет обслуживание секрета без новой гарантии, и достаточно идемпотентности по идентификатору события.