ThunderPhone 2.0 je stigao.Postavite sve sami, već od 2 ¢/min.Pročitajte objavu

Operations

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.

ZaglavljePotpisujeZaštita od ponavljanja
X-ThunderPhone-Signature-V2 (preporučeno)Vremenska oznaka, metoda, putanja, upit i tijeloOdbacite zahtjeve starije od 5 minuta
X-ThunderPhone-SignatureSamo tijeloNema: 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=03a89bec521f78d55727237ca4713bb0b8dcdf2e09e450865b5b5637b80c1c4e
  • t je Unix vrijeme, u sekundama, kada smo poslali zahtjev. Ponovljena isporuka ponovno se potpisuje pri slanju, s vlastitim t.
  • v1 je 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>
PoljeVrijednost
tVrijednost t iz zaglavlja
METHODHTTP metoda velikim slovima: POST, GET, PUT, PATCH ili DELETE
putanja i upitCilj 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
bodyNeobrađeno tijelo zahtjeva. Pozivi alata GET i DELETE nemaju tijelo, pa njihov potpisani niz završava novim retkom nakon putanje

Za provjeru:

  1. Prije bilo kakve obrade pročitajte zaglavlje, metodu, neobrađenu putanju i upit te neobrađeno tijelo.
  2. Iz zaglavlja uzmite t i svaki v1.
  3. Odbijte zahtjev ako je t više od 300 sekundi prije ili nakon vremena na vašem satu.
  4. Izračunajte heksadekadski HMAC-SHA256 potpisanog niza pomoću svoje tajne.
  5. Usporedite ga s v1 u konstantnom vremenu. Odgovorite s 401 ako 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.

Python
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
)
Node.js
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

Python
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)
Node.js
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.

FastAPI
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}
Express
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);
  },
);
Django
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.

IzvorTajna
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-omsecret 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:

  1. Alati GET / DELETE nemaju 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.
  2. Organizacije bez konfiguriranog zastarjelog webhoka nemaju tajnu organizacije. U tom slučaju pozivi alata sadrže samo X-ThunderPhone-Call-ID i nemaju zaglavlja potpisa. Konfigurirajte zastarjeli webhook (PUT /v1/webhook) da biste dobili tajnu za potpisivanje ili autentificirajte pozive alata vlastitim zaglavljem putem endpoint.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.

  1. Pročitajte sirovo tijelo zahtjeva — točne bajtove koje smo vam poslali.
  2. Izračunajte hmac_sha256(secret, body).hexdigest().
  3. 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.

Python
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 "")
Node.js
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),
  );
}
Go
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))
}
Ruby
require "openssl"
 
def verify(body, signature, secret)
  expected = OpenSSL::HMAC.hexdigest("SHA256", secret, body)
  Rack::Utils.secure_compare(expected, signature.to_s)
end

Uobič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.


Sljedeći koraci