Kontrolli webhooki allkirju

Iga päring, mille sinu serverile saadame — webhooki edastused ja tööriista lõpp-punkti kutsed — sisaldab päises X-ThunderPhone-Signature HMAC-SHA256 allkirja. Seadista kontrollimine üks kord õigesti ja kasuta sama abifunktsiooni igas töötlejas.

Algoritm

  1. Loe päringu töötlemata sisu — täpselt need baidid, mille me sulle POSTisime.
  2. Arvuta hmac_sha256(secret, body).hexdigest().
  3. Võrdle seda konstantse ajaga väärtusega X-ThunderPhone-Signature. (Tavaline stringivõrdlus lekitab ajastusteavet.)

Allkirjastame täpselt need baidid, mille edastame, seega töötlemata sisu kontrollimine toimib alati. Need baidid on ka koormuse kanooniline JSON-i serialisatsioon — võtmed on sorditud tähestikuliselt, eraldajad on kompaktsed (, ja : ilma tühikuteta), kodeering on UTF-8. See annab sulle teise, täiesti samaväärse lahenduse juhuks, kui sinu raamistik pakub ainult parsitud JSON-i: serialiseeri see kanooniliselt uuesti ja arvuta sellele HMAC.

# Equivalent to hashing the raw body:
import json
canonical = json.dumps(payload, separators=(",", ":"), sort_keys=True).encode("utf-8")

Eelista töötlemata sisu — see on üks samm vähem ja väldib mõne keele JSON-i arvude edasi-tagasi teisendamise eripärasid.

Milline saladus?

AllikasSaladus
Webhooki lõpp-punkt (/v1/developer/webhook-endpoints)Lõpp-punktipõhine secret (48 hex-märki), mis tagastatakse loomisel üks kord
Pärandatud ühe URL-iga webhookOrganisatsioonipõhine secret, mis tagastatakse käsuga GET /v1/webhook
Tööriista lõpp-punkti kutse (otsene kutse sinu endpoint.url-ile)Organisatsiooni tasemel webhooki saladus (sama mis pärandatud ühe URL-iga webhookil) — mitte lõpp-punktipõhine saladus

Salvesta saladus saladuste haldurisse või keskkonnamuutujasse — ära kunagi kinnita seda versioonihaldusse.

Võrdlusrakendused

Kõik neli kontrollivad päringu töötlemata sisu:

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

Raamistikupõhine ühendamine

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})

Tööriistakutsete kontrollimine

Kui häälagent kutsub otse välja ühe sinu funktsioonitööriistadest (tööriistal on endpoint), sisaldab päring lisaks sinu seadistatud endpoint.headers-ile kahte ThunderPhone'i päist:

Sama verify() abifunktsioon töötab muutmata kujul, kuid kahe erisusega:

  1. GET / DELETE tööriistadel pole sisu. Argumendid edastatakse päringuparameetritena ja allkiri arvutatakse tühja baidijada põhjal — seega verify(b"", sig, secret) (Python) või verify(Buffer.alloc(0), sig, secret) (Node). Ära räsi päringustringi.
  2. Pärand-webhookita organisatsioonidel pole organisatsiooni saladust. Sel juhul sisaldavad tööriistakutsed ainult X-ThunderPhone-Call-ID-d ja mitte allkirjapäist. Allkirjastamissaladuse saamiseks seadista pärand-webhook (PUT /v1/webhook) või autendi tööriistakutsed oma päisega endpoint.headers kaudu.
@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)
    ...

Webhooki-režiimis tööriistade suunamine (tööriistad ilma endpoint-ita, mis edastatakse sinu organisatsiooni webhooki sündmustena telephony.tool / web.tool) on tavaline allkirjastatud webhook — rakenda ülaltoodud standardset juhist. Mõlema päringukuju kohta vaata funktsioonitööriistade dokumentatsiooni.

Levinud vead

Uuesti serialiseerimine vaikevorminguga

Keha parsimine ja selle uuesti väljastamine JSON-i teegi vaikesätetega (tühikud pärast , / :, sisestusjärjekorras võtmed) tekitab erinevad baidid ja rikub HMAC-i. Kontrolli töötlemata keha — või kui pead selle uuesti serialiseerima, järgi täpselt meie kanoonilist vormi: sorditud võtmed, kompaktsed eraldajad, UTF-8.

Raamistik parsib JSON-i automaatselt

Expressi express.json()-i vahetarkvara tarbib keha voo ja kaotad töötlemata baidid. Kasuta webhooki marsruudil eraldi express.raw()-i või puhverdada töötlemata keha eelnevas vahetarkvaras. Sama kehtib NestJS-i / Koa kohta — vaata nende „töötlemata keha” dokumentatsiooni.

Ajastuse suhtes ebaturvaline võrdlus

expected === signature JS-is või expected == signature Pythonis on ajastusest sõltuvad võrdlused. Kasuta vastavalt crypto.timingSafeEqual või hmac.compare_digest. Jõudluserinevus on olematu.

Tööriista lõpp-punktide jaoks vale salajane võti

Tööriista lõpp-punkti otsesed väljakutsed allkirjastatakse organisatsioonitaseme webhooki salajase võtmega (GET /v1/webhook) — mitte ühegi /v1/developer/webhook-endpoints lõpp-punktipõhise salajase võtmega. Kasuta sama verify() funktsiooni, kuid veendu, et edastad tööriistamarsruutidel organisatsiooni salajase võtme.

Päringustringi räsimine GET/DELETE-tööriistades

Kehata tööriistameetodite puhul katab allkiri tühja baidistringi, säilitades ühe universaalse meetodi: arvuta HMAC päringu töötlemata kehast, olenemata selle sisust. URL-i või päringustringi räsimine ei ühti kunagi.

Vastuolu korral 401 tagastamata jätmine

200 tagastamine ebaõnnestunud kontrolli korral muudab töötleja kordusrünnaku sihtmärgiks. Kui kontroll ebaõnnestub, vasta alati muu olekukoodiga kui 2xx.


Järgmised sammud