ГлавнаяБлог › HMAC-SHA256 без багов

Проверка подписи вебхуков Kaspi Pay: HMAC-SHA256 без багов

Заголовок X-Webhook-Signature — единственное, что отделяет «у меня вебхук» от «у меня подделанный POST на /kaspi/webhook». Кажется простым, ломается одинаково у всех. Разбираем три самые частые ошибки и приводим рабочие приёмники на Python, Node и Go.

14 мая 2026 · 11 мин чтения · Безопасность

HMAC-SHA256 Webhooks Python Node.js 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
Django-ловушка. 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. На реальной сети латентность сети маскирует это, но «лучше не давать улик» — стандартная практика.

ЯзыкПравильноНЕправильно
Pythonhmac.compare_digest(a, b)a == b
Node.jscrypto.timingSafeEqual(A, B)a === b
Gohmac.Equal(A, B)string(a) == string(b)
PHPhash_equals($a, $b)$a === $b
RubyOpenSSL.fixed_length_secure_comparea == b
Гочча Node.js: crypto.timingSafeEqual бросает исключение, если длины строк не совпадают. Сначала проверяйте длину, потом вызывайте функцию — иначе ваш приёмник упадёт 500 при первой же случайной попытке доступа извне. В примере выше это сделано: if (A.length !== B.length) return false.

Баг #3: кодировка и пробельные символы

Несколько грабель, на которые наступают по очереди:

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, а не порог.

Ротация секрета

Рано или поздно секрет утекает или просто устаревает. Поскольку вебхук-сабскрипция хранит один секрет, простой путь:

  1. Создать вторую подписку с новым URL/секретом.
  2. Обработчик принимает оба секрета параллельно (если один из двух HMAC совпал — ОК).
  3. Через сутки удалить старую подписку.

Если ваш стек проще — можно временно держать в 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

Отладочный чек-лист, когда не сходится

  1. В логе печатается тот же самый secret, что и в подписке (GET /v1/webhooks).
  2. В логе печатается сырое тело — длина в байтах та же, что в Content-Length.
  3. В hex-выводе HMAC ровно 64 символа.
  4. Префикс sha256= присутствует в обеих сторонах сравнения.
  5. Сравнение — timing-safe.
  6. Между приёмом тела и подсчётом HMAC ничего не модифицирует req.body.
  7. Если у вас Cloudflare / Nginx-Ingress — проверьте, не дополняет ли они тело.
Совет. На время отладки логируйте оба значения подписи (полученное и ожидаемое). После того, как заработало, обязательно выключите — иначе вы зальёте в логи действительные подписи, чем облегчите жизнь тому, кто получит доступ к логам.