ตรวจสอบลายเซ็น webhook

ทุกคำขอที่เราส่งไปยังเซิร์ฟเวอร์ของคุณ — การส่ง webhook และ การเรียกใช้ tool endpoint — มีลายเซ็น HMAC-SHA256 อยู่ใน ส่วนหัว X-ThunderPhone-Signature ตรวจสอบให้ถูกต้องเพียงครั้งเดียว แล้วนำตัวช่วยเดียวกันไปใช้กับทุก handler

อัลกอริทึม

  1. อ่านเนื้อหาคำขอแบบ ดิบ — ไบต์ที่เราส่ง POST ถึงคุณโดยตรง
  2. คำนวณ hmac_sha256(secret, body).hexdigest()
  3. เปรียบเทียบแบบ เวลาคงที่ กับ X-ThunderPhone-Signature (การเปรียบเทียบสตริงแบบทั่วไปอาจเปิดเผยข้อมูลด้านเวลา)

เราลงลายเซ็นบนไบต์ที่ส่งจริงทุกประการ ดังนั้นการตรวจสอบเนื้อหาแบบดิบ จึงใช้ได้เสมอ ไบต์เหล่านั้นยังเป็น การจัดรูปแบบ JSON แบบมาตรฐาน ของ payload — คีย์เรียงตามตัวอักษร ตัวคั่นแบบกระชับ (, และ : โดยไม่มีช่องว่าง) และ UTF-8 ซึ่งให้วิธีที่สองที่ เทียบเท่ากันอย่างสมบูรณ์เมื่อเฟรมเวิร์กของคุณเปิดเผยเฉพาะ JSON ที่แยกวิเคราะห์แล้ว: จัดรูปแบบใหม่ตามมาตรฐานแล้วสร้าง HMAC จากข้อมูลนั้น

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

ควรใช้เนื้อหาแบบดิบ — มีขั้นตอนน้อยกว่าและไม่รับผลกระทบจาก ความผิดปกติของการแปลงเลข JSON ไปกลับในบางภาษา

ต้องใช้ secret ใด

แหล่งที่มาSecret
Webhook endpoint (/v1/developer/webhook-endpoints)secret เฉพาะ endpoint (48 อักขระ hex) ที่ส่งคืนเพียงครั้งเดียวเมื่อสร้าง
Webhook URL เดียวแบบเดิมsecret เฉพาะองค์กรที่ส่งคืนจาก GET /v1/webhook
การเรียกใช้ Tool endpoint (เรียกโดยตรงไปยัง endpoint.url ของคุณ)Webhook secret ระดับองค์กร (ตัวเดียวกับ webhook URL เดียวแบบเดิม) — ไม่ใช่ secret เฉพาะ endpoint

จัดเก็บ secret ในตัวจัดการ secret หรือตัวแปรสภาพแวดล้อม — ห้าม commit โดยเด็ดขาด

ตัวอย่างการติดตั้งใช้งาน

ทั้งสี่ตัวอย่างตรวจสอบเนื้อหาคำขอแบบดิบ:

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

การเชื่อมต่อเฉพาะเฟรมเวิร์ก

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

การตรวจสอบการเรียกใช้เครื่องมือ

เมื่อเอเจนต์เรียกใช้ เครื่องมือฟังก์ชัน รายการใดรายการหนึ่งของคุณโดยตรง (เครื่องมือนั้นมี endpoint) คำขอจะมีส่วนหัว ThunderPhone สองรายการ พร้อมกับ endpoint.headers ที่คุณกำหนดค่าไว้:

ตัวช่วย verify() เดียวกันนี้ใช้ได้โดยไม่ต้องเปลี่ยนแปลง โดยมีข้อแตกต่างสองประการ:

  1. เครื่องมือ GET / DELETE ไม่มีเนื้อหา อาร์กิวเมนต์จะส่งเป็นพารามิเตอร์คิวรี และลายเซ็นจะคำนวณจาก สตริงไบต์ว่าง ดังนั้นให้ใช้ verify(b"", sig, secret) (Python) หรือ verify(Buffer.alloc(0), sig, secret) (Node) อย่าแฮช สตริงคิวรี
  2. องค์กรที่ไม่ได้กำหนดค่าเว็บฮุกแบบเดิมจะไม่มีข้อมูลลับระดับองค์กร ในกรณีนั้น การเรียกใช้เครื่องมือจะมีเพียง X-ThunderPhone-Call-ID และไม่มี ส่วนหัวลายเซ็น กำหนดค่าเว็บฮุกแบบเดิม (PUT /v1/webhook) เพื่อรับข้อมูลลับสำหรับการลงลายเซ็น หรือยืนยันตัวตนการเรียกใช้เครื่องมือ ด้วยส่วนหัวของคุณเองผ่าน 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)
    ...

