Wszystkie artykuły
webhook securityHMACsignature verificationbest practices

Weryfikacja podpisu webhooków bez tajemnic

Większość błędów weryfikacji podpisu webhooków to nie bugi kryptograficzne. To bugi parsowania. Ktoś sparsował ciało przed weryfikacją. Ktoś użył hex, gdy dostawca chciał base64. Ktoś porównał przez ===, gdy potrzebne było porównanie timing-safe. Ten przewodnik to skrócona wersja każdego problemu z podpisem, jaki kiedykolwiek debugowaliśmy.

Model, którego używają dostawcy

Każdy duży dostawca webhooków — Stripe, GitHub, Shopify, Twilio, Slack, Discord — robi w istocie to samo w innym przebraniu:

  1. Ty i dostawca dzielicie sekret.
  2. Dostawca oblicza podpis na ciele żądania (czasem plus timestamp, czasem plus URL).
  3. Podpis wędruje w nagłówku.
  4. Twój handler przelicza podpis tym samym sekretem i odrzuca żądanie, jeśli się nie zgadzają.

Co się różni między dostawcami: algorytm (najczęściej HMAC SHA-256; Discord używa Ed25519), kodowanie (hex vs. base64), co jest podpisywane (ciało, ciało+timestamp, URL+ciało+parametry) i który nagłówek niesie podpis.

Cztery bugi, które tłumaczą prawie wszystko

1. Parsowanie ciała najpierw

Większość frameworków ma middleware JSON, które parsuje przychodzące ciała na obiekty. Gdy się uruchomi, surowe bajty znikają, a każdy obliczony podpis się nie zgodzi — nawet jeśli ponownie zserializujesz sparsowany obiekt, białe znaki i kolejność kluczy mogą się różnić.

Rozwiązanie: czytaj surowe ciało przed jakimkolwiek middleware na trasie webhooka. W Express zamontuj express.raw({ type: 'application/json' }) tylko na ścieżce webhooka. W Next.js App Router czytaj await request.text() bezpośrednio. W Django użyj @csrf_exempt + request.body zanim uruchomi się jakikolwiek parser DRF.

2. Złe kodowanie

Stripe i GitHub używają hex. Shopify używa base64. Ten sam sekret daje w każdym inaczej wyglądający wynik. Omówiliśmy to w przewodniku Shopify — gryzie każdego.

3. Brak timing-safe

Równość ciągów (===) kończy się wcześnie na pierwszym niezgodnym znaku. Różnica czasu między „pierwszy znak się zgadza" a „wszystkie 64 znaki się zgadzają" wycieka informacje o sekrecie. Użyj porównania timing-safe:

// Node
crypto.timingSafeEqual(Buffer.from(a), Buffer.from(b));

// Python
hmac.compare_digest(a, b)

// Ruby
ActiveSupport::SecurityUtils.secure_compare(a, b)

W praktyce atak jest trudny do przeprowadzenia na endpoint webhooka przez publiczny internet, ale bezpieczne porównanie jest tak tanie, że nie ma powodu, by go nie używać.

4. Niewalidowany timestamp

Jeśli podpis obejmuje tylko ciało, atakujący, który przechwyci prawidłowe żądanie, może je powtarzać w nieskończoność. Dostawcy rozwiązują to, dołączając timestamp do payloadu podpisu i okno tolerancji w dokumentacji (Stripe: 5 minut, GitHub: też krótkie). Twój handler powinien odrzucać każdy podpis z timestampem starszym niż tolerancja.

Pomiń ten krok, a zbudowałeś system podatny na replay, bez względu na to, jak doskonale działa HMAC.

Co naprawdę jest w nagłówku

Nagłówki dostawców niosą więcej niż tylko „podpis". Stripe wysyła t=1234567890,v1=abcdef...,v0=... — timestamp plus wiele wersji podpisu. GitHub wysyła sha256=abcdef... z prefiksem. Twilio włącza URL żądania do payloadu podpisu, ale wysyła sam podpis tylko w X-Twilio-Signature. Przeczytaj dokumentację dostawcy, z którym integrujesz, a potem implementuj pod dokładny format.

Testowanie lokalne czyni to debugowalnym

Weryfikacja podpisu to najgorszy rodzaj buga: cichy i trudny do odtworzenia na staging. Testowanie lokalne z tunelem i przechwytywaniem żądań daje dokładny payload, który zawiódł, dokładny nagłówek, który dotarł, i przycisk powtórki, by przetestować poprawkę bez niepokojenia dostawcy.

Gdy weryfikacja coś odrzuci, zaloguj tyle, by zdebugować: długość surowego ciała, odczytany nagłówek, obliczony podpis. Nie loguj sekretu. (Widzieliśmy, jak ludzie logują sekrety. Nie rób tego.)

Minimalny poprawny weryfikator

// Express + Node, generic HMAC SHA-256 hex provider
app.post(
  '/webhooks/foo',
  express.raw({ type: 'application/json' }),
  (req, res) => {
    const signature = req.headers['x-foo-signature'];
    const timestamp = req.headers['x-foo-timestamp'];

    // 1. Reject old timestamps (5 min tolerance)
    if (Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) {
      return res.status(400).send('stale');
    }

    // 2. Compute expected signature
    const expected = crypto
      .createHmac('sha256', process.env.FOO_SECRET)
      .update(`${timestamp}.${req.body}`)
      .digest('hex');

    // 3. Timing-safe compare
    if (!crypto.timingSafeEqual(
      Buffer.from(expected),
      Buffer.from(signature),
    )) {
      return res.status(401).send('invalid');
    }

    // 4. Now safe to parse and process
    const event = JSON.parse(req.body);
    handleEvent(event);
    res.status(200).end();
  },
);

Gdy weryfikacja podpisu wciąż zawodzi

Jeśli wszystko powyższe jest poprawne, a wciąż zawodzi, sprawdź jeszcze trzy rzeczy: sekret ma końcowe białe znaki z pliku env, twój tunel przepisał nagłówek (większość nie, ale warto potwierdzić przechwytywaniem żądań), albo testujesz wobec sekretu live, podczas gdy dashboard pokazuje sekret testowy. Trafiliśmy na wszystkie trzy.

Po szczegóły specyficzne dla dostawców zajrzyj do przewodników Stripe, GitHub i Shopify. A jeśli patrzysz teraz na 401, przejdź do debugowania błędów 401 webhooka. Dołącz do listy oczekujących PortPreview, by mieć tunel + przechwytywanie, które to debugują.

Najczęściej zadawane pytania

Dlaczego weryfikacja podpisu mojego webhooka zawodzi?
Najczęściej ciało zostało sparsowane przez middleware przed uruchomieniem weryfikacji. Inne częste przyczyny: pomylenie hex z base64, porównanie bez timing-safe zawodzące w pewnych przypadkach, albo użycie złego sekretu (test vs live, dashboard vs CLI).
Czy naprawdę potrzebuję porównania timing-safe dla podpisów webhooków?
Tak. Zwykła równość ciągów wycieka informacje o czasie dotyczące sekretu bajt po bajcie. Atak jest trudny w praktyce, ale bezpieczne porównanie to jedna linia kodu, więc nie ma powodu, by go nie używać.
Dlaczego mój podpis webhooka zawiera timestamp?
Bez timestampu atakujący, który przechwyci prawidłowe podpisane żądanie, może je powtarzać w nieskończoność. Timestamp pozwala handlerowi odrzucić wszystko starsze niż okno tolerancji dostawcy, zwykle kilka minut.