Przejdź do treści

Weryfikacja podpisu webhooka: dlaczego HMAC-SHA256 jest jedynym bezpiecznym sposobem na zaufanie powrotowi płatności

Webhook płatności przychodzi na twój serwer, twierdząc, że zamówienie zostało opłacone. Czy mu ufasz? Jeśli uznasz zamówienie na podstawie samego wywołania HTTP — bez weryfikacji podpisu kryptograficznego — właśnie dałeś atakującym darmowy sposób na oznaczenie dowolnego zamówienia jako opłacone.

Payora7 min czytaniaEN · RU · UK · ES · DE · FR · IT · PL · PT · HI · ZH

Webhook płatności przychodzi do twojego serwera, twierdząc, że zamówienie zostało opłacone. Czy mu ufasz? Jeśli uznasz zamówienie na podstawie samego wywołania HTTP — bez weryfikacji podpisu kryptograficznego — właśnie dałeś atakującym darmowy sposób na oznaczenie dowolnego zamówienia jako opłacone.

Co Może Pójść Źle Bez Weryfikacji

Niechroniony punkt końcowy webhooka jest łatwy do wykorzystania. Atakujący musi tylko znać twój adres URL zwrotny — który często można odkryć z zakładki sieciowej przeglądarki, publicznego repozytorium GitHub lub testowego zakupu — i mogą wysłać spreparowany ładunek:

POST /webhook HTTP/1.1
Content-Type: application/json

{"order_id": "1337", "status": "paid", "amount": "99.99"}

Jeśli twój handler odczytuje $_POST['status'] === 'paid' i uznaje zamówienie, atakujący właśnie kupił towary o wartości 99,99 USD za darmo. To nie jest teoretyczny atak; zdarza się regularnie przeciwko niechronionym integracjom płatności.

Jak Działa Podpisywanie HMAC-SHA256

HMAC (Kod uwierzytelniania wiadomości oparty na haszowaniu) z SHA-256 rozwiązuje ten problem, czyniąc ładunek niemożliwym do sfałszowania bez klucza tajnego. Proces jest następujący:

  1. Bramka płatności zna twój api_secret — tajny klucz dzielony tylko między tobą a bramką.
  2. Przed wysłaniem webhooka oblicza: HMAC-SHA256(timestamp + "." + body, api_secret)
  3. Dołącza ten podpis i znacznik czasu w nagłówkach HTTP.
  4. Twój serwer ponownie oblicza ten sam HMAC i porównuje. Jeśli się zgadzają, ładunek jest autentyczny.

Atakujący, który nie zna twojego api_secret, nie może sfałszować ważnego podpisu — SHA-256 to funkcja jednokierunkowa. Znacznik czasu zapobiega atakom powtórzeniowym: nawet ważny przechwycony webhook staje się bezużyteczny po 5 minutach.

Implementacja Weryfikacji: 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. odrzucaj przestarzałe znaczniki czasu (ochrona przed powtórzeniami)
    if (!ctype_digit($ts) || abs(time() - (int)$ts) > 300) {
        http_response_code(400); exit('przestarzały znacznik czasu');
    }

    // 2. ponownie oblicz i porównaj — hash_equals() zapobiega atakom czasowym
    $expected = hash_hmac('sha256', $ts . '.' . $body, $secret);
    if (!hash_equals($expected, $sig)) {
        http_response_code(401); exit('nieprawidłowy podpis');
    }

    return json_decode($body, true);
}

$event = verifyPayoraWebhook('sk_your_secret');
if ($event['status'] === 'paid') {
    creditOrder($event['order_id']);
}

Implementacja Weryfikacji: 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; // przestarzały
    }

    const expected = crypto
        .createHmac('sha256', secret)
        .update(`${ts}.${rawBody}`)
        .digest('hex');

    // timingSafeEqual zapobiega atakom czasowym
    return crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(sig))
        ? JSON.parse(rawBody)
        : null;
}

Typowe Błędy, Które Łamią Weryfikację

1. Odczytywanie sparsowanego ciała zamiast surowych bajtów. Middleware json() Expressa ponownie serializuje JSON, co może zmienić kolejność kluczy lub zmienić białe znaki. Zawsze buforuj surowe ciało żądania przed parsowaniem i używaj tego do HMAC.

2. Używanie === zamiast porównania bezpiecznego czasowo. Naivna porównanie ciągów ujawnia informacje czasowe, które mogą być wykorzystane do zgadywania podpisu bajt po bajcie. Użyj hash_equals() w PHP lub crypto.timingSafeEqual() w Node.

3. Pomijanie sprawdzenia znacznika czasu. Ważny, ale stary webhook przechwycony przez obserwatora sieciowego mógłby zostać powtórzony. Okno 5 minut (±300 sekund) eliminuje to ryzyko, jednocześnie obsługując rozsądne dryfowanie zegara.

4. Logowanie surowego ciała przed weryfikacją. Jeśli twój pipeline logowania zostanie skompromitowany, zalogowane ładunki mogą zostać powtórzone. Weryfikuj najpierw, loguj później.

Poza Podpisem: Idempotencja

Weryfikacja dowodzi, że webhook jest autentyczny; idempotencja zapewnia, że nie uznajesz zamówienia dwa razy. Sieci ponownie próbują webhooki w przypadku odpowiedzi nie-200, więc twój handler może otrzymać to samo zdarzenie wiele razy. Rozwiązaniem jest zapisanie webhook_id w twojej bazie danych i pominięcie przetwarzania, jeśli już je widziałeś:

// pseudo-kod
$id = $event['webhook_id'];
if (DB::exists('processed_webhooks', $id)) return respond(200);
DB::insert('processed_webhooks', $id);
creditOrder($event['order_id']);

Razem, weryfikacja podpisu i idempotencja dają ci integrację płatności, która jest zarówno bezpieczna, jak i odporna. Zobacz pełny przewodnik integracji dla pełnej dokumentacji webhooków Payora.

Comments

Gotowy, aby zacząć?

Utwórz konto i wystaw pierwszą fakturę w mniej niż godzinę.

Na co odpowiada ta strona