Подпись запроса

Каждый webhook-запрос от Emailit содержит заголовок X-Emailit-Signature с HMAC-SHA256 подписью и заголовок X-Emailit-Timestamp с Unix-временной меткой момента подписания запроса. Вам следует проверять эту подпись, чтобы убедиться в подлинности запроса и отсутствии несанкционированных изменений.

Принцип работы

  1. При создании webhook’а Emailit генерирует секретный ключ подписи для данной конечной точки
  2. Для каждой доставки Emailit объединяет временную метку и исходное тело запроса в формате {'{'}timestamp{'}'}.{'{'}rawBody{'}'}
  3. Вычисляется HMAC-SHA256 хеш этой строки с использованием вашего секретного ключа подписи
  4. Подпись отправляется в заголовке X-Emailit-Signature, а временная метка — в X-Emailit-Timestamp
  5. Ваш сервер пересчитывает подпись и сравнивает её со значением в заголовке

Проверка подписи

Для проверки подписи webhook’а:

  1. Извлеките заголовки X-Emailit-Signature и X-Emailit-Timestamp из запроса
  2. Объедините временную метку и исходное тело запроса в формате {'{'}timestamp{'}'}.{'{'}rawBody{'}'}
  3. Вычислите HMAC-SHA256 хеш этой строки, используя ваш секретный ключ подписи webhook’а
  4. Сравните вычисленный хеш с подписью, используя безопасное по времени сравнение
  5. При необходимости отклоняйте запросы с временными метками старше нескольких минут для защиты от атак повторного воспроизведения
import crypto from 'crypto';

function verifyWebhookSignature(rawBody, signature, timestamp, secret) {
const signedPayload = `${timestamp}.${rawBody}`;
const computed = crypto
.createHmac('sha256', secret)
.update(signedPayload, 'utf8')
.digest('hex');

return crypto.timingSafeEqual(
Buffer.from(computed, 'hex'),
Buffer.from(signature, 'hex')
);
}

// В обработчике webhook'а:
const rawBody = req.body; // исходное тело запроса как строка
const signature = req.headers['x-emailit-signature'];
const timestamp = req.headers['x-emailit-timestamp'];
const secret = process.env.WEBHOOK_SIGNING_SECRET;

// Защита от атак повторного воспроизведения (допуск 5 минут)
const age = Math.floor(Date.now() / 1000) - parseInt(timestamp, 10);
if (age > 300) {
return res.status(401).send('Запрос слишком старый');
}

if (!verifyWebhookSignature(rawBody, signature, timestamp, secret)) {
return res.status(401).send('Неверная подпись');
}
import hmac
import hashlib
import time

def verify_webhook_signature(raw_body, signature, timestamp, secret):
signed_payload = f"{timestamp}.{raw_body}"
computed = hmac.new(
    secret.encode('utf-8'),
    signed_payload.encode('utf-8'),
    hashlib.sha256
).hexdigest()

return hmac.compare_digest(computed, signature)

# Защита от атак повторного воспроизведения (допуск 5 минут)
age = int(time.time()) - int(timestamp)
if age > 300:
raise ValueError("Запрос слишком старый")
function verifyWebhookSignature(
string $rawBody,
string $signature,
string $timestamp,
string $secret
): bool {
$signedPayload = "{$timestamp}.{$rawBody}";
$computed = hash_hmac('sha256', $signedPayload, $secret);
return hash_equals($computed, $signature);
}

// Защита от атак повторного воспроизведения (допуск 5 минут)
$age = time() - intval($timestamp);
if ($age > 300) {
http_response_code(401);
exit('Запрос слишком старый');
}
require 'openssl'

def verify_webhook_signature(raw_body, signature, timestamp, secret)
signed_payload = "#{timestamp}.#{raw_body}"
computed = OpenSSL::HMAC.hexdigest('SHA256', secret, signed_payload)
Rack::Utils.secure_compare(computed, signature)
end

# Защита от атак повторного воспроизведения (допуск 5 минут)
age = Time.now.to_i - timestamp.to_i
raise 'Запрос слишком старый' if age > 300
import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"fmt"
"time"
"strconv"
)

func verifyWebhookSignature(rawBody, signature, timestamp, secret string) bool {
signedPayload := fmt.Sprintf("%s.%s", timestamp, rawBody)
mac := hmac.New(sha256.New, []byte(secret))
mac.Write([]byte(signedPayload))
computed := hex.EncodeToString(mac.Sum(nil))
return hmac.Equal([]byte(computed), []byte(signature))
}

// Защита от атак повторного воспроизведения (допуск 5 минут)
ts, _ := strconv.ParseInt(timestamp, 10, 64)
if time.Now().Unix()-ts > 300 {
// отклонить запрос
}

Важные замечания

  • Подпись вычисляется для строки {'{'}timestamp{'}'}.{'{'}rawBody{'}'} — всегда используйте исходную строку тела запроса, а не повторно сериализованную версию (например, JSON.stringify(req.body)). Различия в пробелах или порядке ключей нарушат подпись.
  • Всегда используйте функцию безопасного по времени сравнения (например, crypto.timingSafeEqual, hmac.compare_digest, hash_equals) для предотвращения атак по времени.
  • Рассмотрите возможность отклонения запросов, где X-Emailit-Timestamp старше нескольких минут, для защиты от атак повторного воспроизведения.
  • Храните секретный ключ подписи в безопасности — используйте переменные окружения, а не исходный код.
  • Секретный ключ подписи webhook’а можно найти в панели управления Emailit в настройках webhook’а или через API Webhooks.
Локализовано с помощью ИИ