Open in
Pārbaudiet tīmekļa aizķeru parakstus
Katrs ThunderPhone nosūtītais tīmekļa aizķeres un rīka pieprasījums tiek parakstīts ar laikspiedolu. Pārbaudiet to, izmantojot šeit sniegto recepti vai SDK palīgfunkciju, un noraidiet atkārtoti nosūtītus pieprasījumus, kas ir vecāki par piecām minūtēm.
Katram pieprasījumam, ko nosūtām uz jūsu serveri (tīmekļa aizķeru piegādēm, mantotā viena URL tīmekļa aizķera un rīku galapunktu izsaukumiem), ir divas paraksta galvenes. Abas izmanto HMAC-SHA256 ar vienu un to pašu noslēpumu.
| Galvene | Paraksta | Aizsardzība pret atkārtojumu |
|---|---|---|
X-ThunderPhone-Signature-V2 (ieteicams) | Laikspiedolu, metodi, ceļu, vaicājumu un pamattekstu | Noraidiet pieprasījumus, kas ir vecāki par 5 minūtēm |
X-ThunderPhone-Signature | Tikai pamattekstu | Nav: pārtverts pieprasījums tiek verificēts bez termiņa |
Jaunās integrācijās verificējiet X-ThunderPhone-Signature-V2 un pārvietojiet
uz to esošās integrācijas. X-ThunderPhone-Signature joprojām tiek nosūtīts ar
katru pieprasījumu bez izmaiņām, tāpēc uz tā balstītie verificētāji turpina
darboties. Pirms pārtrauksim to sūtīt, paziņosim datumu.
Paraksts V2
X-ThunderPhone-Signature-V2: t=1767225600,v1=03a89bec521f78d55727237ca4713bb0b8dcdf2e09e450865b5b5637b80c1c4etir Unix laiks sekundēs, kad nosūtījām pieprasījumu. Atkārtoti nosūtīta piegāde tiek parakstīta vēlreiz tās nosūtīšanas brīdī ar savut.v1ir tālāk norādītās parakstītās virknes heksadecimālais HMAC-SHA256, izmantojot jūsu slepeno atslēgu.
Vērtība ir ar komatiem atdalīts key=value pāru saraksts. Ignorējiet atslēgas,
kuras neatpazīstat, un pieņemiet pieprasījumu, ja atbilst jebkurš v1: turpmāku
izmaiņu laikā varam pievienot otru parakstu.
Parakstītā virkne
Četri lauki, katram seko viena jauna rinda (\n, baits 0x0A), izņemot
pēdējo:
<t>\n<METHOD>\n<path and query>\n<body>| Lauks | Vērtība |
|---|---|
t | t vērtība no galvenes |
METHOD | HTTP metode ar lielajiem burtiem: POST, GET, PUT, PATCH vai DELETE |
| path and query | Pieprasījuma mērķis no HTTP pieprasījuma rindas: ceļš, pēc tam ? un vaicājuma virkne, ja tāda ir. Bez shēmas, resursdatora vai fragmenta. Izmantojiet to tieši tā, kā tas tika saņemts, to neatkodējot un nemainot parametru secību |
body | Neapstrādāts pieprasījuma pamatteksts. GET un DELETE rīka izsaukumiem nav pamatteksta, tādēļ to parakstītā virkne beidzas ar jauno rindu pēc ceļa |
Lai verificētu:
- Pirms jebkādas parsēšanas nolasiet galveni, metodi, neapstrādāto ceļu un vaicājumu, kā arī neapstrādāto pamattekstu.
- No galvenes iegūstiet
tun katruv1. - Noraidiet pieprasījumu, ja
tir vairāk nekā 300 sekundes pirms vai pēc jūsu pulksteņa laika. - Aprēķiniet parakstītās virknes heksadecimālo HMAC-SHA256, izmantojot savu slepeno atslēgu.
- Salīdziniet to ar
v1konstantā laikā. Atbildiet ar401, ja nekas neatbilst.
Piemērs
Slepenā atslēga whsec_example_4c1f2a9b. Mēs nosūtām šo pieprasījumu
laikā 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"}Parakstītā virkne ir šīs četras rindas bez jaunas rindas pēc pamatteksta:
1767225600
POST
/tools/lookup?region=us
{"customer_id":"cust_123"}Tās HMAC-SHA256 ir
03a89bec521f78d55727237ca4713bb0b8dcdf2e09e450865b5b5637b80c1c4e.
Pārbaudiet savu implementāciju ar to vai atveidojiet to, izmantojot openssl:
printf '1767225600\nPOST\n/tools/lookup?region=us\n{"customer_id":"cust_123"}' \
| openssl dgst -sha256 -hmac 'whsec_example_4c1f2a9b'GET rīka izsaukumam uz /tools/lookup?customer_id=cust_123 tajā pašā
sekundē ir tukšs pamatteksts. Tā parakstītā virkne ir
1767225600\nGET\n/tools/lookup?customer_id=cust_123\n, un tās paraksts
ir 3ce214cb0b8f2bf39b1d19d4e7265df9dbaff5a5f838d3cc6c753a8f7a4ad280.
Ar SDK
Python un TypeScript SDK ietver palīgfunkciju
(thunderphone 0.2.0 vai jaunāka versija, @thunderphone/sdk 0.2.0 vai
jaunāka versija). Tā pārbauda 5 minūšu logu, salīdzina konstantā laikā un
pieņem slepeno atslēgu sarakstu, lai jūs varētu tās nomainīt bez dīkstāves.
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
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)
);
});
}Sistēmas pieslēgšana
Katrs piemērs nolasa neapstrādāto ceļu un vaicājumu, kā arī neapstrādāto pamattekstu, pēc tam izsauc SDK palīgfunkciju.
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) un ASGI raw_path un query_string
(FastAPI) ir mērķis tieši tāds, kā saņemts. Django
get_full_path() un Flask request.path vispirms atkodē ceļu, kas
parastiem ceļiem dod to pašu virkni. Ja jūsu galapunkta ceļā ir procentu kodētas vai rezervētas rakstzīmes, piemēram, %2F, ; vai =, tā vietā nolasiet
servera nodrošināto neapstrādāto mērķi (piemēram, request.META["RAW_URI"] sistēmā
Gunicorn).
Kurš noslēpums?
Tas pats noslēpums paraksta abas galvenes.
| Avots | Noslēpums |
|---|---|
Tīmekļa aizķeres galapunkts (/v1/developer/webhook-endpoints) | Katram galapunktam paredzēts secret (48 heksadecimālās rakstzīmes), kas tiek atgriezts vienreiz izveides laikā |
| Mantotā tīmekļa aizķere ar vienu URL | Organizācijai paredzēts secret, kas tiek atgriezts ar GET /v1/webhook |
Rīka galapunkta izsaukums (tiešs izsaukums uz jūsu endpoint.url) | Organizācijas līmeņa tīmekļa aizķeres noslēpums (tas pats, kas mantotajai tīmekļa aizķerei ar vienu URL) — nevis katram galapunktam paredzēts noslēpums |
Glabājiet noslēpumu savu noslēpumu pārvaldniekā vai vides mainīgajā — nekad to neiekļaujiet repozitorijā.
Rīku izsaukumu verificēšana
Kad balss aģents tieši izsauc kādu no jūsu
funkciju rīkiem (rīkam ir
endpoint), pieprasījumā kopā ar jūsu konfigurētajiem endpoint.headers
tiek iekļautas šādas ThunderPhone galvenes:
X-ThunderPhone-Call-ID— aktīvā zvana skaitliskais ID.X-ThunderPhone-Signature-V2— iepriekš norādītais paraksts ar laika zīmogu, kas veidots, izmantojot jūsu organizācijas līmeņa webhook noslēpumu.X-ThunderPhone-Signature— mantotais paraksts, kas attiecas tikai uz pamattekstu, ar to pašu atslēgu.
No webhook atšķiras divas lietas:
GET/DELETErīkiem nav pamatteksta. Argumenti tiek nodoti kā vaicājuma parametri. V2 paraksts tos aptver, izmantojot ceļu un vaicājumu, bet pamatteksta lauks ir tukšs. Verificējiet, izmantojot neapstrādāto vaicājuma virkni: parametru parsēšana un jaunas vaicājuma virknes izveide var mainīt to secību vai kodējumu. Mantotā galvene paraksta tukšu baitu virkni.- Organizācijām bez konfigurēta mantotā webhook nav organizācijas noslēpuma. Šādā
gadījumā rīku izsaukumos ir tikai
X-ThunderPhone-Call-IDun nav paraksta galveņu. Konfigurējiet mantoto webhook (PUT /v1/webhook), lai iegūtu parakstīšanas noslēpumu, vai autentificējiet rīku izsaukumus ar savu galveni, izmantojotendpoint.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)
...Programmā Express pamatteksta parsētājs GET pieprasījumā atstāj req.body kā tukšu objektu,
tāpēc nododiet Buffer.isBuffer(req.body) ? req.body : Buffer.alloc(0).
Tīmekļa aizķeres režīma rīku nosūtīšana (rīki bez endpoint, kas tiek
piegādāti uz jūsu organizācijas tīmekļa aizķeri kā telephony.tool / web.tool)
ir parasta parakstīta tīmekļa aizķere — piemērojama iepriekš norādītā standarta
procedūra. Abus pieprasījumu formātus skatiet sadaļā
Funkciju rīki.
Mantotais paraksts: X-ThunderPhone-Signature
Sākotnējā galvene ir tikai neapstrādātā pamatteksta heksadecimālais HMAC-SHA256, izmantojot to pašu noslēpumu. Tā nesatur laikspiedolu, tāpēc ikviens, kurš pārtver vienu parakstītu pieprasījumu, var to atkārtoti nosūtīt. Tā joprojām tiek sūtīta ar katru pieprasījumu, kamēr integrācijas pāriet uz V2.
- Nolasiet neapstrādāto pieprasījuma pamattekstu — precīzos baitus, ko jums nosūtījām.
- Aprēķiniet
hmac_sha256(secret, body).hexdigest(). - Salīdziniet konstantā laikā ar
X-ThunderPhone-Signature.
Mēs parakstām tieši tos baitus, kurus pārsūtām. Šie baiti ir arī
slodzes kanoniskā JSON serializācija — atslēgas sakārtotas
alfabētiski, kompakti atdalītāji (, un : bez atstarpēm), UTF-8.
Ja jūsu ietvars nodrošina tikai parsētu JSON, serializējiet to atkārtoti kanoniski un
aprēķiniet tam HMAC:
# Equivalent to hashing the raw body:
import json
canonical = json.dumps(payload, separators=(",", ":"), sort_keys=True).encode("utf-8")Dodiet priekšroku neapstrādātajam pamattekstam — tas ir par vienu soli mazāk un nav pakļauts JSON skaitļu atkārtotas pārveidošanas īpatnībām dažās valodās. Tas pats attiecas uz V2 pamatteksta lauku.
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)
endBiežākās kļūdas
Pulksteņa laika nobīde
5 minūšu periods tiek mērīts pēc jūsu servera pulksteņa. Sinhronizējiet to ar NTP; pulkstenis, kas atpaliek vai steidzas vairāk nekā par 5 minūtēm, noraida ikvienu pieprasījumu.
Starpniekserveris pārraksta ceļu
Parakstītais ceļš ir tas, kuru pieprasījām: URL ceļš, ko konfigurējāt ThunderPhone. Ja reversais starpniekserveris vai API vārteja noņem vai pievieno prefiksu, pirms jūsu lietojumprogramma saņem pieprasījumu, pirms pārbaudes atjaunojiet mūsu nosūtīto ceļu (piemēram, pievienojiet prefiksu atpakaļ).
Atkārtota serializēšana ar noklusējuma formatējumu
Pamatteksta parsēšana un atkārtota serializēšana ar jūsu JSON bibliotēkas
noklusējuma iestatījumiem (atstarpes pēc , / :, ievietošanas secībā sakārtotas atslēgas) rada
atšķirīgus baitus un sabojā HMAC. Pārbaudiet neapstrādāto pamattekstu — vai, ja
tas ir atkārtoti jāserializē, precīzi ievērojiet mūsu kanonisko formu: sakārtotas
atslēgas, kompakti atdalītāji, UTF-8.
Ietvars automātiski parsē JSON
Express express.json() starpprogrammatūra patērē pamatteksta plūsmu,
un jūs zaudējat neapstrādātos baitus. Konkrēti tīmekļa aizķeres maršrutā izmantojiet express.raw()
vai saglabājiet neapstrādāto pamattekstu buferī pirms starpprogrammatūras.
Tas pats attiecas uz NestJS / Koa — skatiet to dokumentāciju par “neapstrādātu pamattekstu”.
Salīdzināšana, kas nav droša pret laika uzbrukumiem
expected === signature JS vai expected == signature valodā
Python ir salīdzinājumi ar mainīgu izpildes laiku. Attiecīgi izmantojiet crypto.timingSafeEqual
vai hmac.compare_digest. Veiktspējas atšķirība
ir niecīga.
Nepareizs noslēpums rīku galapunktiem
Tiešie rīku galapunktu izsaukumi tiek parakstīti ar organizācijas līmeņa tīmekļa aizķeres
noslēpumu (GET /v1/webhook) — nevis ar katra galapunkta noslēpumu
no /v1/developer/webhook-endpoints. Izmantojiet to pašu palīgfunkciju,
bet pārliecinieties, ka rīku maršrutos tai nododat organizācijas noslēpumu.
Vaicājuma virknes GET/DELETE rīkos
V2 paraksta vaicājuma virkni tieši tādu, kāda tā ir nosūtīta. Mantotā galvene paraksta tukšo pamattekstu, bet nekad vaicājuma virkni, tāpēc URL jaucējvērtības aprēķināšana mantotajai galvenei nekad nesakritīs.
Dublikāti periodā
Laikspiedols aptur atkārtotus pieprasījumus, kas ir vecāki par 5 minūtēm, nevis otru kopiju
šajā periodā. Galapunktu piegādes notiek vismaz vienu reizi, tāpēc var tikt saņemts arī
tā paša notikuma atkārtots mēģinājums; novērsiet dublikātus pēc
aploksnes event_id.
401 neatgriešana neatbilstības gadījumā
200 atgriešana neveiksmīgas verifikācijas gadījumā padara apstrādātāju par atkārtotas atskaņošanas uzbrukuma mērķi. Ja verifikācija neizdodas, vienmēr atbildiet ar statusu, kas nav 2xx.
Nākamās darbības
Piegādes semantika, atkārtoti mēģinājumi, avota IP adreses.
Pārvaldiet vairākus URL, mainiet noslēpumus.
Abi rīku izsaukšanas ceļi un to pieprasījumu formas.
Izveidojiet pilnīgu, ar rīkiem nodrošinātu integrāciju no sākuma līdz beigām.