Безопасность вебхуков: проверка подписи и защита секрета

Как xpayment подписывает каждый вебхук алгоритмом HMAC-SHA256, как проверять заголовок X-xPayment-Signature на Node, Go и Python, и как выбрать, хранить и менять секрет.

Проверяйте каждый вебхук до обработки

Публичный эндпоинт может получить поддельный или повторно отправленный запрос. xpayment аутентифицирует каждую доставку с помощью HMAC-подписи. Обработчик должен проверить ее до разбора события или изменения заказа.

Корректная подпись доказывает, что точное тело подписал тот, кто знает secret подписки. Она не подтверждает свежесть запроса: от повторов отдельно защищает однократная обработка каждого delivery_id.

Контракт подписи

Для каждой попытки xpayment вычисляет:

lowercase_hex(HMAC-SHA256(subscription_secret, exact_raw_body))

64-символьный digest отправляется без префикса sha256=:

X-xPayment-Signature: aeafc0dcd86de4d4d376f525be2b1175520c2818c93938ec8d069957c63a373b

Хешируйте байты точно в полученном виде. Разбор и повторная сериализация JSON могут изменить пробелы, порядок ключей или формат чисел, поэтому подпись не совпадет.

Контрольный пример

Secret: qwerty123

{"event":"payment.refunded","event_version":"1","delivery_id":"c09a3647-7822-4899-8401-3857cbd5bf68","attempt_number":1,"source":"xpayment","payment_id":"65cd0d84-5f66-4763-94d2-1b8e1ba68347","merchant_order_id":"qweasdxzc","created_at":"2026-06-27T10:24:04.104055991+05:00"}

Ожидаемая подпись:

aeafc0dcd86de4d4d376f525be2b1175520c2818c93938ec8d069957c63a373b

Ее можно воспроизвести локально, никуда не отправляя secret:

printf '%s' '<точный JSON выше>' | openssl dgst -sha256 -hmac 'qwerty123'

При повторе меняется attempt_number, но сохраняется delivery_id. Поскольку тело меняется, xpayment вычисляет новую подпись для каждой попытки.

Примеры проверки

Отклоните отсутствующую или неверно сформированную подпись, пересчитайте digest по исходному телу и сравните значения за постоянное время.

Node.js и Express

const crypto = require("crypto")

function validSignature(rawBody, signature, secret) {
  if (!/^[0-9a-f]{64}$/.test(signature || "")) return false
  const received = Buffer.from(signature, "hex")
  const expected = crypto.createHmac("sha256", secret).update(rawBody).digest()
  return crypto.timingSafeEqual(received, expected)
}

app.use(express.json({ verify: (req, _res, buf) => { req.rawBody = buf } }))

app.post("/webhook", async (req, res) => {
  const signature = req.get("X-xPayment-Signature")
  if (!validSignature(req.rawBody, signature, process.env.WEBHOOK_SECRET)) {
    return res.status(401).end()
  }

  await acceptIdempotently(req.body)
  return res.status(200).json({ ok: true })
})

Go

func validSignature(body []byte, signature, secret string) bool {
	received, err := hex.DecodeString(signature)
	if err != nil || len(received) != sha256.Size {
		return false
	}

	mac := hmac.New(sha256.New, []byte(secret))
	_, _ = mac.Write(body)
	return hmac.Equal(received, mac.Sum(nil))
}

func handler(w http.ResponseWriter, r *http.Request) {
	body, err := io.ReadAll(r.Body)
	if err != nil {
		http.Error(w, "invalid body", http.StatusBadRequest)
		return
	}

	if !validSignature(
		body,
		r.Header.Get("X-xPayment-Signature"),
		os.Getenv("WEBHOOK_SECRET"),
	) {
		w.WriteHeader(http.StatusUnauthorized)
		return
	}

	// Разбираем body, идемпотентно принимаем delivery_id и отвечаем.
	w.WriteHeader(http.StatusOK)
}

Python и Flask

import hashlib, hmac, re
from flask import request

def valid_signature(raw_body: bytes, signature: str, secret: str) -> bool:
    if re.fullmatch(r"[0-9a-f]{64}", signature or "") is None:
        return False
    expected = hmac.new(secret.encode(), raw_body, hashlib.sha256).hexdigest()
    return hmac.compare_digest(signature, expected)

@app.post("/webhook")
def webhook():
    raw_body = request.get_data()
    signature = request.headers.get("X-xPayment-Signature", "")
    if not valid_signature(raw_body, signature, WEBHOOK_SECRET):
        return "", 401

    accept_idempotently(request.get_json())
    return {"ok": True}, 200

Порядок всегда один: проверить подпись -> разобрать JSON -> атомарно занять delivery_id -> применить изменение -> вернуть 2xx. Ответ не-2xx сообщает xpayment, что доставку нужно повторить.

Управление secret

Публичный API и кабинет пока не позволяют менять secret существующей подписки. Ротация доступна только через административную CLI-команду. Если команда недоступна, замените подписку новой и спланируйте переключение без потери или двойной обработки событий.

При каждой попытке sender читает текущий secret подписки. После административной ротации события в очереди и последующие повторы подписываются новым значением; уже отправляемый запрос еще может содержать старую подпись.

Всегда публикуйте принимающий эндпоинт через HTTPS. Сейчас backend проверяет только непустой URL и не контролирует его схему за вас.

Проверка подписи аутентифицирует тело, а атомарная идемпотентность по delivery_id защищает от повторных доставок и replay. Формат payload и правила повторов описаны в руководстве по вебхукам.