Главная › Блог › HMAC-SHA256 без багов
Проверка подписи вебхуков Kaspi Pay: HMAC-SHA256 без багов
Заголовок X-Webhook-Signature — единственное, что отделяет
«у меня вебхук» от «у меня подделанный POST на /kaspi/webhook».
Кажется простым, ломается одинаково у всех. Разбираем три самые частые ошибки
и приводим рабочие приёмники на Python, Node и Go.
Формат заголовка
PayProverkaBot шлёт каждый вебхук с заголовком:
X-Webhook-Signature: sha256=<hex_64>
X-Webhook-Event: payment.success
X-Webhook-Attempt: 1
User-Agent: kaspipayinvoice-webhooks/1.0
Content-Type: application/json
Подпись — это HMAC-SHA256(secret, raw_body), закодированный в hex,
с префиксом sha256=. secret — это значение, которое
вы передали в POST /v1/webhooks при подписке.
Hex — 64 символа в нижнем регистре.
sha256=?
Чтобы через год можно было ввести sha512= и не сломать вам клиентов.
Не выкидывайте префикс при сравнении — сравнивайте включая его.
Иначе при апгрейде вы окажетесь дыркой.
Баг #1: JSON.stringify вместо сырого тела
Самый частый баг. Веб-фреймворки услужливо парсят JSON в объект,
и разработчик считает HMAC от JSON.stringify(req.body).
Это не сработает: при сериализации обратно теряются пробелы,
меняется порядок ключей, экранирование Unicode идёт по другим правилам.
HMAC мгновенно перестаёт совпадать.
Node.js / Express — правильно
const express = require('express');
const crypto = require('crypto');
const app = express();
const SECRET = process.env.KASPI_WEBHOOK_SECRET;
// ВАЖНО: получаем сырое тело как Buffer, парсим JSON ВТОРЫМ шагом
app.post('/kaspi/webhook',
express.raw({ type: 'application/json' }),
(req, res) => {
const sig = req.header('x-webhook-signature') || '';
const expected = 'sha256=' + crypto
.createHmac('sha256', SECRET)
.update(req.body) // req.body здесь — Buffer, не объект
.digest('hex');
if (!safeEqual(expected, sig)) {
return res.status(401).send('bad signature');
}
const event = JSON.parse(req.body.toString('utf8'));
handleEvent(event);
res.status(200).send('ok');
}
);
function safeEqual(a, b) {
const A = Buffer.from(a);
const B = Buffer.from(b);
if (A.length !== B.length) return false;
return crypto.timingSafeEqual(A, B);
}
Node.js / Express — НЕправильно
// 💥 НЕ ДЕЛАЙТЕ ТАК
app.use(express.json());
app.post('/kaspi/webhook', (req, res) => {
// req.body здесь уже объект — повторный stringify даст ДРУГИЕ байты
const expected = 'sha256=' + crypto
.createHmac('sha256', SECRET)
.update(JSON.stringify(req.body)) // 💥 неверные байты
.digest('hex');
// подпись никогда не совпадёт
});
Python / Flask
from flask import Flask, request, abort
import hmac, hashlib, os
SECRET = os.environ["KASPI_WEBHOOK_SECRET"].encode()
app = Flask(__name__)
@app.post("/kaspi/webhook")
def webhook():
body = request.get_data() # сырые байты
sig = request.headers.get("X-Webhook-Signature", "")
expected = "sha256=" + hmac.new(SECRET, body, hashlib.sha256).hexdigest()
if not hmac.compare_digest(expected, sig): # timing-safe
abort(401)
event = request.get_json()
handle_event(event)
return "", 200
request.body можно прочитать
только один раз. Если до вашего вьюшки middleware уже прочитало тело
(например, для логирования), request.body будет пустой.
Лечение — middleware должен кэшировать тело, или вы используете
request.read() до парсера.
Go / net/http
package main
import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"io"
"net/http"
"os"
)
var secret = []byte(os.Getenv("KASPI_WEBHOOK_SECRET"))
func webhook(w http.ResponseWriter, r *http.Request) {
body, err := io.ReadAll(r.Body)
if err != nil {
http.Error(w, "bad body", 400)
return
}
mac := hmac.New(sha256.New, secret)
mac.Write(body)
expected := "sha256=" + hex.EncodeToString(mac.Sum(nil))
sig := r.Header.Get("X-Webhook-Signature")
if !hmac.Equal([]byte(expected), []byte(sig)) {
http.Error(w, "bad signature", 401)
return
}
// парсим body, обрабатываем событие
w.WriteHeader(200)
}
Баг #2: обычное сравнение строк (timing attack)
Сравнение через == или === завершается раньше,
если первый отличающийся байт находится раньше в строке. Это позволяет
атакующему по таймингу постепенно вычислить правильный hex.
На реальной сети латентность сети маскирует это, но «лучше не давать улик» —
стандартная практика.
| Язык | Правильно | НЕправильно |
|---|---|---|
| Python | hmac.compare_digest(a, b) | a == b |
| Node.js | crypto.timingSafeEqual(A, B) | a === b |
| Go | hmac.Equal(A, B) | string(a) == string(b) |
| PHP | hash_equals($a, $b) | $a === $b |
| Ruby | OpenSSL.fixed_length_secure_compare | a == b |
crypto.timingSafeEqual бросает исключение,
если длины строк не совпадают. Сначала проверяйте длину, потом вызывайте функцию —
иначе ваш приёмник упадёт 500 при первой же случайной попытке доступа извне.
В примере выше это сделано: if (A.length !== B.length) return false.
Баг #3: кодировка и пробельные символы
Несколько грабель, на которые наступают по очереди:
- UTF-8 BOM или CRLF в теле. Прокси / WAF могут вставить символы при ретрансляции. PayProverkaBot шлёт чистый UTF-8 без BOM, LF переводы строк. Если ваш прокси добавит CR — HMAC сломается. Снимайте подпись до любого middleware, которое может тело перекодировать.
-
Hex в верхнем регистре. Мы шлём lowercase, но безопасный
приёмник делает
toLowerCase()при сравнении — на случай если когда-нибудь прокси решит «причесать» заголовки. -
Двойной заголовок. Некоторые балансировщики дублируют
хедеры через запятую:
X-Webhook-Signature: sha256=abc, sha256=abc. Если ваш фреймворк отдаёт первый — всё ок. Если конкатенирует — упадёт. Тест: распечатайте заголовок в логе и проверьте сырой вид.
Replay-атаки и timestamp
HMAC подтверждает авторство, но не свежесть запроса. Если злоумышленник перехватит ваш вебхук (например, MITM на корпоративном прокси без HTTPS), он сможет повторить его через час.
PayProverkaBot включает в тело поле timestamp (Unix epoch).
Сильное решение — отвергать вебхуки старше N минут:
import time
MAX_AGE = 600 # 10 минут
def handle_event(event):
if abs(time.time() - event["timestamp"]) > MAX_AGE:
# стар, скорее всего replay
log.warn("rejected stale webhook", payment_id=event["paymentId"])
return
...
Идеально — синхронизация времени по NTP с точностью до секунд. Если сервер «улетел» на 20 минут — отлаживайте сначала NTP, а не порог.
Ротация секрета
Рано или поздно секрет утекает или просто устаревает. Поскольку вебхук-сабскрипция хранит один секрет, простой путь:
- Создать вторую подписку с новым URL/секретом.
- Обработчик принимает оба секрета параллельно (если один из двух HMAC совпал — ОК).
- Через сутки удалить старую подписку.
Если ваш стек проще — можно временно держать в env переменной два значения через запятую и итерировать:
SECRETS = os.environ["KASPI_WEBHOOK_SECRETS"].split(",")
def verify(body, sig_header):
for s in SECRETS:
expected = "sha256=" + hmac.new(s.encode(), body, hashlib.sha256).hexdigest()
if hmac.compare_digest(expected, sig_header):
return True
return False
Отладочный чек-лист, когда не сходится
- В логе печатается тот же самый
secret, что и в подписке (GET /v1/webhooks). - В логе печатается сырое тело — длина в байтах та же, что в
Content-Length. - В hex-выводе HMAC ровно 64 символа.
- Префикс
sha256=присутствует в обеих сторонах сравнения. - Сравнение — timing-safe.
- Между приёмом тела и подсчётом HMAC ничего не модифицирует
req.body. - Если у вас Cloudflare / Nginx-Ingress — проверьте, не дополняет ли они тело.
Дальше
- Вебхуки или поллинг — когда вебхуки вообще нужны.
- Интеграция Kaspi Pay в CRM — где принимать подписанный вебхук в Bitrix24/Kommo/amoCRM.
- Документация API по вебхукам — события, поля, ретраи.
- RFC 2104: HMAC — официальная спецификация HMAC, если хочется глубже.