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

  1. Preberite surovo telo zahteve — natančne bajte, ki smo jih poslali z metodo POST.
  2. Izračunajte hmac_sha256(secret, body).hexdigest().
  3. 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?

VirSkrivnost
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-jemsecret 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:

Isti pomočnik verify() deluje nespremenjeno, z dvema posebnostma:

  1. Orodja GET / DELETE nimajo telesa. Argumenti se posredujejo kot parametri poizvedbe, podpis pa se izračuna prek praznega bajtnega niza — zato uporabite verify(b"", sig, secret) (Python) ali verify(Buffer.alloc(0), sig, secret) (Node). Niza poizvedbe ne zgoščujte.
  2. Organizacije brez konfiguriranega starejšega webhooka nimajo skrivnosti organizacije. V tem primeru klici orodij vsebujejo samo X-ThunderPhone-Call-ID in nobene glave podpisa. Konfigurirajte starejši webhook (PUT /v1/webhook), da pridobite skrivnost za podpisovanje, ali klice orodij avtenticirajte z lastno glavo prek endpoint.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.


Naslednji koraki