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

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

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

Isti pomoćnik verify() radi nepromijenjeno, uz dvije pojedinosti:

  1. Alati GET / DELETE nemaju tijelo. Argumenti se prenose kao parametri upita, a potpis se izračunava nad praznim nizom bajtova — dakle verify(b"", sig, secret) (Python) ili verify(Buffer.alloc(0), sig, secret) (Node). Nemojte hashirati niz upita.
  2. Organizacije bez konfiguriranog naslijeđenog webhooka nemaju tajnu organizacije. U tom slučaju pozivi alata nose samo X-ThunderPhone-Call-ID i nemaju zaglavlje potpisa. Konfigurirajte naslijeđeni webhook (PUT /v1/webhook) da biste dobili tajnu za potpisivanje ili autentificirajte pozive alata vlastitim zaglavljem putem 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)
    ...

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.


Sljedeći koraci