До вмісту

Перевірка підпису вебхука: чому HMAC-SHA256 — єдиний безпечний спосіб довіряти платіжному колбеку

Payora7 хв читанняEN · RU · UK · ES · DE · FR · IT · PL · PT · HI · ZH

На ваш сервер надходить платіжний вебхук, який повідомляє, що замовлення оплачено. Чи довіритеся ви йому? Якщо ви зарахуєте замовлення на підставі самого лише 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 без секретного ключа неможливо. Процес такий:

  1. Платіжний шлюз знає ваш api_secret — секрет, відомий лише вам і шлюзу.
  2. Перед надсиланням вебхука він обчислює: HMAC-SHA256(timestamp + "." + body, api_secret)
  3. Підпис і мітку часу він передає в HTTP-заголовках.
  4. Ваш сервер перераховує той самий 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.

Поділитися

Коментарі

Готові почати?

Створіть акаунт — і перший рахунок запрацює менш ніж за годину.

На які запити відповідає ця сторінка