Проверите потписе веб-хукова

Сваки захтев који шаљемо Вашем серверу — испоруке webhook догађаја и позиви крајњих тачака алатки — садржи HMAC-SHA256 потпис у заглављу X-ThunderPhone-Signature. Једном исправно подесите верификацију и укључите исти помоћни програм у сваки обрађивач.

Алгоритам

  1. Прочитајте необрађено тело захтева — тачне бајтове које смо Вам послали POST захтевом.
  2. Израчунајте hmac_sha256(secret, body).hexdigest().
  3. Упоредите у константном времену са X-ThunderPhone-Signature. (Наивно поређење стрингова открива информације о времену.)

Потписујемо тачно бајтове које преносимо, па верификација необрађеног тела увек функционише. Ти бајтови су такође канонска JSON серијализација садржаја — кључеви поређани по абецеди, компактни раздвајачи (, и : без размака), UTF-8. То Вам пружа други, потпуно еквивалентан поступак када Ваш оквир приказује само рашчлањени JSON: поново га серијализујте канонски и израчунајте HMAC над њим.

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

Предност дајте необрађеном телу — има један корак мање и отпорно је на особености поновног претварања JSON бројева у неким језицима.

Која тајна?

ИзворТајна
Webhook крајња тачка (/v1/developer/webhook-endpoints)secret за сваку крајњу тачку (48 хексадецималних знакова), враћа се једном при креирању
Застарели webhook са једним URL-омsecret за сваку организацију, враћа се при GET /v1/webhook
Позив крајње тачке алатке (директан позив Вашег endpoint.url)Webhook тајна на нивоу организације (иста као за застарели webhook са једним URL-ом) — не тајна за појединачну крајњу тачку

Сачувајте тајну у менаџеру тајни или променљивој окружења — никада је не отпремајте у репозиторијум.

Референтне имплементације

Све четири верификују необрађено тело захтева:

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 okvire

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

Verifikacija poziva alata

Kada агент direktno pozove jedan od Ваших funkcijskih alata (alat ima endpoint), zahtev uz Vaše konfigurisane endpoint.headers sadrži dva ThunderPhone zaglavlja:

Isti pomoćnik verify() radi neizmenjeno, uz dve razlike:

  1. GET / DELETE alati nemaju telo. Argumenti se prosleđuju 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 heširati string upita.
  2. Organizacije bez konfigurisanog nasleđenog webhooka nemaju tajnu organizacije. U tom slučaju pozivi alata sadrže samo X-ThunderPhone-Call-ID, bez zaglavlja potpisa. Konfigurišite nasleđeni webhook (PUT /v1/webhook) da biste dobili tajnu za potpisivanje ili autentifikujte pozive alata sopstvenim 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)
    ...

Prosleđivanje poziva alata u režimu webhooka (alati bez endpoint, isporučeni webhooku Vaše organizacije kao telephony.tool / web.tool) jeste običan potpisani webhook — primenjuje se standardni postupak iznad. Pogledajte Funkcijski alati za oba oblika zahteva.

Уобичајене замке

Поновна серијализација са подразумеваним форматирањем

Парсирање тела и његово поновно исписивање са подразумеваним подешавањима ваше JSON библиотеке (размаци после , / :, кључеви у редоследу уметања) даје различите бајтове и онемогућава HMAC. Проверите сирово тело — или, ако морате поново да га серијализујете, тачно ускладите наш канонски облик: сортирани кључеви, компактни раздвајачи, UTF-8.

Оквир аутоматски парсира JSON

Express-ов express.json() посреднички софтвер троши ток тела и губите сирове бајтове. Користите express.raw() посебно на webhook рути или баферујте сирово тело у претходном посредничком софтверу. Исто важи за NestJS / Koa — проверите њихову документацију за „сирово тело“.

Поређење небезбедно у погледу времена

expected === signature у JS или expected == signature у Python-у су поређења са променљивим временом извршавања. Користите crypto.timingSafeEqual односно hmac.compare_digest. Разлика у перформансама је занемарљива.

Погрешна тајна за крајње тачке алата

Позиви директно ка крајњим тачкама алата потписују се помоћу webhook тајне на нивоу организације (GET /v1/webhook) — а не било којом тајном по крајњој тачки из /v1/developer/webhook-endpoints. Поново користите исту функцију verify(), али проверите да ли јој прослеђујете тајну организације на рутама алата.

Хеширање ниске упита на GET/DELETE алатима

За методе алата без тела, потпис обухвата празан низ бајтова, чиме се задржава један универзални поступак: примените HMAC на сирово тело захтева, какво год да је. Хеширање URL-а или ниске упита се никада неће поклопити.

Невраћање 401 при неподударању

Враћање 200 при неуспешној верификацији чини обрађивач метом за поновљене захтеве. Увек одговорите кодом који није 2xx ако верификација не успе.


Следећи кораци