Проверите потписе веб-хукова
Сваки захтев који шаљемо Вашем серверу — испоруке 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
Povezivanje specifično za okvire
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})
Verifikacija poziva alata
Kada агент direktno pozove jedan od Ваших
funkcijskih alata (alat ima
endpoint), zahtev uz Vaše konfigurisane endpoint.headers sadrži
dva ThunderPhone zaglavlja:
X-ThunderPhone-Call-ID— numerički ID aktivnog poziva.X-ThunderPhone-Signature— HMAC-SHA256, sa ključem Vaše tajne za webhook na nivou organizacije, izračunat nad tačnim bajtovima tela zahteva.
Isti pomoćnik verify() radi neizmenjeno, uz dve razlike:
GET/DELETEalati nemaju telo. Argumenti se prosleđuju kao parametri upita, a potpis se izračunava nad praznim nizom bajtova — dakleverify(b"", sig, secret)(Python) iliverify(Buffer.alloc(0), sig, secret)(Node). Nemojte heširati string upita.- Organizacije bez konfigurisanog nasleđenog webhooka nemaju tajnu organizacije. U
tom slučaju pozivi alata sadrže samo
X-ThunderPhone-Call-ID, bez zaglavlja potpisa. Konfigurišite nasleđeni webhook (PUT /v1/webhook) da biste dobili tajnu za potpisivanje ili autentifikujte pozive alata sopstvenim zaglavljem putemendpoint.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)
...
Prosleđivanje poziva alata u režimu webhooka (alati bez endpoint,
isporučeni webhooku Vaše organizacije kao telephony.tool / web.tool) jeste
običan potpisani webhook — primenjuje se standardni postupak iznad. Pogledajte
Funkcijski alati za oba oblika zahteva.
Уобичајене замке
Поновна серијализација са подразумеваним форматирањем
Парсирање тела и његово поновно исписивање са подразумеваним
подешавањима ваше JSON библиотеке (размаци после , / :, кључеви у редоследу уметања) даје
различите бајтове и онемогућава HMAC. Проверите сирово тело — или, ако
морате поново да га серијализујете, тачно ускладите наш канонски облик: сортирани
кључеви, компактни раздвајачи, UTF-8.
Оквир аутоматски парсира JSON
Express-ов express.json() посреднички софтвер троши ток тела
и губите сирове бајтове. Користите 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 ако верификација не успе.