Návod
Ověření podpisu webhooků
Ověřením podpisu HMAC-SHA256 a časového razítka zkontrolujte, že požadavky webhooků přicházejí od Emailitu, s kódem pro Node.js, Python, PHP, Ruby, Go a Laravel.
Požadavky na URL vašeho webhooku může posílat kdokoli, kdo ji zná. Každý požadavek od Emailitu je podepsaný tajným klíčem vašeho webhooku, takže ověření podpisu dokazuje, že požadavek přišel od Emailitu a cestou se nezměnil. Kontrola časového razítka navíc zabrání tomu, aby někdo staré požadavky poslal znovu (útok opakováním). Obojí proveďte dřív, než na jakoukoli událost zareagujete.
Jak podpis funguje
Každý webhook má vlastní tajný klíč: whsec_ a za ním 64 hexadecimálních znaků. Uvidíte ho jednou, když webhook vytvoříte nebo tajný klíč vyměníte, a vrací ho také Načtení webhooku.
U každého požadavku Emailit:
- Vezme aktuální unixový čas v sekundách, například
1790846045, a pošle ho v hlavičceX-Emailit-Timestamp. - Sestaví řetězec
{timestamp}.{raw body}: časové razítko, tečku a přesné bajty těla požadavku. - Z tohoto řetězce spočítá HMAC-SHA256 a jako klíč použije celý tajný klíč včetně
whsec_. - Výsledek zakódovaný hexadecimálně malými písmeny pošle v hlavičce
X-Emailit-Signature.
Při ověření váš endpoint zopakuje kroky 2 a 3 s přijatým surovým tělem požadavku a svůj výsledek porovná s hlavičkou porovnáním v konstantním čase. Pak zkontroluje, že časové razítko je čerstvé. Doporučujeme toleranci 5 minut (300 sekund). Každé opakování se podepíše znovu s novým časovým razítkem, takže 5minutové okno opakování neodmítne.
Ověřujte požadavky
Každý příklad načte surové tělo požadavku, požadavky s chybějícím, zastaralým nebo chybným podpisem odmítne s 401 a teprve potom zpracuje pole JSON.
import crypto from 'node:crypto';
import express from 'express';
const app = express();
const SECRET = process.env.EMAILIT_WEBHOOK_SECRET; // full value, including whsec_
const TOLERANCE_SECONDS = 300;
export function verifyEmailitSignature(rawBody, signature, timestamp) {
if (!signature || !timestamp) return false;
const age = Math.abs(Math.floor(Date.now() / 1000) - Number(timestamp));
if (!(age <= TOLERANCE_SECONDS)) return false; // also rejects non-numeric timestamps
const expected = crypto
.createHmac('sha256', SECRET)
.update(`${timestamp}.`)
.update(rawBody) // a Buffer with the exact bytes received
.digest('hex');
const a = Buffer.from(expected);
const b = Buffer.from(signature);
return a.length === b.length && crypto.timingSafeEqual(a, b);
}
// express.raw() keeps the body as a Buffer. Don't run express.json() on this route.
app.post('/webhooks/emailit', express.raw({ type: 'application/json' }), (req, res) => {
const valid = verifyEmailitSignature(
req.body,
req.get('X-Emailit-Signature'),
req.get('X-Emailit-Timestamp'),
);
if (!valid) return res.status(401).send('Invalid signature');
const events = JSON.parse(req.body.toString('utf8'));
// Queue the events for processing, then acknowledge.
res.sendStatus(204);
});
app.listen(3000);import hashlib
import hmac
import json
import os
import time
from flask import Flask, abort, request
app = Flask(__name__)
SECRET = os.environ["EMAILIT_WEBHOOK_SECRET"].encode() # full value, including whsec_
TOLERANCE_SECONDS = 300
def verify_emailit_signature(raw_body: bytes, signature: str | None, timestamp: str | None) -> bool:
if not signature or not timestamp:
return False
try:
age = abs(time.time() - int(timestamp))
except ValueError:
return False
if age > TOLERANCE_SECONDS:
return False
expected = hmac.new(SECRET, timestamp.encode() + b"." + raw_body, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, signature)
@app.post("/webhooks/emailit")
def emailit_webhook():
raw_body = request.get_data() # raw bytes; don't use request.json first
if not verify_emailit_signature(
raw_body,
request.headers.get("X-Emailit-Signature"),
request.headers.get("X-Emailit-Timestamp"),
):
abort(401)
events = json.loads(raw_body)
# Queue the events for processing, then acknowledge.
return "", 204<?php
const TOLERANCE_SECONDS = 300;
function verifyEmailitSignature(string $rawBody, string $signature, string $timestamp, string $secret): bool
{
if ($signature === '' || !ctype_digit($timestamp)) {
return false;
}
if (abs(time() - (int) $timestamp) > TOLERANCE_SECONDS) {
return false;
}
$expected = hash_hmac('sha256', $timestamp . '.' . $rawBody, $secret);
return hash_equals($expected, $signature);
}
$rawBody = file_get_contents('php://input');
$signature = $_SERVER['HTTP_X_EMAILIT_SIGNATURE'] ?? '';
$timestamp = $_SERVER['HTTP_X_EMAILIT_TIMESTAMP'] ?? '';
$secret = (string) getenv('EMAILIT_WEBHOOK_SECRET'); // full value, including whsec_
if (!verifyEmailitSignature($rawBody, $signature, $timestamp, $secret)) {
http_response_code(401);
exit('Invalid signature');
}
$events = json_decode($rawBody, true);
// Queue the events for processing, then acknowledge.
http_response_code(204);require 'sinatra'
require 'openssl'
require 'json'
require 'rack/utils'
SECRET = ENV.fetch('EMAILIT_WEBHOOK_SECRET') # full value, including whsec_
TOLERANCE_SECONDS = 300
def verify_emailit_signature(raw_body, signature, timestamp)
return false if signature.to_s.empty? || timestamp.to_s !~ /\A\d+\z/
return false if (Time.now.to_i - timestamp.to_i).abs > TOLERANCE_SECONDS
expected = OpenSSL::HMAC.hexdigest('SHA256', SECRET, "#{timestamp}.#{raw_body}")
Rack::Utils.secure_compare(expected, signature)
end
post '/webhooks/emailit' do
raw_body = request.body.read # in Rails, use request.raw_post
valid = verify_emailit_signature(
raw_body,
request.env['HTTP_X_EMAILIT_SIGNATURE'],
request.env['HTTP_X_EMAILIT_TIMESTAMP']
)
halt 401, 'Invalid signature' unless valid
events = JSON.parse(raw_body)
# Queue the events for processing, then acknowledge.
status 204
endpackage main
import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"io"
"net/http"
"os"
"strconv"
"time"
)
const toleranceSeconds = 300
func verifyEmailitSignature(rawBody []byte, signature, timestamp, secret string) bool {
ts, err := strconv.ParseInt(timestamp, 10, 64)
if err != nil || signature == "" {
return false
}
age := time.Now().Unix() - ts
if age > toleranceSeconds || age < -toleranceSeconds {
return false
}
mac := hmac.New(sha256.New, []byte(secret))
mac.Write([]byte(timestamp + "."))
mac.Write(rawBody)
expected := hex.EncodeToString(mac.Sum(nil))
return hmac.Equal([]byte(expected), []byte(signature))
}
type Event struct {
EventID string `json:"event_id"`
Type string `json:"type"`
Data json.RawMessage `json:"data"`
}
func emailitWebhook(w http.ResponseWriter, r *http.Request) {
rawBody, err := io.ReadAll(r.Body)
if err != nil {
http.Error(w, "cannot read body", http.StatusBadRequest)
return
}
secret := os.Getenv("EMAILIT_WEBHOOK_SECRET") // full value, including whsec_
if !verifyEmailitSignature(rawBody, r.Header.Get("X-Emailit-Signature"), r.Header.Get("X-Emailit-Timestamp"), secret) {
http.Error(w, "invalid signature", http.StatusUnauthorized)
return
}
var events []Event
if err := json.Unmarshal(rawBody, &events); err != nil {
http.Error(w, "invalid JSON", http.StatusBadRequest)
return
}
// Queue the events for processing, then acknowledge.
w.WriteHeader(http.StatusNoContent)
}
func main() {
http.HandleFunc("/webhooks/emailit", emailitWebhook)
http.ListenAndServe(":8080", nil)
}<?php
// config/services.php: 'emailit' => ['webhook_secret' => env('EMAILIT_WEBHOOK_SECRET')],
// routes/api.php: Route::post('/webhooks/emailit', EmailitWebhookController::class);
namespace App\Http\Controllers;
use App\Jobs\ProcessEmailitEvent;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
class EmailitWebhookController extends Controller
{
private const TOLERANCE_SECONDS = 300;
public function __invoke(Request $request): Response
{
$rawBody = $request->getContent();
$signature = (string) $request->header('X-Emailit-Signature', '');
$timestamp = (string) $request->header('X-Emailit-Timestamp', '');
$secret = (string) config('services.emailit.webhook_secret'); // full value, including whsec_
$fresh = ctype_digit($timestamp)
&& abs(time() - (int) $timestamp) <= self::TOLERANCE_SECONDS;
$expected = hash_hmac('sha256', $timestamp.'.'.$rawBody, $secret);
abort_unless($fresh && hash_equals($expected, $signature), 401, 'Invalid signature');
foreach (json_decode($rawBody, true) as $event) {
ProcessEmailitEvent::dispatch($event); // a queued job
}
return response()->noContent();
}
}V Laravelu se u rout v routes/api.php nekontrolují tokeny CSRF a routy dostanou předponu /api, takže URL výše je https://acme.com/api/webhooks/emailit. Pokud routu zaregistrujete v routes/web.php, vyřaďte cestu z ověřování CSRF, jinak Laravel požadavky Emailitu odmítne s 419.
Otestujte ověřování
Před nasazením svou funkci vyzkoušejte na tomto známém příkladu. Zavolejte přímo část s HMAC, protože časové razítko je v minulosti a 5minutovou kontrolou neprojde.
| Vstup | Hodnota |
|---|---|
| Tajný klíč | whsec_4f2a9c1e7b3d5a8f0e6c2b9d4a7f1e3c5b8d0a2f6e9c3b7d1a5f8e2c4b6d9a0f |
| Časové razítko | 1790846045 |
| Očekávaný podpis | 7242b580f77ec488f8ac21b57f092e3db61a431694f13074c7b4c85a4dd04be3 |
Surové tělo požadavku přesně v této podobě, bez koncového znaku nového řádku:
[{"event_id":"evt_2xGk7Nq1VbD5sR8tLmW3eYhC6aP","type":"email.delivered"}]Pak u webhooku použijte Send test a ověřte, že nasazený endpoint přijme skutečný podepsaný požadavek. Viz Nastavení webhooku.
Časté chyby
- Zpracování před ověřením. Frameworky, které JSON zpracovávají automaticky (
express.json(),request.jsonve Flasku, params v Rails), vám dají objekt, ne bajty, které Emailit podepsal. Na routě webhooku čtěte surové tělo požadavku. - Opětovná serializace JSON.
JSON.stringify(req.body)nebojson.dumps(data)vytvoří jiné mezery, pořadí klíčů nebo escapování, takže HMAC nebude souhlasit. Vždy hashujte tělo přesně tak, jak přišlo. - Úprava tajného klíče. Použijte celou hodnotu včetně
whsec_jako prostý text. Neodstraňujte předponu a nedekódujte klíč z hexadecimálního zápisu ani z Base64. - Podepsání špatného řetězce. Vstupem je časové razítko, tečka a pak tělo. Pokud podepíšete jen tělo nebo části spojíte jiným oddělovačem, ověření selže.
- Porovnání pomocí
==. Použijte funkci, která porovnává v konstantním čase, napříkladcrypto.timingSafeEqual,hmac.compare_digest,hash_equals,Rack::Utils.secure_comparenebohmac.Equal. - Špatně nastavené hodiny serveru. Pokud se hodiny vašeho serveru rozcházejí, platné požadavky vypadnou z tolerance. Synchronizujte je přes NTP.
- Starý nebo nesprávný tajný klíč. Jakmile vyberete Reset, starý tajný klíč okamžitě přestane fungovat, a to i pro opakování. Každý webhook má vlastní tajný klíč, takže dva endpointy nemohou sdílet jeden.
- Middleware, který přepisuje tělo. Proxy, API brány nebo dekomprese těla, které změní bajty dřív, než je uvidí váš kód, podpis rozbijí. Ověřujte co nejblíž surovému požadavku.
Požadavky, které neprojdou ověřením, dostanou od vašeho endpointu 401 a opakují se jako každé jiné selhání, takže pokud špatně nastavený tajný klíč opravíte do několika hodin, o nic nepřijdete.