Проверка на подписите на webhook заявки
Всяка заявка, която изпращаме до вашия сървър — доставките на webhook и извикванията на крайни точки за инструменти — съдържа HMAC-SHA256 подпис в заглавката X-ThunderPhone-Signature. Настройте проверката правилно веднъж и използвайте един и същ помощен метод във всеки обработчик.
Алгоритъмът
- Прочетете суровото тяло на заявката — точните байтове, които сме ви изпратили чрез POST.
- Изчислете
hmac_sha256(secret, body).hexdigest(). - Сравнете за константно време с
X-ThunderPhone-Signature. (Наивното сравнение на низове разкрива информация за времето.)
Подписваме точно байтовете, които предаваме, така че проверката на суровото тяло
винаги работи. Тези байтове са също и каноничната JSON сериализация
на полезния товар — ключове, сортирани по азбучен ред, компактни разделители
(, и : без интервали), UTF-8. Това ви дава втори, напълно
еквивалентен подход, когато фреймуъркът ви предоставя само анализиран JSON:
сериализирайте отново канонично и изчислете HMAC върху него.
# Equivalent to hashing the raw body:
import json
canonical = json.dumps(payload, separators=(",", ":"), sort_keys=True).encode("utf-8")
Предпочитайте суровото тяло — то е с една стъпка по-малко и не е засегнато от особеностите при повторното преобразуване на JSON числа в някои езици.
Коя тайна?
| Източник | Тайна |
|---|---|
Webhook крайна точка (/v1/developer/webhook-endpoints) | secret за конкретната крайна точка (48 шестнадесетични знака), върната еднократно при създаване |
| Стар webhook с един URL | secret за конкретната организация, върната при GET /v1/webhook |
Извикване на крайна точка за инструмент (директно извикване на вашия endpoint.url) | Webhook тайната на ниво организация (същата като за стария webhook с един URL) — не тайна за конкретна крайна точка |
Съхранявайте тайната във вашия мениджър на тайни или в променлива на средата — никога не я записвайте в хранилището.
Референтни имплементации
И четирите проверяват суровото тяло на заявката:
import hashlib
import hmac
def verify(body: bytes, signature: str, secret: str) -> bool:
"""Constant-time HMAC-SHA256 verification."""
expected = hmac.new(
secret.encode("utf-8"),
body,
hashlib.sha256,
).hexdigest()
return hmac.compare_digest(expected, signature or "")
import crypto from "node:crypto";
export function verify(body, signature, secret) {
const expected = crypto
.createHmac("sha256", secret)
.update(body)
.digest("hex");
if (!signature || expected.length !== signature.length) return false;
return crypto.timingSafeEqual(
Buffer.from(expected),
Buffer.from(signature),
);
}
package webhook
import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
)
func Verify(body []byte, signature, secret string) bool {
mac := hmac.New(sha256.New, []byte(secret))
mac.Write(body)
expected := hex.EncodeToString(mac.Sum(nil))
return hmac.Equal([]byte(expected), []byte(signature))
}
require "openssl"
def verify(body, signature, secret)
expected = OpenSSL::HMAC.hexdigest("SHA256", secret, body)
Rack::Utils.secure_compare(expected, signature.to_s)
end
Свързване според фреймуърка
from fastapi import FastAPI, HTTPException, Request
app = FastAPI()
@app.post("/thunderphone-webhook")
async def hook(request: Request):
body = await request.body() # raw bytes, NOT request.json()
sig = request.headers.get("X-ThunderPhone-Signature", "")
if not verify(body, sig, SECRET):
raise HTTPException(status_code=401)
import json
event = json.loads(body)
# … dispatch on event["type"] …
return {"ok": True}
import express from "express";
const app = express();
app.post(
"/thunderphone-webhook",
// IMPORTANT: parse as raw; do NOT use express.json() here.
express.raw({ type: "application/json" }),
(req, res) => {
const sig = req.header("X-ThunderPhone-Signature") || "";
if (!verify(req.body, sig, process.env.WEBHOOK_SECRET)) {
return res.sendStatus(401);
}
const event = JSON.parse(req.body.toString("utf8"));
// … dispatch on event.type …
res.sendStatus(204);
},
);
import json
from django.http import JsonResponse, HttpResponseForbidden
from django.views.decorators.csrf import csrf_exempt
from django.views.decorators.http import require_POST
@csrf_exempt
@require_POST
def hook(request):
body = request.body # raw bytes
sig = request.headers.get("X-ThunderPhone-Signature", "")
if not verify(body, sig, SECRET):
return HttpResponseForbidden("invalid signature")
event = json.loads(body)
# … dispatch on event["type"] …
return JsonResponse({"ok": True})
Проверяване на извиквания на инструменти
Когато агентът извика директно някой от Вашите
функционални инструменти (инструментът има
endpoint), заявката включва два заглавни реда на ThunderPhone наред
с конфигурираните от Вас endpoint.headers:
X-ThunderPhone-Call-ID— числовият идентификатор на текущото обаждане.X-ThunderPhone-Signature— HMAC-SHA256, с ключ Вашата тайна за уебхукове на ниво организация, върху точните байтове на тялото на заявката.
Същият помощен метод verify() работи без промяна, с две особености:
- Инструментите
GET/DELETEнямат тяло. Аргументите се предават като параметри на заявката, а подписът се изчислява върху празния байтов низ — следователноverify(b"", sig, secret)(Python) илиverify(Buffer.alloc(0), sig, secret)(Node). Не хеширайте низа на заявката. - Организациите без конфигуриран наследен уебхук нямат тайна на организацията. В този случай извикванията на инструменти съдържат само
X-ThunderPhone-Call-IDи нямат заглавен ред за подпис. Конфигурирайте наследения уебхук (PUT /v1/webhook), за да получите тайна за подписване, или удостоверявайте извикванията на инструменти със собствен заглавен ред чрезendpoint.headers.
@app.post("/tools/search-appointments")
async def tool(request: Request):
body = await request.body() # b"" for GET/DELETE tools
sig = request.headers.get("X-ThunderPhone-Signature", "")
call_id = request.headers.get("X-ThunderPhone-Call-ID", "")
if not verify(body, sig, ORG_WEBHOOK_SECRET):
raise HTTPException(status_code=401)
args = json.loads(body)
...
Диспечирането на инструменти в режим на уебхук (инструменти без endpoint, доставяни
до уебхука на Вашата организация като telephony.tool / web.tool) е обикновен
подписан уебхук — прилага се стандартната процедура по-горе. Вижте
Функционални инструменти за двата формата на заявки.
Често срещани грешки
Повторна сериализация с форматиране по подразбиране
Анализирането на тялото и повторното му сериализиране с настройките
по подразбиране на вашата JSON библиотека (интервали след , / :, ключове
в реда на вмъкване) води до различни байтове и нарушава HMAC. Проверявайте необработеното тяло — или, ако трябва да го сериализирате отново, съответствайте точно на нашата канонична форма: сортирани
ключове, компактни разделители, UTF-8.
Framework автоматично анализира JSON
Междинният софтуер express.json() на Express консумира потока на тялото
и губите необработените байтове. Използвайте express.raw() конкретно за маршрута на webhook-а
или буферирайте необработеното тяло в предварителен междинен софтуер.
Същото важи за NestJS / Koa — вижте документацията им за „необработено тяло“.
Сравнение, незащитено срещу атаки по времето
expected === signature в JS или expected == signature в
Python са сравнения с променливо време. Използвайте crypto.timingSafeEqual
или съответно hmac.compare_digest. Разликата в производителността
е нулева.
Грешна тайна за крайни точки на инструменти
Директните извиквания към крайни точки на инструменти се подписват с тайната за webhook на ниво организация
(GET /v1/webhook) — не с тайна за отделна крайна точка
от /v1/developer/webhook-endpoints. Използвайте повторно същата функция verify(),
но се уверете, че подавате тайната на организацията за маршрутите на инструменти.
Хеширане на низа на заявката при GET/DELETE инструменти
При методи на инструменти без тяло подписът обхваща празния байтов низ, като се запазва една универсална схема: изчислявайте HMAC върху необработеното тяло на заявката, каквото и да е то. Хеширането на URL адреса или низа на заявката никога няма да съвпадне.
Невръщане на 401 при несъответствие
Връщането на 200 при неуспешна проверка превръща обработчика в цел за повторно изпращане. Винаги отговаряйте с код извън 2xx, ако проверката е неуспешна.
Следващи стъпки
Семантика на доставката, повторни опити, IP адреси на източника.
Управлявайте множество URL адреси, сменяйте тайни.
Двата начина за извикване на инструменти и формите на техните заявки.
Създайте цялостна интеграция с инструмент от край до край.