Preverite podpise webhookov
Vsaka zahteva, ki jo pošljemo vašemu strežniku — dostave webhookov in
klici končnih točk orodij — vsebuje podpis HMAC-SHA256 v glavi
X-ThunderPhone-Signature. Preverjanje pravilno nastavite enkrat in
istega pomočnika vključite v vsak obdelovalnik.
Algoritem
- Preberite surovo telo zahteve — natančne bajte, ki smo jih poslali z metodo POST.
- Izračunajte
hmac_sha256(secret, body).hexdigest(). - V konstantnem času primerjajte z
X-ThunderPhone-Signature. (Naivna primerjava nizov razkriva časovne informacije.)
Podpišemo natanko bajte, ki jih prenesemo, zato preverjanje surovega telesa
vedno deluje. Ti bajti so tudi kanonična serializacija JSON
koristnega tovora — ključi so razvrščeni po abecedi, ločila so strnjena
(, in : brez presledkov), kodiranje je UTF-8. To vam ponuja drugi,
popolnoma enakovreden postopek, kadar vaše ogrodje izpostavlja samo razčlenjeni JSON:
znova ga kanonično serializirajte in nad njim izračunajte HMAC.
# Equivalent to hashing the raw body:
import json
canonical = json.dumps(payload, separators=(",", ":"), sort_keys=True).encode("utf-8")
Prednost dajte surovemu telesu — to je en korak manj in je odporno na posebnosti ponovnega pretvarjanja števil JSON v nekaterih jezikih.
Katera skrivnost?
| Vir | Skrivnost |
|---|---|
Končna točka webhooka (/v1/developer/webhook-endpoints) | secret za posamezno končno točko (48 šestnajstiških znakov), vrnjen enkrat ob ustvarjanju |
| Podedovani webhook z enim URL-jem | secret za posamezno organizacijo, vrnjen pri GET /v1/webhook |
Klic končne točke orodja (neposredni klic vašega endpoint.url) | Skrivnost webhooka na ravni organizacije (ista kot za podedovani webhook z enim URL-jem) — ne skrivnost za posamezno končno točko |
Skrivnost shranite v upravitelju skrivnosti ali spremenljivki okolja — nikoli je ne potrdite v repozitorij.
Referenčne implementacije
Vse štiri preverjajo surovo telo zahteve:
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
Povezovanje, specifično za ogrodje
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})
Preverjanje klicev orodij
Ko agent neposredno prikliče eno od vaših
funkcijskih orodij (orodje ima
endpoint), zahteva poleg vaših konfiguriranih endpoint.headers
vsebuje dve glavi ThunderPhone:
X-ThunderPhone-Call-ID— številčni ID aktivnega klica.X-ThunderPhone-Signature— HMAC-SHA256, podpisan z vašo skrivnostjo webhooka na ravni organizacije, prek natančnih bajtov telesa zahteve.
Isti pomočnik verify() deluje nespremenjeno, z dvema posebnostma:
- Orodja
GET/DELETEnimajo telesa. Argumenti se posredujejo kot parametri poizvedbe, podpis pa se izračuna prek praznega bajtnega niza — zato uporabiteverify(b"", sig, secret)(Python) aliverify(Buffer.alloc(0), sig, secret)(Node). Niza poizvedbe ne zgoščujte. - Organizacije brez konfiguriranega starejšega webhooka nimajo skrivnosti
organizacije. V tem primeru klici orodij vsebujejo samo
X-ThunderPhone-Call-IDin nobene glave podpisa. Konfigurirajte starejši webhook (PUT /v1/webhook), da pridobite skrivnost za podpisovanje, ali klice orodij avtenticirajte z lastno glavo prekendpoint.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)
...
Odpošiljanje orodij v načinu webhook (orodja brez endpoint, dostavljena
v webhook vaše organizacije kot telephony.tool / web.tool) je običajen
podpisan webhook — uporabite zgornji standardni postopek. Za obe obliki
zahtev glejte Funkcijska orodja.
Pogoste pasti
Ponovna serializacija s privzetim oblikovanjem
Razčlenitev telesa in njegov ponovni izpis s privzetimi nastavitvami
vaše knjižnice JSON (presledki za , / :, ključi v vrstnem redu vstavljanja) ustvari
drugačne bajte in prekine HMAC. Preverite neobdelano telo — ali pa, če ga
morate ponovno serializirati, natančno upoštevajte naš kanonični zapis: razvrščeni
ključi, strnjena ločila, UTF-8.
Ogrodje samodejno razčleni JSON
Vmesna programska oprema express.json() v Expressu porabi tok telesa
in izgubite neobdelane bajte. Na poti webhooka uporabite express.raw(),
ali pa neobdelano telo shranite v medpomnilnik v predhodni vmesni programski opremi.
Enako velja za NestJS / Koa — preverite njuno dokumentacijo za »raw body«.
Primerjava, ki ni varna glede časa
expected === signature v JS ali expected == signature v
Pythonu sta primerjavi s spremenljivim časom izvajanja. Uporabite crypto.timingSafeEqual
oziroma hmac.compare_digest. Razlike v zmogljivosti
ni.
Napačna skrivnost za končne točke orodij
Neposredni klici končnih točk orodij so podpisani s skrivnostjo webhooka
na ravni organizacije (GET /v1/webhook) — ne s skrivnostjo posamezne končne točke
iz /v1/developer/webhook-endpoints. Ponovno uporabite isto funkcijo verify(),
vendar se prepričajte, da ji na poteh orodij posredujete skrivnost organizacije.
Zgoščevanje poizvedbenega niza pri orodjih GET/DELETE
Pri metodah orodij brez telesa podpis zajema prazen bajtni niz, kar ohranja enoten postopek: uporabite HMAC za neobdelano telo zahteve, ne glede na to, kakšno je. Zgoščevanje URL-ja ali poizvedbenega niza se nikoli ne bo ujemalo.
Ne vračate 401 ob neujemanja
Vračanje 200 ob neuspešnem preverjanju naredi obdelovalnik tarčo za ponavljanje zahtev. Če preverjanje ne uspe, vedno odgovorite z vrednostjo, ki ni 2xx.