Open in
Provjerite potpise webhookova
Svaki webhook i zahtjev alata koji ThunderPhone šalje potpisan je vremenskom oznakom. Provjerite ga pomoću ovdje navedenog recepta ili pomoćne funkcije SDK-a i odbijte ponovljene zahtjeve starije od pet minuta.
Svaki zahtjev koji šaljemo na vaš poslužitelj (isporuke webhooka, naslijeđeni webhook s jednim URL-om i pozivi krajnje točke alata) sadrži dva zaglavlja potpisa. Oba koriste HMAC-SHA256 s istom tajnom.
| Zaglavlje | Potpisuje | Zaštita od ponavljanja |
|---|---|---|
X-ThunderPhone-Signature-V2 (preporučeno) | Vremenska oznaka, metoda, putanja, upit i tijelo | Odbacite zahtjeve starije od 5 minuta |
X-ThunderPhone-Signature | Samo tijelo | Nema: presretnuti zahtjev zauvijek prolazi provjeru |
Provjerite X-ThunderPhone-Signature-V2 u novim integracijama i premjestite
postojeće na njega. X-ThunderPhone-Signature i dalje se šalje uz svaki
zahtjev, neizmijenjen, pa provjeravatelji izgrađeni na njemu nastavljaju raditi. Objavit ćemo datum prije nego što ga prestanemo slati.
Potpis V2
X-ThunderPhone-Signature-V2: t=1767225600,v1=03a89bec521f78d55727237ca4713bb0b8dcdf2e09e450865b5b5637b80c1c4etje Unix vrijeme, u sekundama, kada smo poslali zahtjev. Ponovljena isporuka ponovno se potpisuje pri slanju, s vlastitimt.v1je heksadekadski HMAC-SHA256 potpisanog niza u nastavku, generiran pomoću vaše tajne.
Vrijednost je popis parova key=value odvojenih zarezima. Zanemarite ključeve
koje ne prepoznajete i prihvatite zahtjev ako se podudara bilo koji v1: možda
ćemo tijekom buduće promjene dodati drugi potpis.
Potpisani niz
Četiri polja, od kojih svako slijedi jedan novi redak (\n, bajt 0x0A), osim
posljednjeg:
<t>\n<METHOD>\n<path and query>\n<body>| Polje | Vrijednost |
|---|---|
t | Vrijednost t iz zaglavlja |
METHOD | HTTP metoda velikim slovima: POST, GET, PUT, PATCH ili DELETE |
| putanja i upit | Cilj zahtjeva iz retka HTTP zahtjeva: putanja, zatim ? i niz upita ako postoji. Bez sheme, hosta ili fragmenta. Upotrijebite ga točno kako je primljen, bez dekodiranja ili promjene redoslijeda parametara |
body | Neobrađeno tijelo zahtjeva. Pozivi alata GET i DELETE nemaju tijelo, pa njihov potpisani niz završava novim retkom nakon putanje |
Za provjeru:
- Prije bilo kakve obrade pročitajte zaglavlje, metodu, neobrađenu putanju i upit te neobrađeno tijelo.
- Iz zaglavlja uzmite
ti svakiv1. - Odbijte zahtjev ako je
tviše od 300 sekundi prije ili nakon vremena na vašem satu. - Izračunajte heksadekadski HMAC-SHA256 potpisanog niza pomoću svoje tajne.
- Usporedite ga s
v1u konstantnom vremenu. Odgovorite s401ako nema podudaranja.
Primjer
Tajna whsec_example_4c1f2a9b. Ovaj zahtjev šaljemo u
t = 1767225600 (2026-01-01 00:00:00 UTC):
POST /tools/lookup?region=us HTTP/1.1
Host: api.example.com
Content-Type: application/json
X-ThunderPhone-Signature-V2: t=1767225600,v1=03a89bec521f78d55727237ca4713bb0b8dcdf2e09e450865b5b5637b80c1c4e
{"customer_id":"cust_123"}Potpisani niz sastoji se od ova četiri retka, bez novog retka nakon tijela:
1767225600
POST
/tools/lookup?region=us
{"customer_id":"cust_123"}Njegov HMAC-SHA256 je
03a89bec521f78d55727237ca4713bb0b8dcdf2e09e450865b5b5637b80c1c4e.
Provjerite svoju implementaciju prema njemu ili ga reproducirajte pomoću openssl:
printf '1767225600\nPOST\n/tools/lookup?region=us\n{"customer_id":"cust_123"}' \
| openssl dgst -sha256 -hmac 'whsec_example_4c1f2a9b'Poziv alata GET na /tools/lookup?customer_id=cust_123 u istoj
sekundi ima prazno tijelo. Njegov potpisani niz je
1767225600\nGET\n/tools/lookup?customer_id=cust_123\n, a njegov je potpis
3ce214cb0b8f2bf39b1d19d4e7265df9dbaff5a5f838d3cc6c753a8f7a4ad280.
Sa SDK-ovima
Python i TypeScript SDK-ovi uključuju pomoćnu funkciju
(thunderphone 0.2.0 ili noviji, @thunderphone/sdk 0.2.0 ili noviji). Ona
provjerava vremenski prozor od 5 minuta, uspoređuje u konstantnom vremenu i
prihvaća popis tajni kako biste ih mogli rotirati bez prekida rada.
from thunderphone import verify_signature
ok = verify_signature(
header, # the X-ThunderPhone-Signature-V2 value
secret=SECRET, # or [NEW_SECRET, OLD_SECRET] while rotating
method=method, # "POST"
path=path, # "/tools/lookup?region=us", exactly as received
body=raw_body, # bytes, before any JSON parsing
tolerance=300, # optional; seconds
)import { verifySignature } from "@thunderphone/sdk";
const ok = verifySignature({
header, // the X-ThunderPhone-Signature-V2 value
secret: SECRET, // or [NEW_SECRET, OLD_SECRET] while rotating
method, // "POST"
path, // "/tools/lookup?region=us", exactly as received
body: rawBody, // Buffer or string, before any JSON parsing
toleranceSeconds: 300, // optional
});Bez SDK-a
import hashlib
import hmac
import time
TOLERANCE_SECONDS = 300
def verify_v2(header: str, secret: str, method: str, path: str, body: bytes) -> bool:
"""path is the path plus ?query exactly as received; body is the raw body."""
timestamp, signatures = None, []
for item in (header or "").split(","):
key, _, value = item.strip().partition("=")
if key == "t" and value.isdigit() and len(value) <= 15:
timestamp = int(value)
elif key == "v1":
signatures.append(value)
if timestamp is None or abs(time.time() - timestamp) > TOLERANCE_SECONDS:
return False
signed = f"{timestamp}\n{method.upper()}\n{path}\n".encode() + body
expected = hmac.new(secret.encode(), signed, hashlib.sha256).hexdigest()
return any(hmac.compare_digest(expected.encode(), s.encode()) for s in signatures)import crypto from "node:crypto";
const TOLERANCE_SECONDS = 300;
// path is the path plus ?query exactly as received; body is the raw Buffer.
export function verifyV2(header, secret, method, path, body) {
let timestamp;
const signatures = [];
for (const item of (header || "").split(",")) {
const [key, value = ""] = item.trim().split("=", 2);
if (key === "t" && /^\d+$/.test(value)) timestamp = Number(value);
else if (key === "v1") signatures.push(value);
}
if (timestamp === undefined) return false;
if (Math.abs(Date.now() / 1000 - timestamp) > TOLERANCE_SECONDS) return false;
const expected = Buffer.from(
crypto
.createHmac("sha256", secret)
.update(`${timestamp}\n${method.toUpperCase()}\n${path}\n`)
.update(body)
.digest("hex"),
);
return signatures.some((sig) => {
const candidate = Buffer.from(sig);
return (
candidate.length === expected.length &&
crypto.timingSafeEqual(candidate, expected)
);
});
}Povezivanje okvira
Svaki primjer čita neobrađenu putanju i upit te neobrađeno tijelo, a zatim poziva pomoćnu funkciju SDK-a.
import json
from fastapi import FastAPI, HTTPException, Request
from thunderphone import verify_signature
app = FastAPI()
def raw_path_and_query(request: Request) -> str:
path = (request.scope.get("raw_path") or request.url.path.encode()).decode("latin-1")
query = request.scope.get("query_string", b"").decode("latin-1")
return f"{path}?{query}" if query else path
@app.post("/thunderphone-webhook")
async def hook(request: Request):
body = await request.body() # raw bytes, NOT request.json()
if not verify_signature(
request.headers.get("X-ThunderPhone-Signature-V2"),
secret=SECRET,
method=request.method,
path=raw_path_and_query(request),
body=body,
):
raise HTTPException(status_code=401)
event = json.loads(body)
# … dispatch on event["type"] …
return {"ok": True}import express from "express";
import { verifySignature } from "@thunderphone/sdk";
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 ok = verifySignature({
header: req.header("X-ThunderPhone-Signature-V2"),
secret: process.env.WEBHOOK_SECRET,
method: req.method,
path: req.originalUrl, // path and query as received
body: req.body,
});
if (!ok) 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 HttpResponseForbidden, JsonResponse
from django.views.decorators.csrf import csrf_exempt
from django.views.decorators.http import require_POST
from thunderphone import verify_signature
@csrf_exempt
@require_POST
def hook(request):
if not verify_signature(
request.headers.get("X-ThunderPhone-Signature-V2"),
secret=SECRET,
method=request.method,
path=request.get_full_path(),
body=request.body, # raw bytes
):
return HttpResponseForbidden("invalid signature")
event = json.loads(request.body)
# … dispatch on event["type"] …
return JsonResponse({"ok": True})req.originalUrl (Express) te ASGI raw_path i query_string
(FastAPI) predstavljaju ciljni dio zahtjeva točno onako kako je primljen. Djangoov
get_full_path() i Flaskov request.path najprije dekodiraju putanju, što
daje isti niz za uobičajene putanje. Ako putanja vaše krajnje točke sadrži
znakove kodirane postotkom ili rezervirane znakove kao što su %2F, ; ili =, umjesto toga pročitajte
neobrađeni ciljni dio zahtjeva koji vaš poslužitelj pruža (na primjer request.META["RAW_URI"] na
Gunicornu).
Koja tajna?
Ista tajna potpisuje oba zaglavlja.
| Izvor | Tajna |
|---|---|
Krajnja točka web-dojavnika (/v1/developer/webhook-endpoints) | secret za pojedinu krajnju točku (48 heksadekadskih znakova) vraćen jednokratno pri stvaranju |
| Naslijeđeni web-dojavnik s jednim URL-om | secret za organizaciju vraćen pri GET /v1/webhook |
Poziv krajnje točke alata (izravni poziv vašeg endpoint.url) | Tajna web-dojavnika na razini organizacije (ista kao za naslijeđeni web-dojavnik s jednim URL-om) — ne tajna pojedine krajnje točke |
Pohranite tajnu u upravitelj tajni ili varijablu okruženja — nikada je nemojte predati u repozitorij.
Provjera poziva alata
Kada agent izravno pozove jedan od vaših
funkcijskih alata (alat ima endpoint), zahtjev uz
vaše konfigurirane endpoint.headers sadrži sljedeća ThunderPhone zaglavlja:
X-ThunderPhone-Call-ID— numerički ID aktivnog poziva.X-ThunderPhone-Signature-V2— gore navedeni potpis s vremenskom oznakom, povezan s vašom tajnom za webhook na razini organizacije.X-ThunderPhone-Signature— zastarjeli potpis koji obuhvaća samo tijelo, s istim ključem.
Dvije se stvari razlikuju od webhookova:
- Alati
GET/DELETEnemaju tijelo. Argumenti se prenose kao parametri upita. Potpis V2 obuhvaća ih putem putanje i upita, a polje tijela je prazno. Provjerite pomoću neobrađenog niza upita: raščlanjivanje parametara i izgradnja novog niza upita mogu promijeniti njihov redoslijed ili kodiranje. Zastarjelo zaglavlje potpisuje prazan niz bajtova. - Organizacije bez konfiguriranog zastarjelog webhoka nemaju tajnu organizacije. U
tom slučaju pozivi alata sadrže samo
X-ThunderPhone-Call-IDi nemaju zaglavlja potpisa. Konfigurirajte zastarjeli webhook (PUT /v1/webhook) da biste dobili tajnu za potpisivanje ili autentificirajte pozive alata vlastitim zaglavljem putemendpoint.headers.
@app.api_route("/tools/search-appointments", methods=["GET", "POST"])
async def tool(request: Request):
body = await request.body() # b"" for GET/DELETE tools
if not verify_signature(
request.headers.get("X-ThunderPhone-Signature-V2"),
secret=ORG_WEBHOOK_SECRET,
method=request.method,
path=raw_path_and_query(request),
body=body,
):
raise HTTPException(status_code=401)
call_id = request.headers.get("X-ThunderPhone-Call-ID", "")
args = json.loads(body) if body else dict(request.query_params)
...U Expressu parser tijela ostavlja req.body kao prazan objekt za GET,
stoga proslijedite Buffer.isBuffer(req.body) ? req.body : Buffer.alloc(0).
Slanje alata u webhook-načinu rada (alati bez endpoint, isporučeni
na webhook vaše organizacije kao telephony.tool / web.tool) običan je
potpisani webhook — primjenjuje se standardni postupak iznad. Pogledajte
Funkcijski alati za oba oblika zahtjeva.
Naslijeđeni potpis: X-ThunderPhone-Signature
Izvorno zaglavlje jest heksadekadski HMAC-SHA256 samo sirovog tijela, s istom tajnom. Ne sadržava vremensku oznaku, pa svatko tko presretne jedan potpisani zahtjev može ga ponovno poslati. I dalje se šalje uz svaki zahtjev dok se integracije ne prebace na V2.
- Pročitajte sirovo tijelo zahtjeva — točne bajtove koje smo vam poslali.
- Izračunajte
hmac_sha256(secret, body).hexdigest(). - Usporedite u konstantnom vremenu s
X-ThunderPhone-Signature.
Potpisujemo točno bajtove koje prenosimo. Ti su bajtovi ujedno i
kanonska JSON serijalizacija sadržaja — ključevi poredani
abecednim redom, sažeti razdjelnici (, i : bez razmaka), UTF-8.
Ako vaš okvir izlaže samo parsirani JSON, ponovno ga serijalizirajte kanonski 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")Preferirajte sirovo tijelo — to je jedan korak manje i otporno je na posebnosti ponovnog pretvaranja JSON brojeva u nekim jezicima. Isto vrijedi za polje tijela u V2.
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)
endUobičajene zamke
Odstupanje sata
Prozor od 5 minuta mjeri se prema satu vašeg poslužitelja. Održavajte ga sinkroniziranim s NTP-om; sat koji odstupa više od 5 minuta odbacuje svaki zahtjev.
Proxy ponovno zapisuje putanju
Potpisana putanja je ona koju smo zatražili: putanja URL-a koji ste konfigurirali u ThunderPhoneu. Ako obrnuti proxy ili API pristupnik ukloni ili doda prefiks prije nego što vaša aplikacija vidi zahtjev, ponovno izgradite putanju koju smo poslali (na primjer, vratite prefiks) prije provjere.
Ponovno serijaliziranje sa zadanim oblikovanjem
Raščlanjivanje tijela i njegovo ponovno serijaliziranje sa zadanim
postavkama vaše JSON biblioteke (razmaci nakon , / :, ključevi prema
redoslijedu umetanja) proizvodi različite bajtove i narušava HMAC. Provjerite
neobrađeno tijelo — ili, ako ga morate ponovno serijalizirati, točno uskladite
naš kanonski oblik: sortirani ključevi, kompaktni razdjelnici, UTF-8.
Okvir automatski raščlanjuje JSON
Expressov međuprogram express.json() troši tok tijela
i gubite neobrađene bajtove. Upotrijebite express.raw() posebno na webhook
ruti ili spremite neobrađeno tijelo u međuprogramu koji se izvršava ranije.
Isto vrijedi za NestJS / Koa — provjerite njihovu dokumentaciju za „neobrađeno tijelo”.
Usporedba nesigurna na vremenske napade
expected === signature u JS-u ili expected == signature u
Pythonu usporedbe su s promjenjivim vremenom izvršavanja. Upotrijebite crypto.timingSafeEqual
odnosno hmac.compare_digest. Razlika u performansama
je zanemariva.
Pogrešna tajna za krajnje točke alata
Izravni pozivi krajnjih točaka alata potpisuju se tajnom webhooka na
razini organizacije (GET /v1/webhook) — ne tajnom za pojedinu krajnju točku
iz /v1/developer/webhook-endpoints. Ponovno upotrijebite isti pomoćnik,
ali provjerite prosljeđujete li mu tajnu organizacije na rutama alata.
Nizovi upita na GET/DELETE alatima
V2 potpisuje niz upita točno kako je poslan. Naslijeđeno zaglavlje potpisuje prazno tijelo, a nikada niz upita, stoga haširanje URL-a za naslijeđeno zaglavlje nikada neće odgovarati.
Duplikati unutar prozora
Vremenska oznaka sprječava ponavljanja starija od 5 minuta, ali ne i drugu kopiju
unutar tog prozora. Isporuke krajnjim točkama odvijaju se najmanje jednom, pa može
stići i ponovni pokušaj istog događaja; uklonite duplikate prema event_id
omotnice.
Nevraćanje odgovora 401 pri nepodudaranju
Vraćanje odgovora 200 nakon neuspjele provjere čini rukovatelj metom ponavljanja zahtjeva. Uvijek odgovorite statusom koji nije 2xx ako provjera ne uspije.