การส่งต่อเครื่องมือในโหมดเว็บฮุก (เครื่องมือที่ไม่มี endpoint ซึ่งส่งไปยัง เว็บฮุกองค์กรของคุณในรูปแบบ telephony.tool / web.tool) คือเว็บฮุกที่ลงลายเซ็นตามปกติ ให้ใช้ขั้นตอนมาตรฐานข้างต้น ดู เครื่องมือฟังก์ชัน สำหรับรูปแบบคำขอทั้งสองแบบ

ข้อควรระวังที่พบบ่อย

ซีเรียลไลซ์ใหม่ด้วยรูปแบบเริ่มต้น

การแยกวิเคราะห์เนื้อหาแล้วส่งออกอีกครั้งด้วยการตั้งค่าเริ่มต้นของไลบรารี JSON (เว้นวรรคหลัง , / :, คีย์เรียงตามลำดับการแทรก) จะสร้าง ไบต์ที่ต่างออกไปและทำให้ HMAC ใช้งานไม่ได้ ตรวจสอบเนื้อหาดิบ หรือหาก จำเป็นต้องซีเรียลไลซ์ใหม่ ให้ใช้รูปแบบมาตรฐานของเราให้ตรงกันทุกประการ: คีย์เรียงลำดับ ตัวคั่นแบบกระชับ UTF-8

เฟรมเวิร์กแยกวิเคราะห์ JSON โดยอัตโนมัติ

มิดเดิลแวร์ express.json() ของ Express จะใช้สตรีมเนื้อหา ทำให้คุณสูญเสียไบต์ดิบ ใช้ express.raw() กับเส้นทาง webhook โดยเฉพาะ หรือบัฟเฟอร์เนื้อหาดิบใน pre-middleware NestJS / Koa ก็เช่นเดียวกัน โปรดดูเอกสารเกี่ยวกับ "เนื้อหาดิบ" ของแต่ละเฟรมเวิร์ก

การเปรียบเทียบที่ไม่ปลอดภัยต่อการวัดเวลา

expected === signature ใน JS หรือ expected == signature ใน Python เป็นการเปรียบเทียบที่ใช้เวลาไม่คงที่ ให้ใช้ crypto.timingSafeEqual หรือ hmac.compare_digest ตามลำดับ ความแตกต่างด้านประสิทธิภาพ ไม่มีนัยสำคัญ

ใช้ secret ผิดสำหรับปลายทางเครื่องมือ

การเรียกปลายทางเครื่องมือโดยตรงจะลงลายเซ็นด้วย webhook secret ระดับองค์กร (GET /v1/webhook) ไม่ใช่ secret เฉพาะปลายทาง จาก /v1/developer/webhook-endpoints ใช้ฟังก์ชัน verify() เดิมซ้ำได้ แต่ต้องแน่ใจว่าได้ส่ง secret ระดับองค์กรให้ฟังก์ชันในเส้นทางเครื่องมือ

แฮช query string สำหรับเครื่องมือ GET/DELETE

สำหรับเมธอดเครื่องมือที่ไม่มีเนื้อหา ลายเซ็นจะครอบคลุมสตริงไบต์ว่าง เพื่อคงวิธีการเดียวที่ใช้ได้ทุกกรณี: ทำ HMAC กับเนื้อหาคำขอดิบ ไม่ว่าจะเป็นอะไร การแฮช URL หรือ query string จะไม่ตรงกันเสมอ

ไม่ส่งคืน 401 เมื่อลายเซ็นไม่ตรงกัน

การส่งคืน 200 เมื่อการตรวจสอบล้มเหลวทำให้ตัวจัดการเป็นเป้าหมาย ของการโจมตีแบบ replay ตอบกลับด้วยสถานะที่ไม่ใช่ 2xx เสมอหากการตรวจสอบล้มเหลว


ขั้นตอนถัดไป