На ваш сервер надходить платіжний вебхук, який повідомляє, що замовлення оплачено. Чи довіритеся ви йому? Якщо ви зарахуєте замовлення на підставі самого лише HTTP-запиту — без перевірки криптографічного підпису, — ви щойно подарували зловмисникам безкоштовний спосіб позначати будь-яке замовлення як оплачене.
Що може піти не так без перевірки
Незахищений ендпоінт вебхука експлуатується тривіально. Зловмиснику достатньо дізнатися URL вашого колбеку — а його часто можна знайти у вкладці Network у браузері, у публічному репозиторії на GitHub або під час тестової оплати, — і він надішле POST із підготовленим payload:
POST /webhook HTTP/1.1
Content-Type: application/json
{"order_id": "1337", "status": "paid", "amount": "99.99"}Якщо ваш обробник читає $_POST['status'] === 'paid' і зараховує замовлення, зловмисник щойно отримав товарів на $99,99 безкоштовно. Це не теоретична атака: вона регулярно трапляється з незахищеними платіжними інтеграціями.
Як працює підписування HMAC-SHA256
HMAC (Hash-based Message Authentication Code) на базі SHA-256 розв’язує цю проблему: підробити payload без секретного ключа неможливо. Процес такий:
- Платіжний шлюз знає ваш api_secret — секрет, відомий лише вам і шлюзу.
- Перед надсиланням вебхука він обчислює:
HMAC-SHA256(timestamp + "." + body, api_secret) - Підпис і мітку часу він передає в HTTP-заголовках.
- Ваш сервер перераховує той самий HMAC і порівнює значення. Якщо вони збіглися — payload автентичний.
Зловмисник, який не знає вашого api_secret, не зможе підробити коректний підпис: SHA-256 — одностороння функція. Мітка часу захищає від replay-атак — навіть перехоплений валідний вебхук стає марним через 5 хвилин.
Реалізація перевірки: PHP
function verifyPayoraWebhook(string $secret): array {
$ts = $_SERVER['HTTP_X_PAYORA_TIMESTAMP'] ?? '';
$sig = $_SERVER['HTTP_X_PAYORA_SIGNATURE'] ?? '';
$body = file_get_contents('php://input');
// 1. відхиляємо застарілі мітки часу (захист від replay-атак)
if (!ctype_digit($ts) || abs(time() - (int)$ts) > 300) {
http_response_code(400); exit('stale timestamp');
}
// 2. перераховуємо й порівнюємо — hash_equals() захищає від timing-атак
$expected = hash_hmac('sha256', $ts . '.' . $body, $secret);
if (!hash_equals($expected, $sig)) {
http_response_code(401); exit('invalid signature');
}
return json_decode($body, true);
}
$event = verifyPayoraWebhook('sk_your_secret');
if ($event['status'] === 'paid') {
creditOrder($event['order_id']);
}Реалізація перевірки: Node.js
import crypto from 'node:crypto';
function verifyPayoraWebhook(req, rawBody, secret) {
const ts = req.headers['x-payora-timestamp'] ?? '';
const sig = req.headers['x-payora-signature'] ?? '';
if (!ts || Math.abs(Date.now() / 1000 - Number(ts)) > 300) {
return null; // застаріла
}
const expected = crypto
.createHmac('sha256', secret)
.update(`${ts}.${rawBody}`)
.digest('hex');
// timingSafeEqual захищає від атак за стороннім каналом часу
return crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(sig))
? JSON.parse(rawBody)
: null;
}Типові помилки, які ламають перевірку
1. Читання розібраного тіла замість сирих байтів. Middleware json() в Express наново серіалізує JSON і може змінити порядок ключів або пробіли. Завжди буферизуйте сире тіло запиту до парсингу й використовуйте для HMAC саме його.
2. Використання === замість порівняння, стійкого до атак за часом. Наївне порівняння рядків видає інформацію про час виконання, за якою підпис можна підібрати байт за байтом. Використовуйте hash_equals() у PHP або crypto.timingSafeEqual() у Node.
3. Пропуск перевірки мітки часу. Валідний, але старий вебхук, перехоплений під час спостереження за мережею, можна відтворити повторно. Вікно у 5 хвилин (±300 секунд) усуває цей ризик і водночас допускає розумне розходження годинників.
4. Логування сирого тіла до перевірки. Якщо ваш конвеєр логування скомпрометовано, записані payload можна відтворити повторно. Спершу перевірка, потім логування.
Більше, ніж підпис: ідемпотентність
Перевірка підтверджує автентичність вебхука; ідемпотентність гарантує, що ви не зарахуєте замовлення двічі. Шлюзи повторюють надсилання вебхуків у разі відповідей, відмінних від 200, тож ваш обробник може отримати одну й ту саму подію кілька разів. Рішення — записувати webhook_id до бази даних і пропускати обробку, якщо такий ідентифікатор уже траплявся:
// псевдокод
$id = $event['webhook_id'];
if (DB::exists('processed_webhooks', $id)) return respond(200);
DB::insert('processed_webhooks', $id);
creditOrder($event['order_id']);Разом перевірка підпису та ідемпотентність дають платіжну інтеграцію, яка є водночас безпечною та стійкою. Дивіться повний посібник з інтеграції — там же повний довідник із вебхуків Payora.
Коментарі