Provjerite potpise webhookova
Svaki zahtjev koji šaljemo vašem poslužitelju — isporuke webhookova i
pozivi krajnjih točaka alata — sadrži HMAC-SHA256 potpis u zaglavlju
X-ThunderPhone-Signature. Jednom ispravno implementirajte provjeru i
upotrijebite isti pomoćni program u svakom obrađivaču.
Algoritam
- Pročitajte sirovo tijelo zahtjeva — točne bajtove koje smo vam poslali metodom POST.
- Izračunajte
hmac_sha256(secret, body).hexdigest(). - Usporedite u konstantnom vremenu sa zaglavljem
X-ThunderPhone-Signature. (Naivna usporedba nizova otkriva informacije o vremenu.)
Potpisujemo točno one bajtove koje prenosimo, stoga provjera sirovog tijela
uvijek funkcionira. Ti su bajtovi također kanonska JSON serijalizacija
sadržaja — ključevi poredani abecedno, sažeti razdjelnici
(, i : bez razmaka), UTF-8. To vam pruža drugi, potpuno
ekvivalentan postupak kada vaš okvir prikazuje samo parsirani JSON:
ponovno ga kanonski serijalizirajte i izračunajte HMAC nad njime.
# Equivalent to hashing the raw body:
import json
canonical = json.dumps(payload, separators=(",", ":"), sort_keys=True).encode("utf-8")
Prednost dajte sirovom tijelu — to je jedan korak manje i nije podložno posebnostima ponovnog pretvaranja JSON brojeva u nekim jezicima.
Koja tajna?
| Izvor | Tajna |
|---|---|
Krajnja točka webhooka (/v1/developer/webhook-endpoints) | secret za pojedinu krajnju točku (48 heksadecimalnih znakova), vraćen jednom pri stvaranju |
| Naslijeđeni webhook s jednim URL-om | secret za organizaciju, vraćen na GET /v1/webhook |
Poziv krajnje točke alata (izravni poziv vašeg endpoint.url) | Tajna webhooka na razini organizacije (ista kao za naslijeđeni webhook s jednim URL-om) — ne tajna za pojedinu krajnju točku |
Spremite tajnu u upravitelj tajni ili varijablu okruženja — nikada je nemojte uključiti u repozitorij.
Referentne implementacije
Sve četiri provjeravaju sirovo tijelo zahtjeva:
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 okvir
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})
Provjera poziva alata
Kada agent izravno pozove jedan od vaših
funkcijskih alata (alat ima
endpoint), zahtjev uz vaš konfigurirani endpoint.headers nosi
dva ThunderPhone zaglavlja:
X-ThunderPhone-Call-ID— numerički ID aktivnog poziva.X-ThunderPhone-Signature— HMAC-SHA256, s ključem vaše tajne webhooka na razini organizacije, nad točnim bajtovima tijela zahtjeva.
Isti pomoćnik verify() radi nepromijenjeno, uz dvije pojedinosti:
- Alati
GET/DELETEnemaju tijelo. Argumenti se prenose 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 hashirati niz upita. - Organizacije bez konfiguriranog naslijeđenog webhooka nemaju tajnu organizacije. U
tom slučaju pozivi alata nose samo
X-ThunderPhone-Call-IDi nemaju zaglavlje potpisa. Konfigurirajte naslijeđeni webhook (PUT /v1/webhook) da biste dobili tajnu za potpisivanje ili autentificirajte pozive alata vlastitim 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)
...
Otprema alata u načinu webhooka (alati bez endpoint, isporučeni
na webhook vaše organizacije kao telephony.tool / web.tool) običan je
potpisani webhook — primjenjuje se gore navedeni standardni postupak. Pogledajte
Funkcijski alati za oba oblika zahtjeva.
Uobičajene zamke
Ponovna serijalizacija sa zadanim oblikovanjem
Raščlanjivanje tijela i njegovo ponovno ispisivanje uz zadane
postavke vaše JSON biblioteke (razmaci nakon , / :, ključevi prema redoslijedu umetanja) stvara
različite bajtove i prekida HMAC. Provjerite sirovo tijelo — ili, ako ga
morate ponovno serijalizirati, točno uskladite naš kanonski oblik: sortirani
ključevi, sažeti razdjelnici, UTF-8.
Okvir automatski raščlanjuje JSON
Expressov posrednički softver express.json() troši tok tijela
i gubite sirove bajtove. Upotrijebite express.raw() posebno na webhook
ruti ili spremite sirovo tijelo u međuposredničkom softveru.
Isto vrijedi za NestJS / Koa — provjerite njihovu dokumentaciju za „sirovo tijelo”.
Usporedba nesigurna na vremenske napade
expected === signature u JS-u ili expected == signature u
Pythonu usporedbe su s promjenjivim vremenom izvođenja. Upotrijebite crypto.timingSafeEqual
odnosno hmac.compare_digest. Razlika u izvedbi
zanemariva je.
Pogrešna tajna za krajnje točke alata
Izravni pozivi krajnjih točaka alata potpisuju se webhook tajnom
na razini organizacije (GET /v1/webhook) — ne tajnom pojedine krajnje točke
iz /v1/developer/webhook-endpoints. Ponovno upotrijebite istu funkciju verify(),
ali provjerite prosljeđujete li joj tajnu organizacije na rutama alata.
Sažimanje niza upita za GET/DELETE alate
Za metode alata bez tijela potpis obuhvaća prazan niz bajtova, čime se zadržava jedan univerzalan postupak: HMAC sirovog tijela zahtjeva, kakvo god ono bilo. Sažimanje URL-a ili niza upita nikada se neće podudarati.
Nevraćanje 401 pri nepodudaranju
Vraćanje 200 pri neuspjeloj provjeri čini rukovatelj metom za ponavljanje zahtjeva. Uvijek odgovorite statusom koji nije 2xx ako provjera ne uspije.